measuredco / puck

The visual editor for React
https://puckeditor.com
MIT License
4.82k stars 243 forks source link

Add fieldsets and tabs to fields API #64

Open chrisvxd opened 10 months ago

chrisvxd commented 10 months ago

Currently, we have no way to create groups of fields. #62 adds object support, and spun out this discussion.

Inline fieldsets at the top-level (with headings) are something I've considered as a way to break the form up into separate areas of related fields. It strikes me that the Heading scenario here might also work with an inline Heading fieldset.

Proposals

Option 1

Add renderFields method

Example adding a Style fieldset to Heading

export const Heading: ComponentConfig<HeadingProps> = {
  renderFields: () => {
    return <>
      <Field name="text" />
      <Field name="level" />
      <Fieldset title="Style">
        <Field name="size" />
        <Field name="align" />
        <Field name="padding" />
      </Fieldset>
    </>
  },
  fields: {
    text: { type: "text" },
    size: {
      type: "select",
      options: sizeOptions,
    },
    level: {
      type: "select",
      options: levelOptions,
    },
    align: {
      type: "radio",
      options: [
        { label: "Left", value: "left" },
        { label: "Center", value: "center" },
        { label: "Right", value: "right" },
      ],
    },
    padding: { type: "text" },
  },
}

Option 2

Change fields API or add new fieldsets API

export const Heading: ComponentConfig<HeadingProps> = {
  fieldsets: [
    {
      title: "",
      fields: {
        text: { type: "text" },
        level: {
          type: "select",
          options: levelOptions,
        },
      },
    },
    {
      title: "Style",
      fields: {
        size: {
          type: "select",
          options: sizeOptions,
        },

        align: {
          type: "radio",
          options: [
            { label: "Left", value: "left" },
            { label: "Center", value: "center" },
            { label: "Right", value: "right" },
          ],
        },
        padding: { type: "text" },
      },
    },
  ],

Option 3

Something else; could be some combination of the above or another proposal.


Related #62

Danm72 commented 10 months ago

This is the way we currently model our page data, maybe its worth also considering multiple tabs within the right panel e.g content, styles, etc

On the above Option 2 definitely reads better to me. Option 1 sort of feels like a ceremony that can be skipped. https://github.com/measuredco/puck/assets/1926968/ad9c3e97-512d-43e7-95ef-e729db571b2f

chrisvxd commented 10 months ago

Interesting! I've spoken to @monospaced and I think we leaning the other way towards Option 1 (renderFields).

In terms of ceremony, you could merge the fields and renderFields APIs when using renderFields to reduce repetition.

But this model also means we can support tabs and other compositional UI in the future:

export const Heading: ComponentConfig<HeadingProps> = {
  renderFields: () => {
    return (
      <Tabs>
        <Tabs.Tab label="Main tab">
          <Field name="text" type="text" />
          <Field name="level" type="select" options={levelOptions} />
          <Fieldset title="Style">
            <Field name="size" type="select" options={sizeOptions} />
            <Field name="align" type="radio" options={alignOptions} />
            <Field name="padding" type="text" />
          </Fieldset>
        </Tabs.Tab>

        <Tabs.Tab label="Secondary tab">
          Content here
        </Tabs.Tab>
      </Tabs>
    );
  },
};
chrisvxd commented 10 months ago

Going with renderFields. Marking as ready for dev.

chrisvxd commented 6 months ago

It's been sometime since we decided on option 1, and we may wish to revisit that decision.

jperasmus commented 6 months ago

Throwing my 2 cents into the discussion. Consider the following component config for "field groups" that follows a very similar pattern to how component categories are configured:

export const Heading: ComponentConfig<HeadingProps> = {
  fields: {
    heading: {
      type: "text",
    },
    level: {
      type: "select",
      label: "Level (1-6)",
      options: [
        { label: "Heading 1", value: "1" },
        { label: "Heading 2", value: "2" },
        { label: "Heading 3", value: "3" },
        { label: "Heading 4", value: "4" },
        { label: "Heading 5", value: "5" },
        { label: "Heading 6", value: "6" },
      ],
    },
    textColor: {
      type: "custom",
      render: TextColorField,
    },
    backgroundColor: {
      type: "custom",
      render: BackgroundColorField,
    },
    fontFamily: {
      type: "custom",
      render: FontFamilyField,
    },
    fontSize: {
      type: "custom",
      render: FontSizeField,
    },
    fontStyle: {
      type: "custom",
      render: FontStyleField,
    },
    // ...more fields
  },
  fieldGroups: {
    basics: {
      title: "Basics",
      fields: ["heading", "level"],
    },
    typography: {
      title: "Typography",
      fields: ["fontFamily", "fontSize", "fontStyle"],
    },
    other: {
      title: "Other",
    },
  },
  defaultProps: {},
  render() {},
}

This is backwards compatible since nothing changes within the fields config object. When no fieldGroups object is provided it just renders as-is.

Sidenote: the reason I prefer fieldGroups over fieldset is that fieldset is ambiguous to me because it is an existing HTML element.

Another advantage of this approach is that you can guarantee the order of the fields within a group by using the fieldGroups.{field}.fields array. More modern versions of JavaScript don't seem to have too many inconsistencies, but I've been burned in the past assuming the order would be consistent when using objects when it wasn't. Another option (probably outside of this topic) is to introduce an order prop that could be used to specify the order of each field, fieldGroup, etc within the config.

To take this a step further, the tabs concept can also be implemented with an optional tabs addition to the config.

export const Heading: ComponentConfig<HeadingProps> = {
  fields: {
    heading: {
      type: "text",
    },
    level: {
      type: "select",
      label: "Level (1-6)",
      options: [
        { label: "Heading 1", value: "1" },
        { label: "Heading 2", value: "2" },
        { label: "Heading 3", value: "3" },
        { label: "Heading 4", value: "4" },
        { label: "Heading 5", value: "5" },
        { label: "Heading 6", value: "6" },
      ],
    },
    textColor: {
      type: "custom",
      render: TextColorField,
    },
    backgroundColor: {
      type: "custom",
      render: BackgroundColorField,
    },
    fontFamily: {
      type: "custom",
      render: FontFamilyField,
    },
    fontSize: {
      type: "custom",
      render: FontSizeField,
    },
    fontStyle: {
      type: "custom",
      render: FontStyleField,
    },
    // ...more fields
  },
  fieldGroups: {
    basics: {
      title: "Basics",
      fields: ["heading", "level"],
    },
    typography: {
      title: "Typography",
      fields: ["fontFamily", "fontSize", "fontStyle"],
    },
    other: {
      title: "Other",
    },
  },
+  tabs: {
+    settings: {
+      title: "Settings",
+      groups: ["basics", "typography"],
+    },
+    other: {
+      title: "Other",
+      groups: ["other"],
+    },
+  },
  defaultProps: {},
  render() {},
}

Each of these different configs can be overridden using the overrides API: