-
Notifications
You must be signed in to change notification settings - Fork 2.8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
RFC: update slots API to allow arrays #19266
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,165 @@ | ||||||
RFC: Array slots | ||||||
|
||||||
--- | ||||||
|
||||||
@smhigley | ||||||
|
||||||
## Summary | ||||||
|
||||||
This RFC proposes modifying the Slots API to accept arrays of slot props to support components like Combobox or List, where the most ergonomic authoring experience is passing in an array of data rather than unique JSX children. | ||||||
|
||||||
## Problem Statement | ||||||
|
||||||
So far, all slot usage in vNext components assumes the desired authoring experience is to define slots or children one at a time. That makes sense for the components we've created so far, since none of them render a set of elements that are usually generated from an array of data. Even for Menu, which contains a list of MenuItem children, it's not common for authors to want to convert an array of repetitive data into menu items. | ||||||
|
||||||
In contrast, Combobox and Listbox patterns do commonly take an array of data and use it to render mostly uniform items. The same is likely going to be true of components like List (if we have one) and DetailsList/Grid. For those controls, good authoring ergonomics lean more towards this: | ||||||
|
||||||
```js | ||||||
<Combobox options={['apple', 'banana', 'cucumber', ...etc]} /> | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Many combobox/dropdown examples that I have seen in Teams, were not this library friendly. Most items could not just be mapped to simple strings, but also required icons, specific layout changes, styles overrides etc.. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @ling1726 thanks for the feedback! Does it help at all that There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For the RFC I would propose that it also provides some code samples of these more complex |
||||||
``` | ||||||
|
||||||
Than this: | ||||||
|
||||||
```js | ||||||
<Combobox> | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we update this example to showcase usage? IMO this comparison will be more accurate: const options = ["apple", "banana", "cucumber"];
function App() {
return (
<>
<Combobox options={options} />
{/* VS. */}
<Combobox>
{options.map(item => (
<Option key={item}>{item}</Option>
))}
</Combobox>
</>
);
} It's very similar to what React docs are showing: https://reactjs.org/docs/lists-and-keys.html#basic-list-component There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The const options = ["apple", "banana", "cucumber"];
function App() {
const comboboxItems = options.map(item => (<Option key={item.id}>{item.name}</Option>))
return (
<>
<Combobox>
{comboboxItems}
</Combobox>
</>
);
} There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I prefer this approach to having two props of |
||||||
<Option>apple</Option> | ||||||
<Option>banana</Option> | ||||||
<Option>cluster fig</Option> | ||||||
[...etc] | ||||||
</Combobox> | ||||||
``` | ||||||
|
||||||
Accepting an array of slot data isntead of children will also make it easier to internally handle virtualization in the future. | ||||||
|
||||||
## Detailed Proposal | ||||||
|
||||||
Modify `getSlots` and `resolveShorthand` to allow authors to pass in an array of slot data for certain slots. | ||||||
|
||||||
Taking Combobox as an example, the authoring experience would look like this: | ||||||
|
||||||
```js | ||||||
// using strings to define the slots: | ||||||
<Combobox options={['apple', 'banana', 'chokecherry']} icon={<Chevron />} ..etc /> | ||||||
|
||||||
// using slot props | ||||||
<Combobox options={optionData.map((option, i) => ({ 'data-index': i, children: option.name, title: 'tooltips are awful' }))} icon={<Chevron />} ..etc /> | ||||||
``` | ||||||
|
||||||
The Combobox component would then handle `options` like this: | ||||||
|
||||||
within `renderCombobox`: | ||||||
|
||||||
```js | ||||||
const { slots, slotProps } = getSlots < ComboboxSlots > (state, ['options']); | ||||||
return ( | ||||||
<slots.root {...slotProps.root}> | ||||||
{slots.options.map((Option, i) => { | ||||||
return <Option key={`option-${i}`} {...slotProps.options[i]} />; | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using Index as a key is an anti-pattern in React:
https://reactjs.org/docs/lists-and-keys.html#keys |
||||||
})} | ||||||
</slots.root> | ||||||
); | ||||||
``` | ||||||
|
||||||
within `useCombobox`: | ||||||
|
||||||
```js | ||||||
const state: ComboboxState = { | ||||||
role: 'combobox', | ||||||
tabIndex: 0, | ||||||
...etc, | ||||||
components: { | ||||||
root: 'div', | ||||||
options: 'div', | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One drawback here is that you can't specify a different component for each entry in the array. In this case, every option is a This comment is mostly an FYI because I think I have a decent solution that modifies how this // Currently requires a components={...} object separate from the slot value
<MyButton components={{ label: MyLabel }} label="foo" />
<MyButton components={{ label: 'span' }} label={{ className: 'baz', children: 'bar' }} />
// Proposal: slots could accept a tuple of [ type, shorthandValue ]
<MyButton label={[ MyLabel, 'foo' ]} />
<MyButton label={[ 'span', { className: 'baz', children: 'bar' } ]} /> That would naturally allow a different component to be specified per slot in this RFC: <Combobox options={[
[GroupHeader, 'Fruits'],
'apple',
'banana',
[GroupHeader, 'Vegetables'],
'carrot',
'daikon',
]} /> |
||||||
}, | ||||||
options: resolveShorthandArray(props.options, { | ||||||
role: 'option', | ||||||
...etc, | ||||||
}), | ||||||
}; | ||||||
``` | ||||||
|
||||||
Then `resolveShorthandArray` would be exported from `resolveShorthand.ts` as something like this: | ||||||
|
||||||
```js | ||||||
export function resolveShorthandArray<Props extends Record<string, any>>( | ||||||
value: ShorthandProps<Props>[], | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You'd also want to accept
Suggested change
|
||||||
defaultProps?: Props, | ||||||
): ObjectShorthandProps<Props>[] { | ||||||
if (value === null || !Array.isArray(value)) { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you take my suggestion above, you'd also need to check for undefined (or just falsy). Also, (nit) remove the isArray check; that should be handled by typescript.
Suggested change
|
||||||
return [] as ObjectShorthandProps<Props>[]; | ||||||
} | ||||||
|
||||||
return value.map((singleValue) => resolveShorthand(singleValue, defaultProps)); | ||||||
} | ||||||
``` | ||||||
|
||||||
`getSlots` would be modified to handle array slots by adding a check to see if the slot is an array: | ||||||
|
||||||
```js | ||||||
if (Array.isArray(slot) { | ||||||
slots[name] = slot.map((subSlot) => { | ||||||
// run through the same logic getSlots currently uses to standardize slot data | ||||||
}) | ||||||
} | ||||||
``` | ||||||
One downside to this solution is it that in order for an author to define a set of props that appear on every option, they'll need to duplicate that prop object for each option in `props.options`. We could potentially consider adding another prop like `defaultOption: React.HTMLAttributes<HTMLElement>` to make that easier. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it's ok to start by requiring authors to manually apply a prop to each entry for now (e.g. using |
||||||
## Discarded Solutions | ||||||
Another option I looked into was keeping the slot itself singular, and rendering it inside a loop: | ||||||
In `renderCombobox`: | ||||||
```js | ||||||
const { slots, slotProps } = getSlots < ComboboxSlots > (state, ['option']); | ||||||
// resolveOptionProps is a way to add option-specific props like aria-selected or id based on the index | ||||||
const { optionData, resolveOptionProps } = state; | ||||||
|
||||||
return ( | ||||||
<slots.root {...slotProps.root}> | ||||||
{optionData.map((option, i) => { | ||||||
return <slots.option key={`option-${i}`} {...slotProps.option} {...resolveOptionProps(option, i)} />; | ||||||
})} | ||||||
</slots.root> | ||||||
); | ||||||
``` | ||||||
Then `useCombobox` handles `option` and `resolveOptionProps` something like this: | ||||||
```js | ||||||
const state: ComboboxState = { | ||||||
role: 'combobox', | ||||||
tabIndex: 0, | ||||||
...etc, | ||||||
components: { | ||||||
root: 'div', | ||||||
options: 'div', | ||||||
}, | ||||||
options: resolveShorthand(props.option, { | ||||||
role: 'option', | ||||||
...etc, | ||||||
}), | ||||||
}; | ||||||
|
||||||
const resolveOptionProps = React.useCallback( | ||||||
(option: string | Object, index: number) => { | ||||||
return resolveShorthand(props.option, { | ||||||
id: `${id}-${index}`, | ||||||
'aria-selected': index === activeIndex, | ||||||
//simplified code, but this allows a custom per-option render: | ||||||
children: props.renderOption ? props.renderOption(option) : option, | ||||||
}); | ||||||
}, | ||||||
[activeIndex], | ||||||
); | ||||||
state.resolveOptionProps = resolveOptionProps; | ||||||
``` | ||||||
The authoring experience would look like this: | ||||||
```js | ||||||
<Combobox option={{ 'some-default-prop-on-all-options': 'test' }} data={['apple', 'banana', 'cauliflower']}> | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Having been a web-dev using OUIF, this kind of shorthand API was extremely unfriendly to deal with especially when you need to support features like:
OUIF was very well known to have a lot of |
||||||
``` | ||||||
The downside here is it leans back towards custom `renderX` props, and doesn't seem in the spirit of the slot pattern. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is how we started in Semantic UI React and we decided to use the same API in Northstar, we call it shorthand collections. When we started there were no other way to pass props from parent to children components:
From our POV this API worked well as it's really simple:
However, usage in complex applications that are our main audience is different: data comes from different sources, lists are huge and there strict performance requirements. Below is a summary of feedback that we received from Northstar customers.
Keys are hard
First of all, customers should use keys (which is not usually obvious in examples):
There is no easy option how generate keys for customers in automated way, they should pass it. Check a comment from @levithomason in Semantic-Org/Semantic-UI-React#2418 (comment), it's a good summary from library POV.
Performance issues (memoization)
"My items are really expensive to render, how I memoize them?"
Items can really expensive to re-render especially in components like
Dropdown
,Menu
,List
& etc. A simpleAvatar
from Teams is below, it's even without status 😱One of the frequent asks that we had was about memoization, we have an entry in our FAQ. Developers want to implement their features and expect that library will work intuitively for them. But it does not seem to be true from an example below:
Performance issues (redundant rendering)
Another feedback that we received is related to a fact that customers should compute their items in advance for menus (or dropdowns, etc.) even before they are visible.
It could be cheap to get data for the items itself, but each item can contain a lot of additional info required to render: user's state, description, etc. For example:
A short demo of this behavior is available on this CodeSandbox:
getUserStatus()
before a menu is openedgetUserStatus()
only when a menu is openedPerformance issues (root of update is moved to the parent)
Another issue that we met is related to a fact that a parent component is a root of rendering. It's highly coupled to a previous section. See an example below:
Demo of this behavior on CodeSandbox, check output in console:
Bundle size penalty
If I don't use
DropdownItem
from Fluent, it will be bundled anyway withDropdown
as it calls it internally:Virtualization
Components like
Dropdown
(Combobox
) usually have filtering (client search) on client-side this means that input items are different from output items:This means that virtualization component should be put inside
Dropdown
to know about its state, so we will need to add moreprops
only for it:It's one of reasons why we refactored
Tree
component in Northstar to hooks, it was to complex and not enough customisable.BTW, a I don't think that we should include virtualization into components by default:
Libraries that were designed after React hooks appeared also use JSX children in the most cases:
This happened mainly because of React Context & hooks, these APIs simplified sharing data & handlers between parent and children components. We also use
use-context-selector
to avoid redundant updates in child components, there is a good showcase for it in Northstar docs (only items with updated state are re-rendered).For components like
Combobox
I suggest to keep going with JSX items. We will meet edge cases likeTree
component where items are nested and it will be impossible to use JSX items, but I am considering this as an exception rather than a rule.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the detailed comment! The issues with performance and memoization are great to bring up. I'm curious about the best way to approach some of the issues I ran into when implementing Combobox with children, especially now that the Descendants API has been removed.
Essentially, the biggest issue is that the parent control (or a parent context) needs to be aware of the number of child options present, as well as their key/id values so that the active item can be controlled. The parent is the one that receives the keyboard events, and the one that needs to set the current item's
id
in an attribute, so it needs the ability to get the previous/next/first/last/etc child'sid
in response to user interaction.Theoretically this could work with
React.children
, as long as it's a flat list. The issue there is something like this:Potentially we could do something like create a context that gives each option the ability to register/deregister itself by id, though it would be difficult to ensure we have the ids in the correct order. There's maybe also something we can do with
React.Children
in both Combobox and Group, so Combobox's context ends up with the correct set of current options. No matter what we do, the parent is going to need to update any time the descendants array updates, just based on how activedescendant works. There's likely a clever solution to this somewhere that I haven't been able to think of yet 😊.For memoization, since we're dealing with slot arrays, I also wonder if authoring like this might work:
I need to test it more, but it seems like that might still allow delayed updates and allowing the author to update one child without affecting the others. I'm definitely not confident on that approach yet, I'd definitely need to test it more (or you might know offhand if there's a reason it wouldn't fly). It definitely makes managing Combobox's state easier though 😅.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Virtualization is also a really interesting case, since I think even with options as children, the virtualization provider would likely need to live inside
Combobox
as a slot, since Combobox itself also needs to know what the total number of options is vs. the rendered number.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Chatted @smhigley and @behowell about this RFC. After pondering a bit, I think having a slot that takes an array of literals, props objects, or Components is what I would expect as consistent with singular slots in other components. A common scenario will be to pass in the array of props and be able to have a Component that is given each of the those props as a template. What if there was an itemAs property that behaved like as (literal tag name, component, or render function) that would be used for each item? This extends the as metaphor from single slots to a slot with an array.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With the v9 hooks/render func composition architecture, I would have expected users to simply create their own component variants and wrap children with the virtualization provider