Skip to content

A consistent component API for @wordpress/components #33391

@diegohaz

Description

@diegohaz

I'd say the most important thing in API design is having consistent interfaces across different modules, especially when they're related and can be combined together. This lowers the learning curve, making it easier to use other modules when you're already familiar with one of them.

Unfortunately, a solid set of patterns that works consistently across all components in a component library is something really difficult to achieve. I've experienced this with Reakit, but it took a few years before we were able to come up with a set of patterns that would work for all modules in the library, and it's still not completely done. I started writing about this in 2018 on Introducing the Single Element Pattern and then gave a more technical update on that last year on this talk at React Finland.

And this is even more challenging on WordPress components given the components are designed to be more higher-level, which I agree is the right approach here. But it's easier to design component APIs when components render single HTML elements and you don't have to think about passing props to internal elements and customizing how they're rendered, not to mention React Native.

Because of that, instead of trying to include all components, I think we should take those that are related and can be combined together and make their APIs more similar.

Dropdown (or Flyout)

Currently, we have a Popover component that works as a generic element that can be positioned next to an anchor element or DOMRect. The anchor element can be passed via the anchorRef prop. The DOMRect can be passed via the anchorRect or getAnchorRect props.

We also have the Dropdown component, which uses the generic Popover underneath, but also renders a toggle button via the renderToggle prop.

Finally, we're experimenting with a Flyout component that looks similar to Popover and Dropdown, but with a completely different API. The goal here is to find a concise API similar to the Dropdown component API (with very few deprecations), but, ideally, we would use the Flyout implementation.

<Dropdown label="Label" icon={ icon }>
    <p>Content</p>
</Dropdown>

Aside from custom Dropdown props, all the props passed to the Dropdown component, including label and icon, would be passed over to the internal toggle button, which would render a Button by default. This makes the toggleProps prop obsolete.

The snippet above would be rendered to the DOM as something like this (simplified):

<button id="button" aria-label="Label"><svg /></button>
<div role="dialog" aria-labelledby="button">
    <p>Content</p>
</div>

Controlled components

Currently, we have different ways to control the state of the Popover components. The generic Popover component has an onClose prop. Dropdown has onClose and onToggle.

Dropdown also accepts render props like renderToggle and renderContent that pass isOpen, onClose and onToggle as arguments to the render functions:

const args = { isOpen, onToggle: toggle, onClose: close };

Instead of relying on so many different ways to control the state, we can make semi-controlled components similar to how React deals with form inputs:

const [ isOpen, setIsOpen ] = useState( false );

<Dropdown text="Visible label" isOpen={ isOpen } onToggle={ setIsOpen }>
    <p>Content</p>
</Dropdown>

This makes the renderToggle and renderContent props obsolete as we wouldn't need them to access the internal state anymore and the content could just be passed as children.

popoverProps

popoverProps would remain the same.

<Dropdown popoverProps={ { className: 'my-popover' } } />

Menu (or DropdownMenu)

Currently, the DropdownMenu has a controls prop. I suggest we rename the component to Menu to match the role="menu" attribute and deprecate the controls prop in favor of MenuItems passed as children. But DropdownMenu would work as well.

<Menu label="Label" icon={ icon }>
    <MenuItem label="Label" icon={ icon } />
    <MenuItem label="Label" icon={ icon } />
    <MenuItem label="Label" icon={ icon } />
    <MenuItem label="Label" icon={ icon } />
</Menu>

The MenuItem component already exists and follow the same API as Button, so we don't need to change anything here aside from connecting it to the Menu component (through React Context) to provide a roving tabindex navigation similar to the Toolbar component.

The snippet above would be rendered to the DOM as something like this (simplified):

<button id="button" aria-label="Label"><svg /></button>
<div role="menu" aria-labelledby="button">
    <button role="menuitem" aria-label="Label"><svg /></button>
    <button role="menuitem" aria-label="Label"><svg /></button>
    <button role="menuitem" aria-label="Label"><svg /></button>
    <button role="menuitem" aria-label="Label"><svg /></button>
</div>

Sub menus

Because Menu would use Dropdown, and Dropdown and MenuItem has similar APIs, we could make Menu aware of parent menus and render it as a sub menu:

<Menu label="Label" icon={ icon }>
    <MenuItem label="Label" icon={ icon } />
    <MenuItem label="Label" icon={ icon } />
    <MenuItem label="Label" icon={ icon } />
    <Menu label="Label" icon={ icon }>
        <MenuItem label="Label" icon={ icon } />
        <MenuItem label="Label" icon={ icon } />
        <MenuItem label="Label" icon={ icon } />
        <MenuItem label="Label" icon={ icon } />
    </Menu>
</Menu>

Toolbar

Finally, we can combine all those components together in the toolbar. We should provide components like ToolbarDropdown and ToolbarMenu that would render the toggle button as a toolbar item automatically:

<Toolbar label="Label">
    <ToolbarButton label="Label" icon={ icon } />
    <ToolbarButton label="Label" icon={ icon } />
    <ToolbarDropdown label="Label" icon={ icon }>
        <p>Content</p>
    </ToolbarDropdown>
    <ToolbarMenu label="Label" icon={ icon }>
        <MenuItem label="Label" icon={ icon } />
        <MenuItem label="Label" icon={ icon } />
        <MenuItem label="Label" icon={ icon } />
        <MenuItem label="Label" icon={ icon } />
    </ToolbarMenu>
</Toolbar>

The Toolbar component already follows this API, so we don't need to change many things there.

How to connect those components to Toolbar?

The components would be connected through the headless ToolbarItem component that already exists. ToolbarItem passes down all the props necessary to render a toolbar item while being agnostic about which component will be rendered (it doesn't require it to be a Button, for example). Here's an example of how the ToolbarDropdown component could be created:

function ToolbarDropdown( props, ref ) {
  return <ToolbarItem as={ Dropdown } ref={ ref } { ...props} />;
}

This is possible because we would pass the toolbar item props down to the toggle button on the Dropdown component. We can leverage the same technique if we want to connect other components too. For example, we may have a headless MenuItem component (maybe with another name to not conflict with the existing MenuItem) to make any component a menu item.

Metadata

Metadata

Assignees

No one assigned

    Labels

    [Package] Components/packages/components[Type] DiscussionFor issues that are high-level and not yet ready to implement.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions