Skip to content

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

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/@react-spectrum/s2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@
"@react-aria/i18n": "^3.12.8",
"@react-aria/interactions": "^3.25.0",
"@react-aria/live-announcer": "^3.4.2",
"@react-aria/separator": "^3.4.8",
"@react-aria/utils": "^3.28.2",
"@react-spectrum/utils": "^3.12.4",
"@react-stately/layout": "^4.2.2",
Expand Down
225 changes: 199 additions & 26 deletions packages/@react-spectrum/s2/src/ComboBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,43 +22,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.
Expand Down Expand Up @@ -147,6 +148,130 @@ const iconStyles = style({
}
});

export let listbox = style({
width: 'full',
boxSizing: 'border-box',
maxHeight: '[inherit]',
overflow: 'auto',
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
default: {
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
gridColumnStart: 1,
gridColumnEnd: -1,
display: 'grid',
gridTemplateAreas: [
'. checkmark icon label value keyboard descriptor .',
'. . . description . . . .'
],
gridTemplateColumns: {
size: {
S: [edgeToText(24), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(24)],
M: [edgeToText(32), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(32)],
L: [edgeToText(40), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(40)],
XL: [edgeToText(48), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', 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'
},
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(),
display: 'grid',
gridTemplateAreas: [
'. checkmark icon label value keyboard descriptor .',
'. . . description . . . .'
],
gridTemplateColumns: {
size: {
S: [edgeToText(24), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(24)],
M: [edgeToText(32), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(32)],
L: [edgeToText(40), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(40)],
XL: [edgeToText(48), 'auto', 'auto', 'minmax(0, 1fr)', 'auto', 'auto', 'auto', edgeToText(48)]
}
},
rowGap: {
':has([slot=description])': space(1)
}
});

export let listboxHeading = style({
font: 'ui',
fontWeight: 'bold',
margin: 0,
gridColumnStart: 2,
gridColumnEnd: -2
});

// not sure why edgeToText won't work...
const separatorWrapper = style({
display: {
':is(:last-child > &)': 'none',
default: 'flex'
},
// marginX: {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if anyone knows why edgeToText isn't working, let me know...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alternatively, i could do something like this which seems to work and wouldn't define the space using pixels:

  marginX: {
    size: {
      S: '[calc(var(--marginSpace) * 3 / 8)]',
      M: '[calc(var(--marginSpace) * 3 / 8)]',
      L: '[calc(var(--marginSpace) * 3 / 8)]',
      XL: '[calc(var(--marginSpace) * 3 / 8)]'
    }
  },
  '--marginSpace': {
    type: 'marginStart',
    value: {
      size: {
        S: 24,
        M: 32,
        L: 40,
        XL: 48
      }
    }

// size: {
// S: edgeToText(24),
// M: edgeToText(32),
// L: edgeToText(40),
// XL: edgeToText(48)
// }
// },
marginX: {
size: {
S: '[calc(24 * 3 / 8)]',
M: '[calc(32 * 3 / 8)]',
L: '[calc(40 * 3 / 8)]',
XL: '[calc(48 * 3 / 8)]'
}
},
height: 12
});

let InternalComboboxContext = createContext<{size: 'S' | 'M' | 'L' | 'XL'}>({size: 'M'});

/**
Expand Down Expand Up @@ -304,19 +429,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}>
{children}
</ListBox>
</Virtualizer>
</Provider>
</PopoverBase>
</InternalComboboxContext.Provider>
Expand All @@ -326,7 +459,6 @@ export const ComboBox = /*#__PURE__*/ (forwardRef as forwardRefType)(function Co
);
});


export interface ComboBoxItemProps extends Omit<ListBoxItemProps, 'children' | 'style' | 'className'>, StyleProps {
children: ReactNode
}
Expand All @@ -348,7 +480,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 (
Expand Down Expand Up @@ -383,11 +515,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({
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>) {
Copy link
Member Author

Choose a reason for hiding this comment

The 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

[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>
);
});

43 changes: 28 additions & 15 deletions packages/@react-spectrum/s2/src/Picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,26 +23,31 @@ import {
ListBoxItem,
ListBoxItemProps,
ListBoxProps,
ListLayout,
Provider,
SectionProps,
SelectValue
SelectValue,
Virtualizer
} from 'react-aria-components';
import {baseColor, edgeToText, focusRing, style} from '../style' with {type: 'macro'};
import {centerBaseline} from './CenterBaseline';
import {
checkmark,
description,
Divider,
icon,
iconCenterWrapper,
label,
menuitem,
section,
sectionHeader,
sectionHeading
section
} from './Menu';
import CheckmarkIcon from '../ui-icons/Checkmark';
import ChevronIcon from '../ui-icons/Chevron';
import {
Divider,
listbox,
listboxHeader,
listboxHeading,
listboxItem
} from './ComboBox';
import {field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'};
import {
FieldErrorIcon,
Expand Down Expand Up @@ -403,19 +408,27 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick
})(props)}>
<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
}}>
<ListBox
items={items}
className={listbox}>
{children}
</ListBox>
</Virtualizer>

</Provider>
</PopoverBase>
</InternalPickerContext.Provider>
Expand Down Expand Up @@ -446,7 +459,7 @@ export function PickerItem(props: PickerItemProps): 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 (
Expand Down Expand Up @@ -493,7 +506,7 @@ export function PickerSection<T extends object>(props: PickerSectionProps<T>): R
className={section({size})}>
{props.children}
</AriaListBoxSection>
<Divider />
<Divider size={size} />
</>
);
}
Loading