diff --git a/packages/@react-aria/focus/stories/FocusScope.stories.tsx b/packages/@react-aria/focus/stories/FocusScope.stories.tsx index 2cbb7b274a4..e8beb3b7d93 100644 --- a/packages/@react-aria/focus/stories/FocusScope.stories.tsx +++ b/packages/@react-aria/focus/stories/FocusScope.stories.tsx @@ -278,3 +278,34 @@ export const FocusableInputForm: FocusScopeStoryObj = { } } }; + +export let ContainsHiddenElement: FocusScopeStoryObj = { + render: (args) => ( + + + + + + ), + args: { + contain: true + }, + argTypes: { + contain: { + control: 'boolean' + }, + restoreFocus: { + control: 'boolean' + }, + autoFocus: { + control: 'boolean' + } + }, + parameters: { + description: { + data: ` +Should be able to tab navigate from the first input to the last input. +` + } + } +}; diff --git a/packages/@react-aria/select/src/HiddenSelect.tsx b/packages/@react-aria/select/src/HiddenSelect.tsx index 1900d6231ae..61e9455b571 100644 --- a/packages/@react-aria/select/src/HiddenSelect.tsx +++ b/packages/@react-aria/select/src/HiddenSelect.tsx @@ -10,9 +10,10 @@ * governing permissions and limitations under the License. */ -import {FocusableElement, RefObject} from '@react-types/shared'; +import {FocusableElement, Key, RefObject} from '@react-types/shared'; import React, {InputHTMLAttributes, JSX, ReactNode, useCallback, useRef} from 'react'; import {selectData} from './useSelect'; +import {SelectionMode} from '@react-types/select'; import {SelectState} from '@react-stately/select'; import {useFormReset} from '@react-aria/utils'; import {useFormValidation} from '@react-aria/form'; @@ -41,9 +42,9 @@ export interface AriaHiddenSelectProps { isDisabled?: boolean } -export interface HiddenSelectProps extends AriaHiddenSelectProps { +export interface HiddenSelectProps extends AriaHiddenSelectProps { /** State for the select. */ - state: SelectState, + state: SelectState, /** A ref to the trigger element. */ triggerRef: RefObject @@ -70,7 +71,7 @@ export interface HiddenSelectAria { * can be used in combination with `useSelect` to support browser form autofill, mobile form * navigation, and native HTML form submission. */ -export function useHiddenSelect(props: AriaHiddenSelectOptions, state: SelectState, triggerRef: RefObject): HiddenSelectAria { +export function useHiddenSelect(props: AriaHiddenSelectOptions, state: SelectState, triggerRef: RefObject): HiddenSelectAria { let data = selectData.get(state) || {}; let {autoComplete, name = data.name, form = data.form, isDisabled = data.isDisabled} = props; let {validationBehavior, isRequired} = data; @@ -83,14 +84,23 @@ export function useHiddenSelect(props: AriaHiddenSelectOptions, state: Select } }); - useFormReset(props.selectRef, state.defaultSelectedKey, state.setSelectedKey); + useFormReset(props.selectRef, state.defaultValue, state.setValue); useFormValidation({ validationBehavior, focus: () => triggerRef.current?.focus() }, state, props.selectRef); - // eslint-disable-next-line react-hooks/exhaustive-deps - let onChange = useCallback((e: React.ChangeEvent | React.FormEvent) => state.setSelectedKey(e.currentTarget.value), [state.setSelectedKey]); + let setValue = state.setValue; + let onChange = useCallback((e: React.ChangeEvent) => { + if (e.target.multiple) { + setValue(Array.from( + e.target.selectedOptions, + (option) => option.value + ) as any); + } else { + setValue(e.currentTarget.value as any); + } + }, [setValue]); // In Safari, the whereas other browsers @@ -114,10 +124,11 @@ export function useHiddenSelect(props: AriaHiddenSelectOptions, state: Select tabIndex: -1, autoComplete, disabled: isDisabled, + multiple: state.selectionManager.selectionMode === 'multiple', required: validationBehavior === 'native' && isRequired, name, form, - value: state.selectedKey ?? '', + value: (state.value as string | string[]) ?? '', onChange, onInput: onChange } @@ -128,7 +139,7 @@ export function useHiddenSelect(props: AriaHiddenSelectOptions, state: Select * Renders a hidden native ` rather than + // so that an empty value blocks HTML form submission when the field is required. + return ( + {/** Ignore react warning. */}} /> + ); + } - if (validationBehavior === 'native') { - // Use a hidden rather than - // so that an empty value blocks HTML form submission when the field is required. return ( - {/** Ignore react warning. */}} /> + ); - } + }); - return ( - - ); + return <>{res}; } return null; diff --git a/packages/@react-aria/select/src/useSelect.ts b/packages/@react-aria/select/src/useSelect.ts index 38aee11f510..daebc1d3910 100644 --- a/packages/@react-aria/select/src/useSelect.ts +++ b/packages/@react-aria/select/src/useSelect.ts @@ -12,7 +12,7 @@ import {AriaButtonProps} from '@react-types/button'; import {AriaListBoxOptions} from '@react-aria/listbox'; -import {AriaSelectProps} from '@react-types/select'; +import {AriaSelectProps, SelectionMode} from '@react-types/select'; import {chain, filterDOMProps, mergeProps, useId} from '@react-aria/utils'; import {DOMAttributes, KeyboardDelegate, RefObject, ValidationResult} from '@react-types/shared'; import {FocusEvent, useMemo} from 'react'; @@ -24,7 +24,7 @@ import {useCollator} from '@react-aria/i18n'; import {useField} from '@react-aria/label'; import {useMenuTrigger} from '@react-aria/menu'; -export interface AriaSelectOptions extends Omit, 'children'> { +export interface AriaSelectOptions extends Omit, 'children'> { /** * An optional keyboard delegate implementation for type to select, * to override the default. @@ -32,7 +32,7 @@ export interface AriaSelectOptions extends Omit, 'children keyboardDelegate?: KeyboardDelegate } -export interface SelectAria extends ValidationResult { +export interface SelectAria extends ValidationResult { /** Props for the label element. */ labelProps: DOMAttributes, @@ -52,7 +52,7 @@ export interface SelectAria extends ValidationResult { errorMessageProps: DOMAttributes, /** Props for the hidden select element. */ - hiddenSelectProps: HiddenSelectProps + hiddenSelectProps: HiddenSelectProps } interface SelectData { @@ -63,7 +63,7 @@ interface SelectData { validationBehavior?: 'aria' | 'native' } -export const selectData: WeakMap, SelectData> = new WeakMap, SelectData>(); +export const selectData: WeakMap, SelectData> = new WeakMap, SelectData>(); /** * Provides the behavior and accessibility implementation for a select component. @@ -71,7 +71,7 @@ export const selectData: WeakMap, SelectData> = new WeakMap(props: AriaSelectOptions, state: SelectState, ref: RefObject): SelectAria { +export function useSelect(props: AriaSelectOptions, state: SelectState, ref: RefObject): SelectAria { let { keyboardDelegate, isDisabled, @@ -96,6 +96,10 @@ export function useSelect(props: AriaSelectOptions, state: SelectState, ); let onKeyDown = (e: KeyboardEvent) => { + if (state.selectionManager.selectionMode === 'multiple') { + return; + } + switch (e.key) { case 'ArrowLeft': { // prevent scrolling containers @@ -138,6 +142,9 @@ export function useSelect(props: AriaSelectOptions, state: SelectState, typeSelectProps.onKeyDown = typeSelectProps.onKeyDownCapture; delete typeSelectProps.onKeyDownCapture; + if (state.selectionManager.selectionMode === 'multiple') { + typeSelectProps = {}; + } let domProps = filterDOMProps(props, {labelable: true}); let triggerProps = mergeProps(typeSelectProps, menuTriggerProps, fieldProps); diff --git a/packages/@react-aria/test-utils/src/select.ts b/packages/@react-aria/test-utils/src/select.ts index 57dda4173c7..4cce164f53f 100644 --- a/packages/@react-aria/test-utils/src/select.ts +++ b/packages/@react-aria/test-utils/src/select.ts @@ -184,6 +184,8 @@ export class SelectTester { throw new Error('Target option not found in the listbox.'); } + let isMultiSelect = listbox.getAttribute('aria-multiselectable') === 'true'; + if (interactionType === 'keyboard') { if (option?.getAttribute('aria-disabled') === 'true') { return; @@ -203,7 +205,7 @@ export class SelectTester { } } - if (option?.getAttribute('href') == null) { + if (!isMultiSelect && option?.getAttribute('href') == null) { await waitFor(() => { if (document.activeElement !== this._trigger) { throw new Error(`Expected the document.activeElement after selecting an option to be the select component trigger but got ${document.activeElement}`); diff --git a/packages/@react-aria/utils/src/isElementVisible.ts b/packages/@react-aria/utils/src/isElementVisible.ts index 9dea6a0e393..b82515a7d9a 100644 --- a/packages/@react-aria/utils/src/isElementVisible.ts +++ b/packages/@react-aria/utils/src/isElementVisible.ts @@ -63,7 +63,7 @@ function isAttributeVisible(element: Element, childElement?: Element) { */ export function isElementVisible(element: Element, childElement?: Element): boolean { if (supportsCheckVisibility) { - return element.checkVisibility() && !element.closest('[data-react-aria-prevent-focus]'); + return element.checkVisibility({visibilityProperty: true}) && !element.closest('[data-react-aria-prevent-focus]'); } return ( diff --git a/packages/@react-aria/utils/src/scrollIntoView.ts b/packages/@react-aria/utils/src/scrollIntoView.ts index 4022b9b27d3..17b573ef8e6 100644 --- a/packages/@react-aria/utils/src/scrollIntoView.ts +++ b/packages/@react-aria/utils/src/scrollIntoView.ts @@ -11,6 +11,7 @@ */ import {getScrollParents} from './getScrollParents'; +import {isChrome} from './platform'; interface ScrollIntoViewportOpts { /** The optional containing element of the target to be centered in the viewport. */ @@ -40,32 +41,64 @@ export function scrollIntoView(scrollView: HTMLElement, element: HTMLElement): v scrollPaddingLeft } = getComputedStyle(scrollView); + // Account for scroll margin of the element + let { + scrollMarginTop, + scrollMarginRight, + scrollMarginBottom, + scrollMarginLeft + } = getComputedStyle(element); + let borderAdjustedX = x + parseInt(borderLeftWidth, 10); let borderAdjustedY = y + parseInt(borderTopWidth, 10); // Ignore end/bottom border via clientHeight/Width instead of offsetHeight/Width let maxX = borderAdjustedX + scrollView.clientWidth; let maxY = borderAdjustedY + scrollView.clientHeight; - // Get scroll padding values as pixels - defaults to 0 if no scroll padding + // Get scroll padding / margin values as pixels - defaults to 0 if no scroll padding / margin // is used. let scrollPaddingTopNumber = parseInt(scrollPaddingTop, 10) || 0; let scrollPaddingBottomNumber = parseInt(scrollPaddingBottom, 10) || 0; let scrollPaddingRightNumber = parseInt(scrollPaddingRight, 10) || 0; let scrollPaddingLeftNumber = parseInt(scrollPaddingLeft, 10) || 0; + let scrollMarginTopNumber = parseInt(scrollMarginTop, 10) || 0; + let scrollMarginBottomNumber = parseInt(scrollMarginBottom, 10) || 0; + let scrollMarginRightNumber = parseInt(scrollMarginRight, 10) || 0; + let scrollMarginLeftNumber = parseInt(scrollMarginLeft, 10) || 0; + + let targetLeft = offsetX - scrollMarginLeftNumber; + let targetRight = offsetX + width + scrollMarginRightNumber; + let targetTop = offsetY - scrollMarginTopNumber; + let targetBottom = offsetY + height + scrollMarginBottomNumber; - if (offsetX <= x + scrollPaddingLeftNumber) { - x = offsetX - parseInt(borderLeftWidth, 10) - scrollPaddingLeftNumber; - } else if (offsetX + width > maxX - scrollPaddingRightNumber) { - x += offsetX + width - maxX + scrollPaddingRightNumber; + let scrollPortLeft = x + parseInt(borderLeftWidth, 10) + scrollPaddingLeftNumber; + let scrollPortRight = maxX - scrollPaddingRightNumber; + let scrollPortTop = y + parseInt(borderTopWidth, 10) + scrollPaddingTopNumber; + let scrollPortBottom = maxY - scrollPaddingBottomNumber; + + if (targetLeft > scrollPortLeft || targetRight < scrollPortRight) { + if (targetLeft <= x + scrollPaddingLeftNumber) { + x = targetLeft - parseInt(borderLeftWidth, 10) - scrollPaddingLeftNumber; + } else if (targetRight > maxX - scrollPaddingRightNumber) { + x += targetRight - maxX + scrollPaddingRightNumber; + } } - if (offsetY <= borderAdjustedY + scrollPaddingTopNumber) { - y = offsetY - parseInt(borderTopWidth, 10) - scrollPaddingTopNumber; - } else if (offsetY + height > maxY - scrollPaddingBottomNumber) { - y += offsetY + height - maxY + scrollPaddingBottomNumber; + + if (targetTop > scrollPortTop || targetBottom < scrollPortBottom) { + if (targetTop <= borderAdjustedY + scrollPaddingTopNumber) { + y = targetTop - parseInt(borderTopWidth, 10) - scrollPaddingTopNumber; + } else if (targetBottom > maxY - scrollPaddingBottomNumber) { + y += targetBottom - maxY + scrollPaddingBottomNumber; + } + } + + if (process.env.NODE_ENV === 'test') { + scrollView.scrollLeft = x; + scrollView.scrollTop = y; + return; } - scrollView.scrollLeft = x; - scrollView.scrollTop = y; + scrollView.scrollTo({left: x, top: y}); } /** @@ -101,8 +134,9 @@ export function scrollIntoViewport(targetElement: Element | null, opts?: ScrollI if (targetElement && document.contains(targetElement)) { let root = document.scrollingElement || document.documentElement; let isScrollPrevented = window.getComputedStyle(root).overflow === 'hidden'; - // If scrolling is not currently prevented then we aren’t in a overlay nor is a overlay open, just use element.scrollIntoView to bring the element into view - if (!isScrollPrevented) { + // If scrolling is not currently prevented then we aren't in a overlay nor is a overlay open, just use element.scrollIntoView to bring the element into view + // Also ignore in chrome because of this bug: https://issues.chromium.org/issues/40074749 + if (!isScrollPrevented && !isChrome()) { let {left: originalLeft, top: originalTop} = targetElement.getBoundingClientRect(); // use scrollIntoView({block: 'nearest'}) instead of .focus to check if the element is fully in view or not since .focus() diff --git a/packages/@react-spectrum/picker/test/Picker.test.js b/packages/@react-spectrum/picker/test/Picker.test.js index 86991594106..99cedf78a05 100644 --- a/packages/@react-spectrum/picker/test/Picker.test.js +++ b/packages/@react-spectrum/picker/test/Picker.test.js @@ -1508,8 +1508,7 @@ describe('Picker', function () { expect(document.activeElement).toBe(items[1]); await selectTester.selectOption({option: 'Two'}); - expect(onSelectionChange).toHaveBeenCalledTimes(1); - expect(onSelectionChange).toHaveBeenCalledWith('two'); + expect(onSelectionChange).not.toHaveBeenCalled(); expect(document.activeElement).toBe(picker); expect(picker).toHaveTextContent('Two'); diff --git a/packages/@react-spectrum/s2/src/Menu.tsx b/packages/@react-spectrum/s2/src/Menu.tsx index 1b52d55a777..c816be04c0e 100644 --- a/packages/@react-spectrum/s2/src/Menu.tsx +++ b/packages/@react-spectrum/s2/src/Menu.tsx @@ -211,7 +211,7 @@ export let checkmark = style({ aspectRatio: 'square' }); -let checkbox = style({ +export let checkbox = style({ gridArea: 'checkmark', marginEnd: 'text-to-control' }); diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index cc450d41578..4c30582b396 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -33,8 +33,10 @@ import { } from 'react-aria-components'; import {AsyncLoadable, FocusableRef, FocusableRefValue, GlobalDOMAttributes, HelpTextProps, LoadingState, PressEvent, RefObject, SpectrumLabelableProps} from '@react-types/shared'; import {baseColor, edgeToText, focusRing, style} from '../style' with {type: 'macro'}; +import {box, iconStyles as checkboxIconStyles} from './Checkbox'; import {centerBaseline} from './CenterBaseline'; import { + checkbox, checkmark, description, icon, @@ -64,6 +66,7 @@ import {HeaderContext, HeadingContext, Text, TextContext} from './Content'; import {IconContext} from './Icon'; // @ts-ignore import intlMessages from '../intl/*.json'; +import {mergeStyles} from '../style/runtime'; import {Placement} from 'react-aria'; import {PopoverBase} from './Popover'; import {PressResponder} from '@react-aria/interactions'; @@ -91,8 +94,9 @@ export interface PickerStyleProps { isQuiet?: boolean } -export interface PickerProps extends - Omit, 'children' | 'style' | 'className' | keyof GlobalDOMAttributes>, +type SelectionMode = 'single' | 'multiple'; +export interface PickerProps extends + Omit, 'children' | 'style' | 'className' | keyof GlobalDOMAttributes>, PickerStyleProps, StyleProps, SpectrumLabelableProps, @@ -262,7 +266,7 @@ let InsideSelectValueContext = createContext(false); /** * Pickers allow users to choose a single option from a collapsible list of options when space is limited. */ -export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Picker(props: PickerProps, ref: FocusableRef) { +export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Picker(props: PickerProps, ref: FocusableRef) { let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); [props, ref] = useSpectrumContextProps(props, ref, PickerContext); let domRef = useFocusableRef(ref); @@ -507,7 +511,7 @@ const PickerButton = createHideableComponent(function PickerButton ( <> * {display: none;}')}> - {({defaultChildren}) => { + {({selectedItems, defaultChildren, selectedText}) => { return ( - {defaultChildren} + {selectedItems.length <= 1 ? defaultChildren : {selectedText}} ); }} @@ -577,6 +581,7 @@ export function PickerItem(props: PickerItemProps): ReactNode { className={renderProps => (props.UNSAFE_className || '') + listboxItem({...renderProps, size, isLink}, props.styles)}> {(renderProps) => { let {children} = props; + let checkboxRenderProps = {...renderProps, size, isFocused: false, isFocusVisible: false, isIndeterminate: false, isReadOnly: false, isInvalid: false, isRequired: false}; return ( - {!isLink && } + {renderProps.selectionMode === 'single' && !isLink && } + {renderProps.selectionMode === 'multiple' && !isLink && ( +
+ +
+ )} {typeof children === 'string' ? {children} : children}
diff --git a/packages/@react-spectrum/s2/stories/Picker.stories.tsx b/packages/@react-spectrum/s2/stories/Picker.stories.tsx index d7c826789ed..1ffb595bedd 100644 --- a/packages/@react-spectrum/s2/stories/Picker.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Picker.stories.tsx @@ -41,7 +41,7 @@ const meta: Meta> = { decorators: [StaticColorDecorator], tags: ['autodocs'], argTypes: { - ...categorizeArgTypes('Events', ['onOpenChange', 'onSelectionChange', 'onLoadMore']), + ...categorizeArgTypes('Events', ['onOpenChange', 'onChange', 'onLoadMore']), label: {control: {type: 'text'}}, description: {control: {type: 'text'}}, errorMessage: {control: {type: 'text'}}, diff --git a/packages/@react-stately/combobox/package.json b/packages/@react-stately/combobox/package.json index a69fe5551f5..b7508ce09fa 100644 --- a/packages/@react-stately/combobox/package.json +++ b/packages/@react-stately/combobox/package.json @@ -30,7 +30,6 @@ "@react-stately/form": "^3.2.1", "@react-stately/list": "^3.13.0", "@react-stately/overlays": "^3.6.19", - "@react-stately/select": "^3.7.1", "@react-stately/utils": "^3.10.8", "@react-types/combobox": "^3.13.8", "@react-types/shared": "^3.32.0", diff --git a/packages/@react-stately/combobox/src/useComboBoxState.ts b/packages/@react-stately/combobox/src/useComboBoxState.ts index 8278ca9a05b..e2cce7de048 100644 --- a/packages/@react-stately/combobox/src/useComboBoxState.ts +++ b/packages/@react-stately/combobox/src/useComboBoxState.ts @@ -14,13 +14,14 @@ import {Collection, CollectionStateBase, FocusStrategy, Key, Node} from '@react- import {ComboBoxProps, MenuTriggerAction} from '@react-types/combobox'; import {FormValidationState, useFormValidationState} from '@react-stately/form'; import {getChildNodes} from '@react-stately/collections'; -import {ListCollection, useSingleSelectListState} from '@react-stately/list'; -import {SelectState} from '@react-stately/select'; +import {ListCollection, SingleSelectListState, useSingleSelectListState} from '@react-stately/list'; +import {OverlayTriggerState, useOverlayTriggerState} from '@react-stately/overlays'; import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {useControlledState} from '@react-stately/utils'; -import {useOverlayTriggerState} from '@react-stately/overlays'; -export interface ComboBoxState extends SelectState, FormValidationState{ +export interface ComboBoxState extends SingleSelectListState, OverlayTriggerState, FormValidationState { + /** The default selected key. */ + readonly defaultSelectedKey: Key | null, /** The current value of the combo box input. */ inputValue: string, /** The default value of the combo box input. */ @@ -31,6 +32,10 @@ export interface ComboBoxState extends SelectState, FormValidationState{ commit(): void, /** Controls which item will be auto focused when the menu opens. */ readonly focusStrategy: FocusStrategy | null, + /** Whether the select is currently focused. */ + readonly isFocused: boolean, + /** Sets whether the select is focused. */ + setFocused(isFocused: boolean): void, /** Opens the menu. */ open(focusStrategy?: FocusStrategy | null, trigger?: MenuTriggerAction): void, /** Toggles the menu. */ diff --git a/packages/@react-stately/select/package.json b/packages/@react-stately/select/package.json index 08024a90149..3368ca9c750 100644 --- a/packages/@react-stately/select/package.json +++ b/packages/@react-stately/select/package.json @@ -29,6 +29,7 @@ "@react-stately/form": "^3.2.1", "@react-stately/list": "^3.13.0", "@react-stately/overlays": "^3.6.19", + "@react-stately/utils": "^3.10.8", "@react-types/select": "^3.10.1", "@react-types/shared": "^3.32.0", "@swc/helpers": "^0.5.0" diff --git a/packages/@react-stately/select/src/useSelectState.ts b/packages/@react-stately/select/src/useSelectState.ts index cdab4312f40..e172b750c44 100644 --- a/packages/@react-stately/select/src/useSelectState.ts +++ b/packages/@react-stately/select/src/useSelectState.ts @@ -10,19 +10,53 @@ * governing permissions and limitations under the License. */ -import {CollectionStateBase, FocusStrategy, Key} from '@react-types/shared'; +import {CollectionStateBase, FocusStrategy, Key, Node, Selection} from '@react-types/shared'; import {FormValidationState, useFormValidationState} from '@react-stately/form'; +import {ListState, useListState} from '@react-stately/list'; import {OverlayTriggerState, useOverlayTriggerState} from '@react-stately/overlays'; -import {SelectProps} from '@react-types/select'; -import {SingleSelectListState, useSingleSelectListState} from '@react-stately/list'; -import {useState} from 'react'; +import {SelectionMode, SelectProps, ValueType} from '@react-types/select'; +import {useControlledState} from '@react-stately/utils'; +import {useMemo, useState} from 'react'; -export interface SelectStateOptions extends Omit, 'children'>, CollectionStateBase {} +export interface SelectStateOptions extends Omit, 'children'>, CollectionStateBase {} -export interface SelectState extends SingleSelectListState, OverlayTriggerState, FormValidationState { - /** The default selected key. */ +export interface SelectState extends ListState, OverlayTriggerState, FormValidationState { + /** + * The key for the first selected item. + * @deprecated + */ + readonly selectedKey: Key | null, + + /** + * The default selected key. + * @deprecated + */ readonly defaultSelectedKey: Key | null, + /** + * Sets the selected key. + * @deprecated + */ + setSelectedKey(key: Key | null): void, + + /** The current select value. */ + readonly value: ValueType, + + /** The default select value. */ + readonly defaultValue: ValueType, + + /** Sets the select value. */ + setValue(value: Key | Key[] | null): void, + + /** + * The value of the first selected item. + * @deprecated + */ + readonly selectedItem: Node | null, + + /** The value of the selected items. */ + readonly selectedItems: Node[], + /** Whether the select is currently focused. */ readonly isFocused: boolean, @@ -44,34 +78,85 @@ export interface SelectState extends SingleSelectListState, OverlayTrigger * of items from props, handles the open state for the popup menu, and manages * multiple selection state. */ -export function useSelectState(props: SelectStateOptions): SelectState { +export function useSelectState(props: SelectStateOptions): SelectState { + let {selectionMode = 'single' as M} = props; let triggerState = useOverlayTriggerState(props); let [focusStrategy, setFocusStrategy] = useState(null); - let listState = useSingleSelectListState({ + let defaultValue = useMemo(() => { + return props.defaultValue ?? (selectionMode === 'single' ? props.defaultSelectedKey ?? null : []) as ValueType; + }, [props.defaultValue, props.defaultSelectedKey, selectionMode]); + let value = useMemo(() => { + return props.value ?? (selectionMode === 'single' ? props.selectedKey : undefined) as ValueType; + }, [props.value, props.selectedKey, selectionMode]); + let [controlledValue, setControlledValue] = useControlledState>(value as any, defaultValue as any, props.onChange); + let setValue = (value: Key | Key[] | null) => { + if (selectionMode === 'single') { + let key = Array.isArray(value) ? value[0] ?? null : value; + setControlledValue(key as ValueType); + if (key !== controlledValue) { + props.onSelectionChange?.(key); + } + } else { + let keys: Key[] = []; + if (Array.isArray(value)) { + keys = value; + } else if (value != null) { + keys = [value]; + } + + setControlledValue(keys as ValueType); + } + }; + + let listState = useListState({ ...props, - onSelectionChange: (key) => { - if (props.onSelectionChange != null) { - props.onSelectionChange(key); + selectionMode, + disallowEmptySelection: selectionMode === 'single', + allowDuplicateSelectionEvents: true, + selectedKeys: useMemo(() => convertValue(controlledValue), [controlledValue]), + onSelectionChange: (keys: Selection) => { + // impossible, but TS doesn't know that + if (keys === 'all') { + return; + } + + if (selectionMode === 'single') { + let key = keys.values().next().value ?? null; + setValue(key); + triggerState.close(); + } else { + setValue([...keys]); } - triggerState.close(); validationState.commitValidation(); } }); + let selectedKey = listState.selectionManager.firstSelectedKey; + let selectedItems = useMemo(() => { + return [...listState.selectionManager.selectedKeys].map(key => listState.collection.getItem(key)).filter(item => item != null); + }, [listState.selectionManager.selectedKeys, listState.collection]); + let validationState = useFormValidationState({ ...props, - value: listState.selectedKey + value: Array.isArray(controlledValue) && controlledValue.length === 0 ? null : controlledValue as any }); let [isFocused, setFocused] = useState(false); - let [initialSelectedKey] = useState(listState.selectedKey); + let [initialValue] = useState(controlledValue); return { ...validationState, ...listState, ...triggerState, - defaultSelectedKey: props.defaultSelectedKey ?? initialSelectedKey, + value: controlledValue, + defaultValue: defaultValue ?? initialValue, + setValue, + selectedKey, + setSelectedKey: setValue, + selectedItem: selectedItems[0] ?? null, + selectedItems, + defaultSelectedKey: props.defaultSelectedKey ?? (props.selectionMode === 'single' ? initialValue as Key : null), focusStrategy, open(focusStrategy: FocusStrategy | null = null) { // Don't open if the collection is empty. @@ -90,3 +175,13 @@ export function useSelectState(props: SelectStateOptions): setFocused }; } + +function convertValue(value: Key | Key[] | null | undefined) { + if (value === undefined) { + return undefined; + } + if (value === null) { + return []; + } + return Array.isArray(value) ? value : [value]; +} diff --git a/packages/@react-types/select/src/index.d.ts b/packages/@react-types/select/src/index.d.ts index 22b6f976801..97a3366a065 100644 --- a/packages/@react-types/select/src/index.d.ts +++ b/packages/@react-types/select/src/index.d.ts @@ -27,10 +27,35 @@ import { SpectrumLabelableProps, StyleProps, TextInputBase, - Validation + Validation, + ValueBase } from '@react-types/shared'; -export interface SelectProps extends CollectionBase, Omit, Validation, HelpTextProps, LabelableProps, TextInputBase, Omit, FocusableProps { +export type SelectionMode = 'single' | 'multiple'; +export type ValueType = M extends 'single' ? Key | null : Key[]; +type ValidationType = M extends 'single' ? Key : Key[]; + +export interface SelectProps extends CollectionBase, Omit, ValueBase>, Validation>, HelpTextProps, LabelableProps, TextInputBase, FocusableProps { + /** + * Whether single or multiple selection is enabled. + * @default 'single' + */ + selectionMode?: M, + /** + * The currently selected key in the collection (controlled). + * @deprecated + */ + selectedKey?: Key | null, + /** + * The initial selected key in the collection (uncontrolled). + * @deprecated + */ + defaultSelectedKey?: Key, + /** + * Handler that is called when the selection changes. + * @deprecated + */ + onSelectionChange?: (key: Key | null) => void, /** Sets the open state of the menu. */ isOpen?: boolean, /** Sets the default open state of the menu. */ @@ -39,7 +64,7 @@ export interface SelectProps extends CollectionBase, Omit void } -export interface AriaSelectProps extends SelectProps, DOMProps, AriaLabelingProps, FocusableDOMProps { +export interface AriaSelectProps extends SelectProps, DOMProps, AriaLabelingProps, FocusableDOMProps { /** * Describes the type of autocomplete functionality the input should provide if any. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefautocomplete). */ @@ -56,7 +81,7 @@ export interface AriaSelectProps extends SelectProps, DOMProps, AriaLabeli form?: string } -export interface SpectrumPickerProps extends AriaSelectProps, AsyncLoadable, SpectrumLabelableProps, StyleProps { +export interface SpectrumPickerProps extends Omit, 'selectionMode' | 'selectedKey' | 'defaultSelectedKey' | 'onSelectionChange' | 'value' | 'defaultValue' | 'onChange'>, Omit, AsyncLoadable, SpectrumLabelableProps, StyleProps { /** Whether the textfield should be displayed with a quiet style. */ isQuiet?: boolean, /** Alignment of the menu relative to the input target. diff --git a/packages/react-aria-components/docs/Select.mdx b/packages/react-aria-components/docs/Select.mdx index a8664128524..34ab4cbf007 100644 --- a/packages/react-aria-components/docs/Select.mdx +++ b/packages/react-aria-components/docs/Select.mdx @@ -105,8 +105,11 @@ import {ChevronDown} from 'lucide-react'; } .react-aria-SelectValue { - flex: 1 0 auto; + flex: 1; text-align: start; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; &[data-placeholder] { font-style: italic; @@ -319,7 +322,7 @@ This example wraps `Select` and all of its children together into a single compo import type {SelectProps, ListBoxItemProps, ValidationResult} from 'react-aria-components'; import {Text, FieldError} from 'react-aria-components'; -interface MySelectProps extends Omit, 'children'> { +interface MySelectProps extends Omit, 'children'> { label?: string, description?: string, errorMessage?: string | ((validation: ValidationResult) => string), @@ -327,7 +330,9 @@ interface MySelectProps extends Omit, 'children children: React.ReactNode | ((item: T) => React.ReactNode) } -export function MySelect({label, description, errorMessage, children, items, ...props}: MySelectProps) { +export function MySelect( + {label, description, errorMessage, children, items, ...props}: MySelectProps +) { return ( +} + {props.name && } ); }); diff --git a/packages/react-aria-components/src/Select.tsx b/packages/react-aria-components/src/Select.tsx index 2248f429380..30740522b99 100644 --- a/packages/react-aria-components/src/Select.tsx +++ b/packages/react-aria-components/src/Select.tsx @@ -10,10 +10,10 @@ * governing permissions and limitations under the License. */ -import {AriaSelectProps, HiddenSelect, useFocusRing, useLocalizedStringFormatter, useSelect} from 'react-aria'; +import {AriaSelectProps, HiddenSelect, useFocusRing, useListFormatter, useLocalizedStringFormatter, useSelect} from 'react-aria'; import {ButtonContext} from './Button'; import {Collection, Node, SelectState, useSelectState} from 'react-stately'; -import {CollectionBuilder} from '@react-aria/collections'; +import {CollectionBuilder, createHideableComponent} from '@react-aria/collections'; import {ContextValue, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; import {FieldErrorContext} from './FieldError'; import {filterDOMProps, mergeProps, useResizeObserver} from '@react-aria/utils'; @@ -26,9 +26,11 @@ import {LabelContext} from './Label'; import {ListBoxContext, ListStateContext} from './ListBox'; import {OverlayTriggerStateContext} from './Dialog'; import {PopoverContext} from './Popover'; -import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, ReactNode, useCallback, useContext, useMemo, useRef, useState} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, Fragment, HTMLAttributes, ReactNode, useCallback, useContext, useMemo, useRef, useState} from 'react'; import {TextContext} from './Text'; +type SelectionMode = 'single' | 'multiple'; + export interface SelectRenderProps { /** * Whether the select is focused, either via a mouse or keyboard. @@ -62,7 +64,7 @@ export interface SelectRenderProps { isRequired: boolean } -export interface SelectProps extends Omit, 'children' | 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior' | 'items'>, RACValidation, RenderProps, SlotProps, GlobalDOMAttributes { +export interface SelectProps extends Omit, 'children' | 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior' | 'items'>, RACValidation, RenderProps, SlotProps, GlobalDOMAttributes { /** * Temporary text that occupies the select when it is empty. * @default 'Select an item' (localized) @@ -70,13 +72,13 @@ export interface SelectProps extends Omit, HTMLDivElement>>(null); -export const SelectStateContext = createContext | null>(null); +export const SelectContext = createContext, HTMLDivElement>>(null); +export const SelectStateContext = createContext | null>(null); /** * A select displays a collapsible list of options and allows a user to select one of them. */ -export const Select = /*#__PURE__*/ (forwardRef as forwardRefType)(function Select(props: SelectProps, ref: ForwardedRef) { +export const Select = /*#__PURE__*/ (forwardRef as forwardRefType)(function Select(props: SelectProps, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, SelectContext); let {children, isDisabled = false, isInvalid = false, isRequired = false} = props; let content = useMemo(() => ( @@ -104,7 +106,7 @@ export const Select = /*#__PURE__*/ (forwardRef as forwardRefType)(function Sele const CLEAR_CONTEXTS = [LabelContext, ButtonContext, TextContext]; interface SelectInnerProps { - props: SelectProps, + props: SelectProps, selectRef: ForwardedRef, collection: Collection> } @@ -228,10 +230,17 @@ export interface SelectValueRenderProps { * @selector [data-placeholder] */ isPlaceholder: boolean, - /** The object value of the currently selected item. */ + /** + * The object value of the first selected item. + * @deprecated + */ selectedItem: T | null, - /** The textValue of the currently selected item. */ - selectedText: string | null + /** The object values of the currently selected items. */ + selectedItems: (T | null)[], + /** The textValue of the currently selected items. */ + selectedText: string, + /** The state of the select. */ + state: SelectState } export interface SelectValueProps extends Omit, keyof RenderProps>, RenderProps> {} @@ -242,46 +251,78 @@ export const SelectValueContext = createContext(props: SelectValueProps, ref: ForwardedRef) { +export const SelectValue = /*#__PURE__*/ createHideableComponent(function SelectValue(props: SelectValueProps, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, SelectValueContext); - let state = useContext(SelectStateContext)!; + let state = useContext(SelectStateContext)! as SelectState; let {placeholder} = useSlottedContext(SelectContext)!; - let selectedItem = state.selectedKey != null - ? state.collection.getItem(state.selectedKey) - : null; - let rendered = selectedItem?.props.children; - if (typeof rendered === 'function') { + let rendered = state.selectedItems.map((item) => { + let rendered = item.props?.children; // If the selected item has a function as a child, we need to call it to render to React.JSX. - let fn = rendered as (s: ItemRenderProps) => ReactNode; - rendered = fn({ - isHovered: false, - isPressed: false, - isSelected: false, - isFocused: false, - isFocusVisible: false, - isDisabled: false, - selectionMode: 'single', - selectionBehavior: 'toggle' + if (typeof rendered === 'function') { + let fn = rendered as (s: ItemRenderProps) => ReactNode; + rendered = fn({ + isHovered: false, + isPressed: false, + isSelected: false, + isFocused: false, + isFocusVisible: false, + isDisabled: false, + selectionMode: 'single', + selectionBehavior: 'toggle' + }); + } + + return rendered; + }); + + let formatter = useListFormatter(); + let textValue = useMemo(() => state.selectedItems.map(item => item?.textValue), [state.selectedItems]); + let selectionMode = state.selectionManager.selectionMode; + let selectedText = useMemo(() => ( + selectionMode === 'single' + ? textValue[0] ?? '' + : formatter.format(textValue) + ), [selectionMode, formatter, textValue]); + + let defaultChildren = useMemo(() => { + if (selectionMode === 'single') { + return rendered[0]; + } + + let parts = formatter.formatToParts(textValue); + if (parts.length === 0) { + return null; + } + + let index = 0; + return parts.map(part => { + if (part.type === 'element') { + return {rendered[index++]}; + } else { + return part.value; + } }); - } + }, [selectionMode, formatter, textValue, rendered]); let stringFormatter = useLocalizedStringFormatter(intlMessages, 'react-aria-components'); let renderProps = useRenderProps({ ...props, - defaultChildren: rendered ?? placeholder ?? stringFormatter.format('selectPlaceholder'), + defaultChildren: defaultChildren ?? placeholder ?? stringFormatter.format('selectPlaceholder'), defaultClassName: 'react-aria-SelectValue', values: { - selectedItem: state.selectedItem?.value as T ?? null, - selectedText: state.selectedItem?.textValue ?? null, - isPlaceholder: !selectedItem + selectedItem: state.selectedItems[0]?.value as T ?? null, + selectedItems: useMemo(() => state.selectedItems.map(item => item.value as T ?? null), [state.selectedItems]), + selectedText, + isPlaceholder: state.selectedItems.length === 0, + state } }); let DOMProps = filterDOMProps(props, {global: true}); return ( - + {/* clear description and error message slots */} {renderProps.children} diff --git a/packages/react-aria-components/src/TagGroup.tsx b/packages/react-aria-components/src/TagGroup.tsx index b6724c7f6bf..22b781a693a 100644 --- a/packages/react-aria-components/src/TagGroup.tsx +++ b/packages/react-aria-components/src/TagGroup.tsx @@ -62,9 +62,11 @@ export const TagListContext = createContext, HTML export const TagGroup = /*#__PURE__*/ (forwardRef as forwardRefType)(function TagGroup(props: TagGroupProps, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, TagGroupContext); return ( - - {collection => } - + + + {collection => } + + ); }); diff --git a/packages/react-aria-components/stories/DateField.stories.tsx b/packages/react-aria-components/stories/DateField.stories.tsx index 2be9f7aecf4..3c7ff7cdabc 100644 --- a/packages/react-aria-components/stories/DateField.stories.tsx +++ b/packages/react-aria-components/stories/DateField.stories.tsx @@ -43,6 +43,12 @@ export default { isInvalid: { control: 'boolean' }, + isDisabled: { + control: 'boolean' + }, + isReadOnly: { + control: 'boolean' + }, validationBehavior: { control: 'select', options: ['native', 'aria'] diff --git a/packages/react-aria-components/stories/ListBox.stories.tsx b/packages/react-aria-components/stories/ListBox.stories.tsx index 74a049009ac..0dec20894b4 100644 --- a/packages/react-aria-components/stories/ListBox.stories.tsx +++ b/packages/react-aria-components/stories/ListBox.stories.tsx @@ -743,6 +743,46 @@ export const AsyncListBoxVirtualized: StoryFn = (args ); }; +export const ListBoxScrollMargin: ListBoxStory = (args) => { + let items: {id: number, name: string, description: string}[] = []; + for (let i = 0; i < 100; i++) { + items.push({id: i, name: `Item ${i}`, description: `Description ${i}`}); + } + return ( + + {item => ( + + {item.name} + {item.description} + + )} + + ); +}; + +export const ListBoxSmoothScroll: ListBoxStory = (args) => { + let items: {id: number, name: string}[] = []; + for (let i = 0; i < 100; i++) { + items.push({id: i, name: `Item ${i}`}); + } + return ( + + {item => {item.name}} + + ); +}; + AsyncListBoxVirtualized.story = { args: { delay: 50 diff --git a/packages/react-aria-components/stories/Select.stories.tsx b/packages/react-aria-components/stories/Select.stories.tsx index 03a5b67d756..c71374de62e 100644 --- a/packages/react-aria-components/stories/Select.stories.tsx +++ b/packages/react-aria-components/stories/Select.stories.tsx @@ -16,6 +16,7 @@ import {LoadingSpinner, MyListBoxItem} from './utils'; import {Meta, StoryFn, StoryObj} from '@storybook/react'; import React, {JSX} from 'react'; import styles from '../example/index.css'; +import {Tag, TagGroup} from 'vanilla-starter/TagGroup'; import {useAsyncList} from 'react-stately'; import './styles.css'; @@ -26,14 +27,18 @@ export default { validationBehavior: { control: 'select', options: ['native', 'aria'] + }, + selectionMode: { + control: 'radio', + options: ['single', 'multiple'] } } } as Meta; export type SelectStory = StoryFn; -export const SelectExample: SelectStory = () => ( - @@ -75,6 +84,35 @@ export const SelectRenderProps: SelectStory = () => ( ); +export const SelectWithTagGroup: SelectStory = (args) => ( + +); + let makeItems = (length: number) => Array.from({length}, (_, i) => ({ id: i, name: `Item ${i}` @@ -143,8 +181,8 @@ const usStateOptions = [ {id: 'WY', name: 'Wyoming'} ]; -export const SelectManyItems: SelectStory = () => ( - + {item => {item.name}} diff --git a/packages/react-aria-components/test/DateField.test.js b/packages/react-aria-components/test/DateField.test.js index c54966e07d8..6f9830aba14 100644 --- a/packages/react-aria-components/test/DateField.test.js +++ b/packages/react-aria-components/test/DateField.test.js @@ -56,6 +56,11 @@ describe('DateField', () => { expect(segment).toHaveAttribute('data-placeholder', 'true'); expect(segment).toHaveAttribute('data-type'); expect(segment).toHaveAttribute('data-test', 'test'); + expect(segment).not.toHaveAttribute('data-readonly'); + } + + for (let literal of [...input.children].filter(child => child.getAttribute('data-type') === 'literal')) { + expect(literal).not.toHaveAttribute('data-readonly'); } }); @@ -164,7 +169,7 @@ describe('DateField', () => { }); it('should support disabled state', () => { - let {getByRole} = render( + let {getByRole, getAllByRole} = render( isDisabled ? 'disabled' : ''}> @@ -175,6 +180,64 @@ describe('DateField', () => { let group = getByRole('group'); expect(group).toHaveAttribute('data-disabled'); expect(group).toHaveClass('disabled'); + + for (let segment of getAllByRole('spinbutton')) { + expect(segment).not.toHaveAttribute('data-readonly'); + expect(segment).toHaveAttribute('data-disabled'); + } + for (let literal of [...group.children].filter(child => child.getAttribute('data-type') === 'literal')) { + expect(literal).not.toHaveAttribute('data-readonly'); + expect(literal).toHaveAttribute('data-disabled'); + } + }); + + it('should support readonly with disabled state', () => { + let {getByRole, getAllByRole} = render( + + + + {segment => } + + + ); + + let group = getByRole('group'); + expect(group).toHaveAttribute('data-readonly'); + expect(group).toHaveAttribute('data-disabled'); + + for (let segment of getAllByRole('spinbutton')) { + expect(segment).toHaveAttribute('data-readonly'); + expect(segment).toHaveAttribute('data-disabled'); + } + for (let literal of [...group.children].filter(child => child.getAttribute('data-type') === 'literal')) { + expect(literal).toHaveAttribute('data-readonly'); + expect(literal).toHaveAttribute('data-disabled'); + } + }); + + it('should support readonly state', () => { + let {getByRole, getAllByRole} = render( + + + + {segment => } + + + ); + + let group = getByRole('group'); + expect(group).toHaveAttribute('data-readonly'); + expect(group).not.toHaveAttribute('data-disabled'); + expect(group).not.toHaveClass('disabled'); + + for (let segment of getAllByRole('spinbutton')) { + expect(segment).toHaveAttribute('data-readonly'); + expect(segment).not.toHaveAttribute('data-disabled'); + } + for (let literal of [...group.children].filter(child => child.getAttribute('data-type') === 'literal')) { + expect(literal).toHaveAttribute('data-readonly'); + expect(literal).not.toHaveAttribute('data-disabled'); + } }); it('should support render props', () => { diff --git a/packages/react-aria-components/test/NumberField.test.js b/packages/react-aria-components/test/NumberField.test.js index 14c3e0db770..d29514fe54c 100644 --- a/packages/react-aria-components/test/NumberField.test.js +++ b/packages/react-aria-components/test/NumberField.test.js @@ -135,6 +135,12 @@ describe('NumberField', () => { expect(input).toHaveValue(''); }); + it('should support disabled when having a form value', () => { + render(); + let input = document.querySelector('input[name=test]'); + expect(input).toBeDisabled(); + }); + it('should render data- attributes only on the outer element', () => { let {getAllByTestId} = render( diff --git a/packages/react-aria-components/test/Select.test.js b/packages/react-aria-components/test/Select.test.js index 67c93fcd993..4328ecfc904 100644 --- a/packages/react-aria-components/test/Select.test.js +++ b/packages/react-aria-components/test/Select.test.js @@ -26,9 +26,9 @@ let TestSelect = (props) => ( Error - Cat - Dog - Kangaroo + Cat + Dog + Kangaroo @@ -554,4 +554,137 @@ describe('Select', () => { expect(onSubmit).toHaveBeenCalledTimes(1); expect(document.querySelector('[name=select]').value).toBe(''); }); + + it('should support multiple selection', async () => { + let onChange = jest.fn(); + let {getByTestId} = render( + + + + ); + let wrapper = getByTestId('select'); + let selectTester = testUtilUser.createTester('Select', {root: wrapper}); + + let trigger = selectTester.trigger; + expect(trigger).toHaveTextContent('Select an item'); + + await selectTester.open(); + + let listbox = selectTester.listbox; + expect(listbox).toHaveAttribute('aria-multiselectable', 'true'); + + let options = selectTester.options(); + expect(options).toHaveLength(3); + + await user.click(options[0]); + await user.click(options[1]); + expect(trigger).toHaveTextContent('Cat and Dog'); + await selectTester.close(); + + expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenLastCalledWith(['cat', 'dog']); + + let formData = new FormData(getByTestId('form')); + expect(formData.getAll('select')).toEqual(['cat', 'dog']); + }); + + it('should support multiple selection form integration with many items', async () => { + let items = []; + for (let i = 0; i < 320; i++) { + items.push({id: i, name: 'item' + i}); + } + + let {getByTestId} = render( +
e.preventDefault()}> + + +
+ ); + let wrapper = getByTestId('select'); + let selectTester = testUtilUser.createTester('Select', {root: wrapper}); + + let trigger = selectTester.trigger; + expect(trigger).toHaveTextContent('Select an item'); + + let submit = getByTestId('submit'); + await user.click(submit); + + let fieldError = document.querySelector('.react-aria-FieldError'); + expect(fieldError).toHaveTextContent('Constraints not satisfied'); + + await selectTester.open(); + + let options = selectTester.options(); + await user.click(options[0]); + await user.click(options[1]); + await selectTester.close(); + expect(trigger).toHaveTextContent('item0 and item1'); + + let formData = new FormData(getByTestId('form')); + expect(formData.getAll('select')).toEqual(['0', '1']); + + await user.click(submit); + fieldError = document.querySelector('.react-aria-FieldError'); + expect(fieldError).toBe(null); + }); + + it('should support controlled multi-selection', async () => { + let {getByTestId} = render(); + + let wrapper = getByTestId('select'); + let selectTester = testUtilUser.createTester('Select', {root: wrapper}); + + let trigger = selectTester.trigger; + expect(trigger).toHaveTextContent('Dog and Kangaroo'); + + await selectTester.open(); + + let options = selectTester.options(); + expect(options[0]).toHaveAttribute('aria-selected', 'false'); + expect(options[1]).toHaveAttribute('aria-selected', 'true'); + expect(options[2]).toHaveAttribute('aria-selected', 'true'); + }); + + it('supports custom select value with multi-selection', async () => { + let items = [ + {id: 1, name: 'Cat'}, + {id: 2, name: 'Dog'} + ]; + + let {getByTestId} = render( + + ); + + let selectTester = testUtilUser.createTester('Select', {root: getByTestId('select')}); + let trigger = selectTester.trigger; + expect(trigger).toHaveTextContent('Cat'); + + await selectTester.selectOption({option: 'Dog'}); + expect(trigger).toHaveTextContent('2 selected items'); + }); }); diff --git a/packages/react-aria/src/index.ts b/packages/react-aria/src/index.ts index d968bc20245..1bb86de1659 100644 --- a/packages/react-aria/src/index.ts +++ b/packages/react-aria/src/index.ts @@ -21,7 +21,7 @@ export {useDialog} from '@react-aria/dialog'; export {useDisclosure} from '@react-aria/disclosure'; export {useDrag, useDrop, useDraggableCollection, useDroppableCollection, useDroppableItem, useDropIndicator, useDraggableItem, useClipboard, DragPreview, ListDropTargetDelegate, DIRECTORY_DRAG_TYPE, isDirectoryDropItem, isFileDropItem, isTextDropItem} from '@react-aria/dnd'; export {FocusRing, FocusScope, useFocusManager, useFocusRing} from '@react-aria/focus'; -export {I18nProvider, isRTL, useCollator, useDateFormatter, useFilter, useLocale, useLocalizedStringFormatter, useMessageFormatter, useNumberFormatter} from '@react-aria/i18n'; +export {I18nProvider, isRTL, useCollator, useDateFormatter, useFilter, useLocale, useLocalizedStringFormatter, useMessageFormatter, useNumberFormatter, useListFormatter} from '@react-aria/i18n'; export {useFocus, useFocusVisible, useFocusWithin, useHover, useInteractOutside, useKeyboard, useMove, usePress, useLongPress, useFocusable, Pressable, Focusable} from '@react-aria/interactions'; export {useField, useLabel} from '@react-aria/label'; export {useGridList, useGridListItem, useGridListSection, useGridListSelectionCheckbox} from '@react-aria/gridlist'; diff --git a/starters/docs/src/TagGroup.css b/starters/docs/src/TagGroup.css index 6dff8c881a1..d72cd89433f 100644 --- a/starters/docs/src/TagGroup.css +++ b/starters/docs/src/TagGroup.css @@ -26,6 +26,9 @@ display: flex; align-items: center; transition: border-color 200ms; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; &[data-hovered] { border-color: var(--border-color-hover); diff --git a/starters/docs/src/TagGroup.tsx b/starters/docs/src/TagGroup.tsx index a3ecd6eeec7..4ce35017cce 100644 --- a/starters/docs/src/TagGroup.tsx +++ b/starters/docs/src/TagGroup.tsx @@ -36,7 +36,7 @@ export function TagGroup( return ( ( - + {label && } {children} diff --git a/yarn.lock b/yarn.lock index 00ba22c61df..1c770a5f681 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8049,7 +8049,6 @@ __metadata: "@react-stately/form": "npm:^3.2.1" "@react-stately/list": "npm:^3.13.0" "@react-stately/overlays": "npm:^3.6.19" - "@react-stately/select": "npm:^3.7.1" "@react-stately/utils": "npm:^3.10.8" "@react-types/combobox": "npm:^3.13.8" "@react-types/shared": "npm:^3.32.0" @@ -8247,6 +8246,7 @@ __metadata: "@react-stately/form": "npm:^3.2.1" "@react-stately/list": "npm:^3.13.0" "@react-stately/overlays": "npm:^3.6.19" + "@react-stately/utils": "npm:^3.10.8" "@react-types/select": "npm:^3.10.1" "@react-types/shared": "npm:^3.32.0" "@swc/helpers": "npm:^0.5.0"