From 1e054030060a54a7c6cda533eea882a438f0eda7 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 29 Aug 2025 16:07:29 -0400 Subject: [PATCH 1/5] feat: Add support for multiple selection to Select and Picker (#8734) * feat: Add support for multiple selection to Select * Add example with TagGroup * Use checkboxes for multi-select * lint * yarn --- .../@react-aria/select/src/HiddenSelect.tsx | 84 +++++++---- packages/@react-aria/select/src/useSelect.ts | 19 ++- packages/@react-aria/test-utils/src/select.ts | 4 +- .../picker/test/Picker.test.js | 3 +- packages/@react-spectrum/s2/src/Menu.tsx | 2 +- packages/@react-spectrum/s2/src/Picker.tsx | 22 ++- .../s2/stories/Picker.stories.tsx | 2 +- packages/@react-stately/combobox/package.json | 1 - .../combobox/src/useComboBoxState.ts | 13 +- packages/@react-stately/select/package.json | 1 + .../select/src/useSelectState.ts | 127 ++++++++++++++-- packages/@react-types/select/src/index.d.ts | 33 ++++- .../react-aria-components/docs/Select.mdx | 67 +++++++-- packages/react-aria-components/src/Select.tsx | 109 +++++++++----- .../react-aria-components/src/TagGroup.tsx | 8 +- .../stories/Select.stories.tsx | 61 ++++++-- .../react-aria-components/test/Select.test.js | 139 +++++++++++++++++- packages/react-aria/src/index.ts | 2 +- starters/docs/src/TagGroup.css | 3 + starters/docs/src/TagGroup.tsx | 2 +- yarn.lock | 2 +- 21 files changed, 563 insertions(+), 141 deletions(-) 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-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 ( + +export const SelectExample: SelectStory = (args) => ( + ); -export const SelectRenderProps: SelectStory = () => ( - {({isOpen}) => ( <> @@ -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/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" From 778bf5f9da40bed834361d3bddcedfc0ef5e4144 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Sat, 30 Aug 2025 06:48:48 +1000 Subject: [PATCH 2/5] fix: focusscope navigation past visibility hidden elements (#8794) * fix: focusscope navigation past visibility hidden elements * add comment * jsdom doesn't support this yet --------- Co-authored-by: Devon Govett --- .../focus/stories/FocusScope.stories.tsx | 31 +++++++++++++++++++ .../@react-aria/utils/src/isElementVisible.ts | 2 +- 2 files changed, 32 insertions(+), 1 deletion(-) 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/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 ( From 591d1f89e0ef65ca08af308591689e15cc3e8ae5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= <25958801+nwidynski@users.noreply.github.com> Date: Fri, 29 Aug 2025 22:58:33 +0200 Subject: [PATCH 3/5] fix: scrollIntoView should respect scroll-margin (#8715) * fix: scrollIntoView should respect scroll-margin * chore: formatting * feat: add scroll-margin story * feat: add smooth scroll story --------- Co-authored-by: Robert Snow --- .../@react-aria/utils/src/scrollIntoView.ts | 60 +++++++++++++++---- .../stories/ListBox.stories.tsx | 40 +++++++++++++ 2 files changed, 87 insertions(+), 13 deletions(-) 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-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 From a1c99d5bfb3766f68648b48b3caf3d4fe1c4e510 Mon Sep 17 00:00:00 2001 From: Thomas Klepzig Date: Fri, 29 Aug 2025 23:02:40 +0200 Subject: [PATCH 4/5] Pass `disabled` prop to hidden `input` in `NumberField` (#8706) Co-authored-by: Robert Snow --- packages/react-aria-components/src/NumberField.tsx | 2 +- packages/react-aria-components/test/NumberField.test.js | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/react-aria-components/src/NumberField.tsx b/packages/react-aria-components/src/NumberField.tsx index b8d79d2bf5a..79899a5a2b7 100644 --- a/packages/react-aria-components/src/NumberField.tsx +++ b/packages/react-aria-components/src/NumberField.tsx @@ -127,7 +127,7 @@ export const NumberField = /*#__PURE__*/ (forwardRef as forwardRefType)(function data-disabled={props.isDisabled || undefined} data-required={props.isRequired || undefined} data-invalid={validation.isInvalid || undefined} /> - {props.name && } + {props.name && } ); }); 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( From a9a451c1d0904dcf4ea9b45a5390e3191fe5c152 Mon Sep 17 00:00:00 2001 From: Jonas Heisterberg <103542043+Persists@users.noreply.github.com> Date: Fri, 29 Aug 2025 23:31:49 +0200 Subject: [PATCH 5/5] fix: ensure `isReadonly` applies to all non-literal date segments (#7969) * fix: force data-readonly when datefield is readonly * chore: simplify isEditable * adding tests * update based on expectations * fix formatting * Apply suggestion from @snowystinger --------- Co-authored-by: Robert Snow Co-authored-by: Robert Snow --- .../react-aria-components/src/DateField.tsx | 14 ++-- packages/react-aria-components/src/Group.tsx | 7 +- .../stories/DateField.stories.tsx | 6 ++ .../test/DateField.test.js | 65 ++++++++++++++++++- 4 files changed, 82 insertions(+), 10 deletions(-) diff --git a/packages/react-aria-components/src/DateField.tsx b/packages/react-aria-components/src/DateField.tsx index 1a590f9831f..863c4d6df2a 100644 --- a/packages/react-aria-components/src/DateField.tsx +++ b/packages/react-aria-components/src/DateField.tsx @@ -111,11 +111,11 @@ export const DateField = /*#__PURE__*/ (forwardRef as forwardRefType)(function D slot={props.slot || undefined} data-invalid={state.isInvalid || undefined} data-disabled={state.isDisabled || undefined} /> - + state={state} /> ); }); @@ -180,7 +180,7 @@ export const TimeField = /*#__PURE__*/ (forwardRef as forwardRefType)(function T {...renderProps} ref={ref} slot={props.slot || undefined} - data-invalid={state.isInvalid || undefined} + data-invalid={state.isInvalid || undefined} data-disabled={state.isDisabled || undefined} /> ); @@ -269,6 +269,7 @@ const DateInputInner = forwardRef((props: DateInputProps, ref: ForwardedRef {state.segments.map((segment, i) => cloneElement(children(segment), {key: i}))} @@ -337,12 +338,11 @@ export const DateSegment = /*#__PURE__*/ (forwardRef as forwardRefType)(function let {segmentProps} = useDateSegment(segment, state, domRef); let {focusProps, isFocused, isFocusVisible} = useFocusRing(); let {hoverProps, isHovered} = useHover({...otherProps, isDisabled: state.isDisabled || segment.type === 'literal'}); - let {isEditable, ...segmentRest} = segment; let renderProps = useRenderProps({ ...otherProps, values: { - ...segmentRest, - isReadOnly: !isEditable, + ...segment, + isReadOnly: state.isReadOnly, isInvalid: state.isInvalid, isDisabled: state.isDisabled, isHovered, @@ -361,7 +361,7 @@ export const DateSegment = /*#__PURE__*/ (forwardRef as forwardRefType)(function ref={domRef} data-placeholder={segment.isPlaceholder || undefined} data-invalid={state.isInvalid || undefined} - data-readonly={!isEditable || undefined} + data-readonly={state.isReadOnly || undefined} data-disabled={state.isDisabled || undefined} data-type={segment.type} data-hovered={isHovered || undefined} diff --git a/packages/react-aria-components/src/Group.tsx b/packages/react-aria-components/src/Group.tsx index 222ca6a0037..03f398f5728 100644 --- a/packages/react-aria-components/src/Group.tsx +++ b/packages/react-aria-components/src/Group.tsx @@ -48,6 +48,8 @@ export interface GroupProps extends AriaLabelingProps, Omit) { [props, ref] = useContextProps(props, ref, GroupContext); - let {isDisabled, isInvalid, onHoverStart, onHoverChange, onHoverEnd, ...otherProps} = props; + let {isDisabled, isInvalid, isReadOnly, onHoverStart, onHoverChange, onHoverEnd, ...otherProps} = props; let {hoverProps, isHovered} = useHover({onHoverStart, onHoverChange, onHoverEnd, isDisabled}); let {isFocused, isFocusVisible, focusProps} = useFocusRing({ @@ -92,7 +94,8 @@ export const Group = /*#__PURE__*/ (forwardRef as forwardRefType)(function Group data-hovered={isHovered || undefined} data-focus-visible={isFocusVisible || undefined} data-disabled={isDisabled || undefined} - data-invalid={isInvalid || undefined}> + data-invalid={isInvalid || undefined} + data-readonly={isReadOnly || undefined}> {renderProps.children} ); 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/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', () => {