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 cannot have `display: none` or `hidden` for autofill to work. // In Firefox, there must be a to identify 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 `` element, which can be used to support browser * form autofill, mobile form navigation, and native form submission. */ -export function HiddenSelect(props: HiddenSelectProps): JSX.Element | null { +export function HiddenSelect(props: HiddenSelectProps): JSX.Element | null { let {state, triggerRef, label, name, form, isDisabled} = props; let selectRef = useRef(null); let inputRef = useRef(null); @@ -164,32 +175,43 @@ export function HiddenSelect(props: HiddenSelectProps): JSX.Element | null let data = selectData.get(state) || {}; let {validationBehavior} = data; - let inputProps: InputHTMLAttributes = { - type: 'hidden', - autoComplete: selectProps.autoComplete, - name, - form, - disabled: isDisabled, - value: state.selectedKey ?? '' - }; + // Always render at least one hidden input to ensure required form submission. + let values: (Key | null)[] = Array.isArray(state.value) ? state.value : [state.value]; + if (values.length === 0) { + values = [null]; + } + + let res = values.map((value, i) => { + let inputProps: InputHTMLAttributes = { + type: 'hidden', + autoComplete: selectProps.autoComplete, + name, + form, + disabled: isDisabled, + value: value ?? '' + }; + + 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. */}} /> + ); + } - 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 ( {label} @@ -409,7 +414,7 @@ The examples above show static collections, which can be used when the full list as shown below, can be used when the options come from an external data source such as an API call, or update over time. As seen below, an iterable list of options is passed to the Select using the `items` prop. Each item accepts an `id` prop, which -is passed to the `onSelectionChange` handler to identify the selected item. Alternatively, if the item objects contain an `id` property, +is passed to the `onChange` handler to identify the selected item. Alternatively, if the item objects contain an `id` property, as shown in the example below, then this is used automatically and an `id` prop is not required. ```tsx example @@ -434,11 +439,10 @@ function Example() { } ``` -## Selection +## Value -Setting a selected option can be done by using the `defaultSelectedKey` or `selectedKey` prop. The selected key corresponds to the `id` prop of an item. +Setting a selected option can be done by using the `defaultValue` or `value` prop. The value corresponds to the `id` prop of an item. When `Select` is used with a dynamic collection as described above, the id of each item is derived from the data. -See the [Selection](selection.html) guide for more details. ```tsx example import type {Key} from 'react-aria-components'; @@ -458,8 +462,43 @@ function Example() { setAnimal(selected)}> + value={animal} + onChange={selected => setAnimal(selected)}> + {item => {item.name}} + + ); +} +``` + +### Multiple selection + +Set the `selectionMode="multiple"` prop to enable the user to select multiple items. When multiple selection is enabled, the `value` prop should be an array of item ids instead of a single item, and `onChange` will be called with an array. See [below](#customizing-selectvalue) for how to customize the `SelectValue`. + +```tsx example +import type {Key} from 'react-aria-components'; + +function Example() { + let options = [ + {name: 'Koala'}, + {name: 'Kangaroo'}, + {name: 'Platypus'}, + {name: 'Bald Eagle'}, + {name: 'Bison'}, + {name: 'Skunk'} + ]; + /*- begin highlight -*/ + let [animals, setAnimals] = React.useState(['Bison', 'Kangaroo']); + /*- end highlight -*/ + + return ( + setAnimals(selected)}> {item => {item.name}} ); @@ -485,7 +524,7 @@ Select supports the `name` prop for integration with HTML forms. The `id` of the ## Links -By default, interacting with an item in a Select triggers `onSelectionChange`. Alternatively, items may be links to another page or website. This can be achieved by passing the `href` prop to the `` component. Link items in a `Select` are not selectable. +By default, interacting with an item in a Select triggers `onChange`. Alternatively, items may be links to another page or website. This can be achieved by passing the `href` prop to the `` component. Link items in a `Select` are not selectable. ```tsx example @@ -902,14 +941,14 @@ function Example() { Select passes the rendered children of the selected item in the render props of the `SelectValue` as well as if the placeholder should be showing. You can use this to customize the value displayed in the Select. ```tsx example - + Favorite Animal {/*- begin highlight -*/} - {({defaultChildren, isPlaceholder}) => { - return isPlaceholder ? <>Animal selection> : defaultChildren; - }} + {({selectedItems, defaultChildren, isPlaceholder}) => ( + isPlaceholder || selectedItems.length === 1 ? defaultChildren : `${selectedItems.length} selected items` + )} {/*- end highlight -*/} 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/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/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 = () => ( - +export const SelectExample: SelectStory = (args) => ( + Test @@ -53,13 +58,17 @@ export const SelectExample: SelectStory = () => ( ); -export const SelectRenderProps: SelectStory = () => ( - +export const SelectRenderProps: SelectStory = (args) => ( + {({isOpen}) => ( <> Test - + + {({selectedItems, defaultChildren}) => ( + selectedItems.length <= 1 ? defaultChildren : `${selectedItems.length} selected items` + )} + {isOpen ? '▲' : '▼'} @@ -75,6 +84,35 @@ export const SelectRenderProps: SelectStory = () => ( ); +export const SelectWithTagGroup: SelectStory = (args) => ( + + States + + + {({selectedItems, state}) => ( + 'No selected items'} + onRemove={(keys) => { + for (let key of keys) { + state.selectionManager.toggleSelection(key); + } + }}> + {item => {item.name}} + + )} + + + + + + + {state => {state.name}} + + + +); + 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 = () => ( - +export const SelectManyItems: SelectStory = (args) => ( + Test @@ -161,8 +199,8 @@ export const SelectManyItems: SelectStory = () => ( ); -export const VirtualizedSelect: SelectStory = () => ( - +export const VirtualizedSelect: SelectStory = (args) => ( + Test @@ -253,7 +291,7 @@ export const AsyncVirtualizedCollectionRenderSelect: StoryObj ( +export const SelectSubmitExample: SelectStory = (args) => ( ( - + Company @@ -303,6 +341,7 @@ export const RequiredSelectWithManyItems = (props) => ( ▼ + {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( Birth date 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( + + Birth date + + {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( + + Birth date + + {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()}> + + Select + + + + + + + {item => {item.name}} + + + + Submit + + ); + 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( + + Favorite Animal + + + {({selectedItems}) => ( + selectedItems.length === 1 ? selectedItems[0]?.name : `${selectedItems.length} selected items` + )} + + + + + {item => {item.name}} + + + + ); + + 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} + {label && {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"