diff --git a/packages/@react-spectrum/s2/package.json b/packages/@react-spectrum/s2/package.json index c631e2beaf8..74c0a29fcda 100644 --- a/packages/@react-spectrum/s2/package.json +++ b/packages/@react-spectrum/s2/package.json @@ -136,6 +136,7 @@ "@react-aria/interactions": "^3.25.0", "@react-aria/live-announcer": "^3.4.2", "@react-aria/overlays": "^3.27.0", + "@react-aria/separator": "^3.4.8", "@react-aria/utils": "^3.28.2", "@react-spectrum/utils": "^3.12.4", "@react-stately/layout": "^4.2.2", diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index 99439d91e38..bbb69b968ba 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -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,112 @@ const iconStyles = style({ } }); +export let listbox = style<{size: 'S' | 'M' | 'L' | 'XL'}>({ + 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: '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 .', + '. . . description .' + ], + 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' + }, + 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... +const separatorWrapper = style({ + display: { + ':is(:last-child > *)': 'none', + default: 'flex' + }, + // A workaround since edgeToText() returns undefined for some reason + 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 +412,27 @@ export const ComboBox = /*#__PURE__*/ (forwardRef as forwardRefType)(function Co })}> - - {children} - + + + {children} + + @@ -327,7 +442,6 @@ export const ComboBox = /*#__PURE__*/ (forwardRef as forwardRefType)(function Co ); }); - export interface ComboBoxItemProps extends Omit, StyleProps { children: ReactNode } @@ -349,7 +463,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 +498,52 @@ export function ComboBoxSection(props: ComboBoxSectionProps return ( <> + {...props}> {props.children} - + ); } + +export function Divider(props: SeparatorProps & {size?: 'S' | 'M' | 'L' | 'XL' | undefined}): ReactNode { + return ( + + ); +} + +const Separator = /*#__PURE__*/ createLeafComponent('separator', function Separator(props: SeparatorProps & {size?: 'S' | 'M' | 'L' | 'XL'}, ref: ForwardedRef) { + [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 ( +
+ +
+ ); +}); + diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index ea3a15d66d5..f3bbcf9e1a1 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -23,26 +23,30 @@ 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 + label } 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, @@ -403,19 +407,27 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick })(props)}> - - {children} - + + + {children} + + @@ -446,7 +458,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 ( @@ -489,11 +501,10 @@ export function PickerSection(props: PickerSectionProps): R return ( <> + {...props}> {props.children} - + ); } diff --git a/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx b/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx index 802b7971840..e1f931163fe 100644 --- a/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx +++ b/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx @@ -99,6 +99,28 @@ export const Dynamic: Story = { }; +function VirtualizedCombobox(props) { + let items: IExampleItem[] = []; + for (let i = 0; i < 10000; i++) { + items.push({id: i.toString(), label: `Item ${i}`}); + } + + return ( + + {(item) => {(item as IExampleItem).label}} + + ); +} + +export const ManyItems: Story = { + render: (args) => ( + + ), + args: { + label: 'Many items' + } +}; + export const WithIcons: Story = { render: (args) => ( diff --git a/packages/@react-spectrum/s2/stories/Picker.stories.tsx b/packages/@react-spectrum/s2/stories/Picker.stories.tsx index 0520c236b9e..8b514fc9b6e 100644 --- a/packages/@react-spectrum/s2/stories/Picker.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Picker.stories.tsx @@ -131,6 +131,28 @@ export const WithIcons: Story = { } }; +function VirtualizedPicker(props) { + let items: IExampleItem[] = []; + for (let i = 0; i < 10000; i++) { + items.push({id: i.toString(), label: `Item ${i}`}); + } + + return ( + + {(item) => {(item as IExampleItem).label}} + + ); +} + +export const ManyItems: Story = { + render: (args) => ( + + ), + args: { + label: 'Many items' + } +}; + const ValidationRender = (props) => (
diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index 999bd12b82e..96dbfb868d5 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -301,6 +301,7 @@ export class ListLayout exte let layoutNode = this.buildNode(node, x, y); layoutNode.layoutInfo.parentKey = parentKey ?? null; + layoutNode.layoutInfo.allowOverflow = true; this.layoutNodes.set(node.key, layoutNode); return layoutNode; } @@ -315,6 +316,8 @@ export class ListLayout exte return this.buildSectionHeader(node, x, y); case 'loader': return this.buildLoader(node, x, y); + case 'separator': + return this.buildItem(node, x, y); default: throw new Error('Unsupported node type: ' + node.type); } diff --git a/yarn.lock b/yarn.lock index 37f6db6fb06..62f07046e46 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7944,6 +7944,7 @@ __metadata: "@react-aria/interactions": "npm:^3.25.0" "@react-aria/live-announcer": "npm:^3.4.2" "@react-aria/overlays": "npm:^3.27.0" + "@react-aria/separator": "npm:^3.4.8" "@react-aria/test-utils": "npm:1.0.0-alpha.3" "@react-aria/utils": "npm:^3.28.2" "@react-spectrum/utils": "npm:^3.12.4"