-
Notifications
You must be signed in to change notification settings - Fork 1.3k
feat: add virtualization to S2 combobox and picker #8110
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
Changes from 34 commits
27260f4
f8b1745
b272469
7a2877c
5c423d4
fdf423f
711dff6
286d8b8
0938fb9
5fc224f
18c65b2
85b7edb
7f57506
d493fc4
a69a794
4523888
3052001
7f062b8
0c3a3b4
85440fc
48caf94
379fba7
160e9a0
fac1920
77b4b62
1bbd69c
bebc44b
9976091
9c51d0d
ebba44e
cb52f58
112df59
3cbb286
923e260
01ead63
3ae8f29
dd0a3f7
c5ddd4d
08437c7
2ba7e3d
cabcf11
6b45081
ad58e65
176868a
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 |
---|---|---|
|
@@ -23,43 +23,44 @@ import { | |
ListBoxItem, | ||
ListBoxItemProps, | ||
ListBoxProps, | ||
ListLayout, | ||
Provider, | ||
SectionProps | ||
SectionProps, | ||
SeparatorContext, | ||
SeparatorProps, | ||
useContextProps, | ||
Virtualizer | ||
} from 'react-aria-components'; | ||
import {baseColor, style} from '../style' with {type: 'macro'}; | ||
import {baseColor, edgeToText, focusRing, space, style} from '../style' with {type: 'macro'}; | ||
import {centerBaseline} from './CenterBaseline'; | ||
import {centerPadding, field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; | ||
import { | ||
checkmark, | ||
description, | ||
Divider, | ||
icon, | ||
iconCenterWrapper, | ||
label, | ||
menuitem, | ||
section, | ||
sectionHeader, | ||
sectionHeading | ||
label | ||
} from './Menu'; | ||
import CheckmarkIcon from '../ui-icons/Checkmark'; | ||
import ChevronIcon from '../ui-icons/Chevron'; | ||
import {createContext, CSSProperties, forwardRef, ReactNode, Ref, useCallback, useContext, useImperativeHandle, useRef, useState} from 'react'; | ||
import {createContext, CSSProperties, ElementType, ForwardedRef, forwardRef, ReactNode, Ref, useCallback, useContext, useImperativeHandle, useRef, useState} from 'react'; | ||
import {createFocusableRef} from '@react-spectrum/utils'; | ||
import {field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; | ||
import {createLeafComponent} from '@react-aria/collections'; | ||
import {divider} from './Divider'; | ||
import {FieldErrorIcon, FieldGroup, FieldLabel, HelpText, Input} from './Field'; | ||
import {filterDOMProps, mergeRefs, useResizeObserver} from '@react-aria/utils'; | ||
import {FormContext, useFormProps} from './Form'; | ||
import {forwardRefType} from './types'; | ||
import {HeaderContext, HeadingContext, Text, TextContext} from './Content'; | ||
import {HelpTextProps, SpectrumLabelableProps} from '@react-types/shared'; | ||
import {IconContext} from './Icon'; | ||
import {menu} from './Picker'; | ||
import {mergeRefs, useResizeObserver} from '@react-aria/utils'; | ||
import {Placement} from 'react-aria'; | ||
import {mergeStyles} from '../style/runtime'; | ||
import {Placement, useSeparator} from 'react-aria'; | ||
import {PopoverBase} from './Popover'; | ||
import {pressScale} from './pressScale'; | ||
import {TextFieldRef} from '@react-types/textfield'; | ||
import {useSpectrumContextProps} from './useSpectrumContextProps'; | ||
|
||
|
||
export interface ComboboxStyleProps { | ||
/** | ||
* The size of the Combobox. | ||
|
@@ -148,6 +149,114 @@ const iconStyles = style({ | |
} | ||
}); | ||
|
||
export let listbox = style<{size: 'S' | 'M' | 'L' | 'XL'}>({ | ||
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. What are the differences between the styles here and the ones in menu? A bit unfortunate to duplicate them 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. Mainly that menu has some additional styles that Combobox and Picker didn't need but it wasn't actually affecting anything. I could probably revert the change and just use menu but I'd need to double check |
||
width: 'full', | ||
boxSizing: 'border-box', | ||
maxHeight: '[inherit]', | ||
// TODO: Might help with horizontal scrolling happening on Windows, will need to check somehow. Otherwise, revert back to overflow: auto | ||
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. what is the todo here? was something else being tried? 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. It was something that I thought might fix the issue with the horizontal scrollbar but I'm unable to see if it actually works since I can't reproduce the original issue 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'll make a note to test this tmrw on my windows machine 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'm unable to reproduce the original issue as well on Chrome/FF, probably ok for now then |
||
overflowY: 'auto', | ||
overflowX: 'hidden', | ||
fontFamily: 'sans', | ||
fontSize: 'control' | ||
}); | ||
|
||
export let listboxItem = style({ | ||
...focusRing(), | ||
boxSizing: 'border-box', | ||
borderRadius: 'control', | ||
font: 'control', | ||
'--labelPadding': { | ||
type: 'paddingTop', | ||
value: centerPadding() | ||
}, | ||
paddingBottom: '--labelPadding', | ||
backgroundColor: { // TODO: revisit color when I have access to dev mode again | ||
yihuiliao marked this conversation as resolved.
Show resolved
Hide resolved
|
||
default: 'transparent', | ||
isFocused: baseColor('gray-100').isFocusVisible | ||
}, | ||
color: { | ||
default: 'neutral', | ||
isDisabled: { | ||
default: 'disabled', | ||
forcedColors: 'GrayText' | ||
} | ||
}, | ||
position: 'relative', | ||
// each menu item should take up the entire width, the subgrid will handle within the item | ||
yihuiliao marked this conversation as resolved.
Show resolved
Hide resolved
|
||
gridColumnStart: 1, | ||
gridColumnEnd: -1, | ||
display: 'grid', | ||
gridTemplateAreas: [ | ||
'. checkmark icon label .', | ||
'. . . description .' | ||
yihuiliao marked this conversation as resolved.
Show resolved
Hide resolved
|
||
], | ||
gridTemplateColumns: { | ||
size: { | ||
S: [edgeToText(24), 'auto', 'auto', 'minmax(0, 1fr)', edgeToText(24)], | ||
M: [edgeToText(32), 'auto', 'auto', 'minmax(0, 1fr)', edgeToText(32)], | ||
L: [edgeToText(40), 'auto', 'auto', 'minmax(0, 1fr)', edgeToText(40)], | ||
XL: [edgeToText(48), 'auto', 'auto', 'minmax(0, 1fr)', edgeToText(48)] | ||
} | ||
}, | ||
gridTemplateRows: { | ||
// min-content prevents second row from 'auto'ing to a size larger then 0 when empty | ||
default: 'auto minmax(0, min-content)', | ||
':has([slot=description])': 'auto auto' | ||
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. pull out into constant? seems to be the common pattern we're adopting |
||
}, | ||
rowGap: { | ||
':has([slot=description])': space(1) | ||
}, | ||
alignItems: 'baseline', | ||
minHeight: 'control', | ||
height: 'min', | ||
textDecoration: 'none', | ||
cursor: { | ||
default: 'default', | ||
isLink: 'pointer' | ||
}, | ||
transition: 'default' | ||
}, getAllowedOverrides()); | ||
|
||
export let listboxHeader = style<{size?: 'S' | 'M' | 'L' | 'XL'}>({ | ||
color: 'neutral', | ||
boxSizing: 'border-box', | ||
minHeight: 'control', | ||
paddingY: centerPadding(), | ||
marginX: { | ||
size: { | ||
S: `[${edgeToText(24)}]`, | ||
M: `[${edgeToText(32)}]`, | ||
L: `[${edgeToText(40)}]`, | ||
XL: `[${edgeToText(48)}]` | ||
} | ||
} | ||
}); | ||
|
||
export let listboxHeading = style({ | ||
fontSize: 'ui', | ||
fontWeight: 'bold', | ||
lineHeight: 'ui', | ||
margin: 0 | ||
}); | ||
|
||
// not sure why edgeToText won't work... | ||
yihuiliao marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const separatorWrapper = style({ | ||
display: { | ||
':is(:last-child > *)': 'none', | ||
default: 'flex' | ||
}, | ||
// A workaround since edgeToText() returns undefined for some reason | ||
yihuiliao marked this conversation as resolved.
Show resolved
Hide resolved
|
||
marginX: { | ||
size: { | ||
S: `[${edgeToText(24)}]`, | ||
M: `[${edgeToText(32)}]`, | ||
L: `[${edgeToText(40)}]`, | ||
XL: `[${edgeToText(48)}]` | ||
} | ||
}, | ||
height: 12 | ||
}); | ||
|
||
let InternalComboboxContext = createContext<{size: 'S' | 'M' | 'L' | 'XL'}>({size: 'M'}); | ||
|
||
/** | ||
|
@@ -305,19 +414,27 @@ export const ComboBox = /*#__PURE__*/ (forwardRef as forwardRefType)(function Co | |
})}> | ||
<Provider | ||
values={[ | ||
[HeaderContext, {styles: sectionHeader({size})}], | ||
[HeadingContext, {styles: sectionHeading}], | ||
[HeaderContext, {styles: listboxHeader({size})}], | ||
[HeadingContext, {styles: listboxHeading}], | ||
[TextContext, { | ||
slots: { | ||
'description': {styles: description({size})} | ||
} | ||
}] | ||
]}> | ||
<ListBox | ||
items={items} | ||
className={menu({size})}> | ||
{children} | ||
</ListBox> | ||
<Virtualizer | ||
layout={ListLayout} | ||
layoutOptions={{ | ||
estimatedRowHeight: 32, | ||
padding: 8, | ||
estimatedHeadingHeight: 50 | ||
}}> | ||
<ListBox | ||
items={items} | ||
className={listbox({size})}> | ||
{children} | ||
</ListBox> | ||
</Virtualizer> | ||
</Provider> | ||
</PopoverBase> | ||
</InternalComboboxContext.Provider> | ||
|
@@ -327,7 +444,6 @@ export const ComboBox = /*#__PURE__*/ (forwardRef as forwardRefType)(function Co | |
); | ||
}); | ||
|
||
|
||
export interface ComboBoxItemProps extends Omit<ListBoxItemProps, 'children' | 'style' | 'className'>, StyleProps { | ||
children: ReactNode | ||
} | ||
|
@@ -349,7 +465,7 @@ export function ComboBoxItem(props: ComboBoxItemProps): ReactNode { | |
ref={ref} | ||
textValue={props.textValue || (typeof props.children === 'string' ? props.children as string : undefined)} | ||
style={pressScale(ref, props.UNSAFE_style)} | ||
className={renderProps => (props.UNSAFE_className || '') + menuitem({...renderProps, size, isLink}, props.styles)}> | ||
className={renderProps => (props.UNSAFE_className || '') + listboxItem({...renderProps, size, isLink}, props.styles)}> | ||
{(renderProps) => { | ||
let {children} = props; | ||
return ( | ||
|
@@ -384,11 +500,52 @@ export function ComboBoxSection<T extends object>(props: ComboBoxSectionProps<T> | |
return ( | ||
<> | ||
<AriaListBoxSection | ||
{...props} | ||
className={section({size})}> | ||
{...props}> | ||
{props.children} | ||
</AriaListBoxSection> | ||
<Divider /> | ||
<Divider size={size} /> | ||
</> | ||
); | ||
} | ||
|
||
export function Divider(props: SeparatorProps & {size?: 'S' | 'M' | 'L' | 'XL' | undefined}): ReactNode { | ||
return ( | ||
<Separator | ||
{...props} | ||
className={mergeStyles( | ||
divider({ | ||
yihuiliao marked this conversation as resolved.
Show resolved
Hide resolved
|
||
size: 'M', | ||
orientation: 'horizontal', | ||
isStaticColor: false | ||
}, style({alignSelf: 'center', width: 'full'})))} /> | ||
); | ||
} | ||
|
||
const Separator = /*#__PURE__*/ createLeafComponent('separator', function Separator(props: SeparatorProps & {size?: 'S' | 'M' | 'L' | 'XL'}, ref: ForwardedRef<HTMLElement>) { | ||
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. it does feel a little overkill to import separator separately but it's at least a pretty simple component ...i can probably simplify the code since this isn't being used anywhere else except for picker + combobox. given that it's not interactive and just a visual thing, i don't think it has any accessibility requirements. otherwise, i experimented a little bit with adding a bottom border which would work i think? i would need to play around with the linear-gradient so that the visible border aligns with the item text and it'd require some additional targeting of first-child/last-child but i think it should be possible. that said, not sure if it's worth the time figuring this out if this current solution works 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. What is the reason we can't use the S2 Divider which already renders the RAC Separator which is a LeafComponent? 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. No, it was that it wasn't measuring the height correctly. We had some additional padding on the divider that wasn't being accounted for when measuring the layout. By copying over the S2 Divider, wrapping it in a div, we could account for the height correctly. The reason I had to copy it over was because if I just wrapped the S2 Divider in a div, it would throw an error. I had to do it inside the I tried updating the ListLayout to have a separatorHeight but the reason that didn't work out was because even if we had display: 'none' set on the last separator, the collection would still account for the separatorHeight so there was additional empty space on the bottom that shouldn't have been there. In that case, we would need it to be an estimatedSeparatorHeight and involve slightly different logic similar to buildItem but I didn't test that out. Overall, we decided that it was probably not worth our time spending too much time on just a separator so this is what we came up with. 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. what was the reason for the extra div? where was the extra padding coming from? 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. actually I think ListBox doesn't even allow separators. aXe currently fails on S2 prod storybook. In v3 we overrode the role to presentation, but RAC doesn't support that. Any reason it needs to be a separate element at all? Can it just be a border-bottom on the section? 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. Or actually it was margins, not padding. It was coming from here. The extra div was so I could set an explicit height. We could potentially use border-bottom instead, but in order to align the separator with the margins we have on the menu items + heading on the left + right, we'd probably need something like border-image. For example, if we added a border (this just added it to the top, very basic, no styling, for demonstration purposes), understandably, the length of it is the width of the section. However, the separator has a length that is less than the width of the section. Right now, I'm able to account for that by adding a margin with edgeToText(). I suppose we could just calculate it with a percentage tho? ![]() 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. Since it doesn't need to be an actual divider, can we make a stripped down version like this? const dividerStyle = style({
backgroundColor: {
default: 'gray-200',
forcedColors: 'ButtonBorder'
},
borderRadius: 'full',
height: '[2px]',
width: 'full'
});
export const Divider = /*#__PURE__*/ createLeafComponent('separator', function Divider({size}: {size?: 'S' | 'M' | 'L' | 'XL'}, ref: ForwardedRef<HTMLDivElement>) {
return (
<div className={separatorWrapper({size})}>
<div ref={ref} className={dividerStyle} />
</div>
);
}); needs 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. yep, happy to simplify it |
||
[props, ref] = useContextProps(props, ref, SeparatorContext); | ||
|
||
let {elementType, orientation, size, style, className, slot, ...otherProps} = props; | ||
let Element = (elementType as ElementType) || 'hr'; | ||
if (Element === 'hr' && orientation === 'vertical') { | ||
Element = 'div'; | ||
} | ||
|
||
let {separatorProps} = useSeparator({ | ||
...otherProps, | ||
elementType, | ||
orientation | ||
}); | ||
|
||
return ( | ||
<div className={separatorWrapper({size})}> | ||
<Element | ||
{...filterDOMProps(props)} | ||
{...separatorProps} | ||
style={style} | ||
className={className ?? 'react-aria-Separator'} | ||
ref={ref} | ||
slot={slot || undefined} /> | ||
</div> | ||
); | ||
}); | ||
|
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.
is this going to be problematic for users of v3 in their own tests?
will we need to call out in release?
Uh oh!
There was an error while loading. Please reload this page.
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.
Probably something we'd like to call out but we also we have this section in are docs where we do mock the scrollHeight for virtualized components
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.
just my 2 cents, would be good to call out in the release notes. Just spent some time trying to figure out why one of my Picker tests were failing and it turned out it also needed this scrollHeight mock lol