-
Notifications
You must be signed in to change notification settings - Fork 1.2k
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
base: main
Are you sure you want to change the base?
Changes from 13 commits
27260f4
f8b1745
b272469
7a2877c
5c423d4
fdf423f
711dff6
286d8b8
0938fb9
5fc224f
18c65b2
85b7edb
7f57506
d493fc4
a69a794
4523888
3052001
7f062b8
0c3a3b4
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 |
---|---|---|
|
@@ -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. | ||
|
@@ -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: { | ||
// 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'}); | ||
|
||
/** | ||
|
@@ -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> | ||
|
@@ -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 | ||
} | ||
|
@@ -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 ( | ||
|
@@ -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>) { | ||
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 |
||
[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.
if anyone knows why edgeToText isn't working, let me know...
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.
alternatively, i could do something like this which seems to work and wouldn't define the space using pixels: