diff --git a/packages/@internationalized/date/docs/CalendarDate.mdx b/packages/@internationalized/date/docs/CalendarDate.mdx index a14440ba735..b7740ba88d1 100644 --- a/packages/@internationalized/date/docs/CalendarDate.mdx +++ b/packages/@internationalized/date/docs/CalendarDate.mdx @@ -50,6 +50,8 @@ let date = parseDate('2022-02-03'); Today's date can be retrieved using the function. This requires a time zone identifier to be provided, which is used to determine the local date. The function can be used to retrieve the user's current time zone. +**Note:** the local time zone is cached after the first call. You can reset it by calling , or mock it in unit tests by calling . + ```tsx import {today, getLocalTimeZone} from '@internationalized/date'; @@ -206,6 +208,8 @@ A `CalendarDate` can be converted to a native JavaScript `Date` object using the Because a `Date` represents an exact time, a time zone identifier is required to be passed to the `toDate` method. The time of the returned date will be set to midnight in that time zone. The function can be used to retrieve the user's current time zone. +**Note:** the local time zone is cached after the first call. You can reset it by calling , or mock it in unit tests by calling . + ```tsx import {getLocalTimeZone} from '@internationalized/date'; diff --git a/packages/@internationalized/date/docs/CalendarDateTime.mdx b/packages/@internationalized/date/docs/CalendarDateTime.mdx index 3796777ed49..6e19cd58b2d 100644 --- a/packages/@internationalized/date/docs/CalendarDateTime.mdx +++ b/packages/@internationalized/date/docs/CalendarDateTime.mdx @@ -280,6 +280,8 @@ A `CalendarDateTime` can be converted to a native JavaScript `Date` object using Because a `Date` represents an exact time, a time zone identifier is required to be passed to the `toDate` method. The function can be used to retrieve the user's current time zone. +**Note:** the local time zone is cached after the first call. You can reset it by calling , or mock it in unit tests by calling . + ```tsx import {getLocalTimeZone} from '@internationalized/date'; diff --git a/packages/@internationalized/date/docs/ZonedDateTime.mdx b/packages/@internationalized/date/docs/ZonedDateTime.mdx index 543f89db6dd..5b7e8ca9940 100644 --- a/packages/@internationalized/date/docs/ZonedDateTime.mdx +++ b/packages/@internationalized/date/docs/ZonedDateTime.mdx @@ -76,6 +76,8 @@ let date = fromAbsolute(1688023843144, 'America/Los_Angeles'); The current time can be retrieved using the function. This requires a time zone identifier to be provided, which is used to determine the local time. The function can be used to retrieve the user's current time zone. +**Note:** the local time zone is cached after the first call. You can reset it by calling , or mock it in unit tests by calling . + ```tsx import {now, getLocalTimeZone} from '@internationalized/date'; diff --git a/packages/@react-aria/button/src/useButton.ts b/packages/@react-aria/button/src/useButton.ts index 8b33496778c..163b00c05e6 100644 --- a/packages/@react-aria/button/src/useButton.ts +++ b/packages/@react-aria/button/src/useButton.ts @@ -114,7 +114,8 @@ export function useButton(props: AriaButtonOptions, ref: RefObject< 'aria-expanded': props['aria-expanded'], 'aria-controls': props['aria-controls'], 'aria-pressed': props['aria-pressed'], - 'aria-current': props['aria-current'] + 'aria-current': props['aria-current'], + 'aria-disabled': props['aria-disabled'] }) }; } diff --git a/packages/@react-aria/button/test/useButton.test.js b/packages/@react-aria/button/test/useButton.test.js index 913e1586625..0a9492e0553 100644 --- a/packages/@react-aria/button/test/useButton.test.js +++ b/packages/@react-aria/button/test/useButton.test.js @@ -61,4 +61,11 @@ describe('useButton tests', function () { expect(typeof result.current.buttonProps.onKeyDown).toBe('function'); expect(result.current.buttonProps.rel).toBeUndefined(); }); + + it('handles aria-disabled passthrough for button elements', function () { + let props = {'aria-disabled': 'true'}; + let {result} = renderHook(() => useButton(props)); + expect(result.current.buttonProps['aria-disabled']).toBeTruthy(); + expect(result.current.buttonProps['disabled']).toBeUndefined(); + }); }); diff --git a/packages/@react-aria/collections/src/BaseCollection.ts b/packages/@react-aria/collections/src/BaseCollection.ts index cb41c19a1d3..e06d1a360f4 100644 --- a/packages/@react-aria/collections/src/BaseCollection.ts +++ b/packages/@react-aria/collections/src/BaseCollection.ts @@ -71,6 +71,15 @@ export class CollectionNode implements Node { return node; } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: FilterFn): CollectionNode | null { + let clone = this.clone(); + newCollection.addDescendants(clone, collection); + return clone; + } +} + +export class FilterableNode extends CollectionNode { filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: FilterFn): CollectionNode | null { let [firstKey, lastKey] = filterChildren(collection, newCollection, this.firstChildKey, filterFn); let newNode: Mutable> = this.clone(); @@ -80,26 +89,15 @@ export class CollectionNode implements Node { } } -// TODO: naming, but essentially these nodes shouldn't be affected by filtering (BaseNode)? -// Perhaps this filter logic should be in CollectionNode instead and the current logic of CollectionNode's filter should move to Table -export class FilterLessNode extends CollectionNode { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: FilterFn): FilterLessNode | null { - let clone = this.clone(); - newCollection.addDescendants(clone, collection); - return clone; - } -} - -export class HeaderNode extends FilterLessNode { +export class HeaderNode extends CollectionNode { static readonly type = 'header'; } -export class LoaderNode extends FilterLessNode { +export class LoaderNode extends CollectionNode { static readonly type = 'loader'; } -export class ItemNode extends CollectionNode { +export class ItemNode extends FilterableNode { static readonly type = 'item'; filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: FilterFn): ItemNode | null { @@ -113,7 +111,7 @@ export class ItemNode extends CollectionNode { } } -export class SectionNode extends CollectionNode { +export class SectionNode extends FilterableNode { static readonly type = 'section'; filter(collection: BaseCollection, newCollection: BaseCollection, filterFn: FilterFn): SectionNode | null { @@ -142,6 +140,7 @@ export class BaseCollection implements ICollection> { private lastKey: Key | null = null; private frozen = false; private itemCount: number = 0; + isComplete = true; get size(): number { return this.itemCount; diff --git a/packages/@react-aria/collections/src/CollectionBuilder.tsx b/packages/@react-aria/collections/src/CollectionBuilder.tsx index 83b1e834d57..742fe8c056e 100644 --- a/packages/@react-aria/collections/src/CollectionBuilder.tsx +++ b/packages/@react-aria/collections/src/CollectionBuilder.tsx @@ -116,6 +116,7 @@ function useCollectionDocument>(cr let collection = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); useLayoutEffect(() => { document.isMounted = true; + document.isInitialRender = false; return () => { // Mark unmounted so we can skip all of the collection updates caused by // React calling removeChild on every item in the collection. diff --git a/packages/@react-aria/collections/src/Document.ts b/packages/@react-aria/collections/src/Document.ts index 36bdb3491a8..be0510d8efc 100644 --- a/packages/@react-aria/collections/src/Document.ts +++ b/packages/@react-aria/collections/src/Document.ts @@ -416,6 +416,7 @@ export class Document = BaseCollection> extend nodeId = 0; nodesByProps: WeakMap> = new WeakMap>(); isMounted = true; + isInitialRender = true; private collection: C; private nextCollection: C | null = null; private subscriptions: Set<() => void> = new Set(); @@ -522,6 +523,10 @@ export class Document = BaseCollection> extend this.nextCollection = null; } } + + if (this.isInitialRender) { + this.collection.isComplete = false; + } } queueUpdate(): void { diff --git a/packages/@react-aria/collections/src/index.ts b/packages/@react-aria/collections/src/index.ts index c87d1d7fe1b..060f214ce0c 100644 --- a/packages/@react-aria/collections/src/index.ts +++ b/packages/@react-aria/collections/src/index.ts @@ -13,7 +13,7 @@ export {CollectionBuilder, Collection, createLeafComponent, createBranchComponent} from './CollectionBuilder'; export {createHideableComponent, useIsHidden} from './Hidden'; export {useCachedChildren} from './useCachedChildren'; -export {BaseCollection, CollectionNode, ItemNode, SectionNode, FilterLessNode, LoaderNode, HeaderNode} from './BaseCollection'; +export {BaseCollection, CollectionNode, ItemNode, SectionNode, FilterableNode, LoaderNode, HeaderNode} from './BaseCollection'; export type {CollectionBuilderProps, CollectionProps} from './CollectionBuilder'; export type {CachedChildrenOptions} from './useCachedChildren'; diff --git a/packages/@react-aria/overlays/src/calculatePosition.ts b/packages/@react-aria/overlays/src/calculatePosition.ts index 4b1c5955003..5d78be36f62 100644 --- a/packages/@react-aria/overlays/src/calculatePosition.ts +++ b/packages/@react-aria/overlays/src/calculatePosition.ts @@ -67,7 +67,7 @@ export interface PositionResult { position: Position, arrowOffsetLeft?: number, arrowOffsetTop?: number, - triggerOrigin: {x: number, y: number}, + triggerAnchorPoint: {x: number, y: number}, maxHeight: number, placement: PlacementAxis } @@ -450,7 +450,7 @@ export function calculatePositionInternal( } let crossOrigin = placement === 'left' || placement === 'top' ? overlaySize[size] : 0; - let triggerOrigin = { + let triggerAnchorPoint = { x: placement === 'top' || placement === 'bottom' ? origin : crossOrigin, y: placement === 'left' || placement === 'right' ? origin : crossOrigin }; @@ -461,7 +461,7 @@ export function calculatePositionInternal( arrowOffsetLeft: arrowPosition.left, arrowOffsetTop: arrowPosition.top, placement, - triggerOrigin + triggerAnchorPoint }; } diff --git a/packages/@react-aria/overlays/src/useOverlayPosition.ts b/packages/@react-aria/overlays/src/useOverlayPosition.ts index b2fc3572d76..24e1b74cad2 100644 --- a/packages/@react-aria/overlays/src/useOverlayPosition.ts +++ b/packages/@react-aria/overlays/src/useOverlayPosition.ts @@ -73,7 +73,7 @@ export interface PositionAria { /** Placement of the overlay with respect to the overlay trigger. */ placement: PlacementAxis | null, /** The origin of the target in the overlay's coordinate system. Useful for animations. */ - triggerOrigin: {x: number, y: number} | null, + triggerAnchorPoint: {x: number, y: number} | null, /** Updates the position of the overlay. */ updatePosition(): void } @@ -149,6 +149,11 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { return; } + // Delay updating the position until children are finished rendering (e.g. collections). + if (overlayRef.current.querySelector('[data-react-aria-incomplete]')) { + return; + } + // Don't update while the overlay is animating. // Things like scale animations can mess up positioning by affecting the overlay's computed size. if (overlayRef.current.getAnimations?.().length > 0) { @@ -294,14 +299,16 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { return { overlayProps: { style: { - position: 'absolute', + position: position ? 'absolute' : 'fixed', + top: !position ? 0 : undefined, + left: !position ? 0 : undefined, zIndex: 100000, // should match the z-index in ModalTrigger ...position?.position, maxHeight: position?.maxHeight ?? '100vh' } }, placement: position?.placement ?? null, - triggerOrigin: position?.triggerOrigin ?? null, + triggerAnchorPoint: position?.triggerAnchorPoint ?? null, arrowProps: { 'aria-hidden': 'true', role: 'presentation', diff --git a/packages/@react-aria/overlays/src/usePopover.ts b/packages/@react-aria/overlays/src/usePopover.ts index ba66ae49704..3263932e978 100644 --- a/packages/@react-aria/overlays/src/usePopover.ts +++ b/packages/@react-aria/overlays/src/usePopover.ts @@ -74,7 +74,7 @@ export interface PopoverAria { /** Placement of the popover with respect to the trigger. */ placement: PlacementAxis | null, /** The origin of the target in the overlay's coordinate system. Useful for animations. */ - triggerOrigin: {x: number, y: number} | null + triggerAnchorPoint: {x: number, y: number} | null } /** @@ -106,7 +106,7 @@ export function usePopover(props: AriaPopoverProps, state: OverlayTriggerState): groupRef ?? popoverRef ); - let {overlayProps: positionProps, arrowProps, placement, triggerOrigin: origin} = useOverlayPosition({ + let {overlayProps: positionProps, arrowProps, placement, triggerAnchorPoint: origin} = useOverlayPosition({ ...otherProps, targetRef: triggerRef, overlayRef: popoverRef, @@ -133,6 +133,6 @@ export function usePopover(props: AriaPopoverProps, state: OverlayTriggerState): arrowProps, underlayProps, placement, - triggerOrigin: origin + triggerAnchorPoint: origin }; } diff --git a/packages/@react-aria/overlays/test/calculatePosition.test.ts b/packages/@react-aria/overlays/test/calculatePosition.test.ts index f9020d46322..ed3bde4dcb6 100644 --- a/packages/@react-aria/overlays/test/calculatePosition.test.ts +++ b/packages/@react-aria/overlays/test/calculatePosition.test.ts @@ -128,7 +128,7 @@ describe('calculatePosition', function () { arrowOffsetTop: expected[3], maxHeight, placement: calculatedPlacement, - triggerOrigin: { + triggerAnchorPoint: { x: expected[2] ?? (calculatedPlacement === 'left' ? overlaySize.width : 0), y: expected[3] ?? (calculatedPlacement === 'top' ? Math.min(overlaySize.height, maxHeight) : 0) } diff --git a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx index d545624255b..c37053655aa 100644 --- a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx +++ b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx @@ -19,16 +19,31 @@ import { ListBoxProps, Provider } from 'react-aria-components'; -import {DOMRef, DOMRefValue, GlobalDOMAttributes, Orientation, Selection} from '@react-types/shared'; +import {DOMRef, DOMRefValue, GlobalDOMAttributes, Orientation} from '@react-types/shared'; import {focusRing, style} from '../style' with {type: 'macro'}; import {getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import {IllustrationContext} from '../src/Icon'; -import React, {createContext, forwardRef, ReactNode, useContext, useMemo} from 'react'; +import {pressScale} from './pressScale'; +import React, {createContext, forwardRef, ReactNode, useContext, useMemo, useRef} from 'react'; import {TextContext} from './Content'; -import {useControlledState} from '@react-stately/utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; -export interface SelectBoxGroupProps extends StyleProps, Omit, keyof GlobalDOMAttributes | 'layout' | 'dragAndDropHooks' | 'renderEmptyState' | 'dependencies' | 'items' | 'children' | 'selectionMode'>{ +type ExcludedListBoxProps = + | keyof GlobalDOMAttributes + | 'layout' + | 'dragAndDropHooks' + | 'renderEmptyState' + | 'dependencies' + | 'items' + | 'onAction' + | 'children' + | 'selectionMode' + | 'shouldSelectOnPress' + | 'shouldFocusWrap' + | 'selectionBehavior' + | 'shouldSelectOnFocus'; + +export interface SelectBoxGroupProps extends StyleProps, Omit, ExcludedListBoxProps> { /** * The SelectBox elements contained within the SelectBoxGroup. */ @@ -38,33 +53,10 @@ export interface SelectBoxGroupProps extends StyleProps, Omit * @default 'single' */ selectionMode?: 'single' | 'multiple', - /** - * The currently selected keys in the collection (controlled). - */ - selectedKeys?: Selection, - /** - * The initial selected keys in the collection (uncontrolled). - */ - defaultSelectedKeys?: Selection, - /** - * Number of columns to display the SelectBox elements in. - * @default 2 - */ - numColumns?: number, - /** - * Gap between grid items. - * @default 'default' - */ - gutterWidth?: 'default' | 'compact' | 'spacious', /** * Whether the SelectBoxGroup is disabled. */ - isDisabled?: boolean, - /** - * Whether to show selection checkboxes for all SelectBoxes. - * @default false - */ - showCheckbox?: boolean + isDisabled?: boolean } export interface SelectBoxProps extends StyleProps { @@ -85,10 +77,7 @@ export interface SelectBoxProps extends StyleProps { interface SelectBoxContextValue { allowMultiSelect?: boolean, orientation?: Orientation, - isDisabled?: boolean, - showCheckbox?: boolean, - selectedKeys?: Selection, - onSelectionChange?: (keys: Selection) => void + isDisabled?: boolean } export const SelectBoxContext = createContext({orientation: 'vertical'}); @@ -98,9 +87,6 @@ const labelOnly = ':has([slot=label]):not(:has([slot=description]))'; const noIllustration = ':not(:has([slot=illustration]))'; const selectBoxStyles = style({ ...focusRing(), - outlineOffset: { - isFocusVisible: -2 - }, display: 'grid', gridAutoRows: '1fr', position: 'relative', @@ -116,7 +102,7 @@ const selectBoxStyles = style({ height: { default: 170, orientation: { - horizontal: '100%' + horizontal: 'auto' } }, minWidth: { @@ -151,12 +137,12 @@ const selectBoxStyles = style({ }, paddingStart: { orientation: { - horizontal: 24 + horizontal: 32 } }, paddingEnd: { orientation: { - horizontal: 32 + horizontal: 24 } }, gridTemplateAreas: { @@ -181,14 +167,14 @@ const selectBoxStyles = style({ orientation: { vertical: ['min-content', 8, 'min-content'], horizontal: { - default: ['min-content', 'min-content'], + default: ['min-content', 2, 'min-content'], [noIllustration]: ['min-content'] } } }, gridTemplateColumns: { orientation: { - horizontal: ['min-content', 12, '1fr'] + horizontal: 'min-content 10px 1fr' } }, alignContent: { @@ -214,15 +200,10 @@ const selectBoxStyles = style({ default: 'emphasized', isHovered: 'elevated', isSelected: 'elevated', - forcedColors: 'none', - isDisabled: 'emphasized' + isDisabled: 'none' }, borderWidth: 2, - transition: 'default', - cursor: { - default: 'pointer', - isDisabled: 'default' - } + transition: 'default' }, getAllowedOverrides()); const illustrationContainer = style({ @@ -231,7 +212,8 @@ const illustrationContainer = style({ justifySelf: 'center', minSize: 48, color: { - isDisabled: 'disabled' + isDisabled: 'disabled', + isHovered: 'gray-900' }, opacity: { isDisabled: 0.4 @@ -256,6 +238,7 @@ const descriptionText = style({ }, color: { default: 'neutral', + isHovered: 'gray-900', isDisabled: 'disabled' } }); @@ -287,19 +270,19 @@ const labelText = style({ }, color: { default: 'neutral', + isHovered: 'gray-900', isDisabled: 'disabled' } }); -const gridStyles = style({ +const gridStyles = style<{orientation?: Orientation}>({ display: 'grid', - outline: 'none', gridAutoRows: '1fr', - gap: { - gutterWidth: { - default: 16, - compact: 8, - spacious: 24 + gap: 16, + gridTemplateColumns: { + orientation: { + horizontal: 'repeat(auto-fit, minmax(368px, 1fr))', + vertical: 'repeat(auto-fit, minmax(170px, 1fr))' } } }, getAllowedOverrides()); @@ -308,81 +291,82 @@ const gridStyles = style({ * SelectBox is a single selectable item in a SelectBoxGroup. */ export function SelectBox(props: SelectBoxProps): ReactNode { - let {children, value, isDisabled: individualDisabled = false, UNSAFE_style} = props; + let {children, value, isDisabled: individualDisabled = false, UNSAFE_style, UNSAFE_className, styles, ...otherProps} = props; let { orientation = 'vertical', - isDisabled: groupDisabled = false, - showCheckbox = false + isDisabled: groupDisabled = false } = useContext(SelectBoxContext); - const size = 'M'; const isDisabled = individualDisabled || groupDisabled; + const ref = useRef(null); return ( (props.UNSAFE_className || '') + selectBoxStyles({ - size, - orientation, - ...renderProps - }, props.styles)} - style={UNSAFE_style}> - {(renderProps) => ( - <> - {showCheckbox && (renderProps.isSelected || (!renderProps.isDisabled && renderProps.isHovered)) && ( + isDisabled={isDisabled} + ref={ref} + className={renderProps => (UNSAFE_className || '') + selectBoxStyles({ + ...renderProps, + orientation + }, styles)} + style={pressScale(ref, UNSAFE_style)} + {...otherProps}> + {({isSelected, isDisabled, isHovered}) => { + return ( + <> - )} - - {children} - - - )} + }] + ]}> + {children} + + + ); + }} ); } -/** +/* * SelectBoxGroup allows users to select one or more options from a list. */ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(props: SelectBoxGroupProps, ref: DOMRef) { @@ -390,54 +374,32 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup { const contextValue = { - allowMultiSelect: selectionMode === 'multiple', orientation, - isDisabled, - showCheckbox, - selectedKeys, - onSelectionChange: setSelectedKeys + isDisabled }; - return contextValue; }, - [selectionMode, orientation, isDisabled, showCheckbox, selectedKeys, setSelectedKeys] + [orientation, isDisabled] ); return ( + layout="grid" + className={(UNSAFE_className || '') + gridStyles({orientation})} + style={UNSAFE_style} + {...otherProps}> {children} diff --git a/packages/@react-spectrum/s2/src/SkeletonCollection.tsx b/packages/@react-spectrum/s2/src/SkeletonCollection.tsx index 05a591af3ea..ca2d2206bb1 100644 --- a/packages/@react-spectrum/s2/src/SkeletonCollection.tsx +++ b/packages/@react-spectrum/s2/src/SkeletonCollection.tsx @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {createLeafComponent, FilterLessNode} from '@react-aria/collections'; +import {CollectionNode, createLeafComponent} from '@react-aria/collections'; import {ReactNode} from 'react'; import {Skeleton} from './Skeleton'; @@ -20,7 +20,7 @@ export interface SkeletonCollectionProps { let cache = new WeakMap(); -class SkeletonNode extends FilterLessNode { +class SkeletonNode extends CollectionNode { static readonly type = 'skeleton'; } diff --git a/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx b/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx index 8babd315516..c0720a6b6d2 100644 --- a/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx +++ b/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx @@ -17,14 +17,12 @@ import {action} from '@storybook/addon-actions'; import AlertNotice from '../spectrum-illustrations/linear/AlertNotice'; -import {Button, SelectBox, SelectBoxGroup, Text} from '../src'; import type {Meta, StoryObj} from '@storybook/react'; import PaperAirplane from '../spectrum-illustrations/linear/Paperairplane'; -import React, {useState} from 'react'; -import type {Selection} from 'react-aria-components'; +import React from 'react'; +import {SelectBox, SelectBoxGroup, Text} from '../src'; import Server from '../spectrum-illustrations/linear/Server'; import StarFilled1 from '../spectrum-illustrations/gradient/generic1/Star'; -import StarFilled2 from '../spectrum-illustrations/gradient/generic2/Star'; import {style} from '../style' with {type: 'macro'}; const headingStyles = style({ @@ -48,14 +46,6 @@ const sectionHeadingStyles = style({ marginBottom: 8 }); -const descriptionStyles = style({ - font: 'body', - fontSize: 'body-sm', - color: 'gray-600', - margin: 0, - marginBottom: 16 -}); - const meta: Meta = { title: 'SelectBoxGroup', component: SelectBoxGroup, @@ -72,248 +62,46 @@ const meta: Meta = { control: 'select', options: ['vertical', 'horizontal'] }, - numColumns: { - control: {type: 'number', min: 1, max: 4} - }, - gutterWidth: { - control: 'select', - options: ['compact', 'default', 'spacious'] - }, - showCheckbox: { - control: 'boolean' - } + selectedKeys: {control: false, table: {disable: true}}, + defaultSelectedKeys: {control: false, table: {disable: true}} }, args: { selectionMode: 'single', orientation: 'vertical', - numColumns: 2, - gutterWidth: 'default', - isDisabled: false, - showCheckbox: false + isDisabled: false } }; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = { - render: (args) => ( - - - - Amazon Web Services - Reliable cloud infrastructure - - - - Microsoft Azure - - - - Google Cloud Platform - - - - IBM Cloud - Hybrid cloud solutions - - - ) -}; - -export const MultipleSelection: Story = { - args: { - selectionMode: 'multiple', - defaultSelectedKeys: new Set(['aws', 'gcp']), - numColumns: 3, - gutterWidth: 'default' - }, - render: (args) => ( -
-

- Focus any item and use arrow keys for grid navigation: -

- - - - Amazon Web Services - {/* Reliable cloud infrastructure */} - - - - Microsoft Azure - Enterprise cloud solutions - - - - Google Cloud Platform - Modern cloud services - - - - Oracle Cloud - Database-focused cloud - - - - IBM Cloud - Hybrid cloud solutions - - - - Alibaba Cloud - Asia-focused services - - - - DigitalOcean - Developer-friendly platform - - - - Linode - Simple cloud computing - - - - Vultr - High performance cloud - - -
- ) -}; - -export const DisabledGroup: Story = { - args: { - isDisabled: true, - defaultSelectedKeys: new Set(['option1']), - isCheckboxSelection: true - }, - render: (args) => ( - - - - Selected then Disabled - - - - Disabled - - - ) -}; - -function InteractiveExamplesStory() { - const [selectedKeys, setSelectedKeys] = useState(new Set(['enabled1', 'starred2'])); - - return ( -
-

Interactive Features Combined

-

- Current selection: {selectedKeys === 'all' ? 'All' : Array.from(selectedKeys).join(', ') || 'None'} -

- - { - setSelectedKeys(selection); - action('onSelectionChange')(selection); - }}> - {/* Enabled items with dynamic illustrations */} - - {selectedKeys !== 'all' && selectedKeys.has('enabled1') ? ( - - ) : ( - - )} - Enabled Item 1 - Status updates - - - {selectedKeys !== 'all' && selectedKeys.has('enabled2') ? ( - - ) : ( - - )} - Enabled Item 2 - Click to toggle - - {/* Disabled item */} - - - Disabled Item - Cannot select - - - {selectedKeys !== 'all' && selectedKeys.has('starred1') ? ( - - ) : ( - - )} - Starred Item 1 - Click to star - - - {selectedKeys !== 'all' && selectedKeys.has('starred2') ? ( - - ) : ( - - )} - Starred Item 2 - Click to star - - - - Disabled Service - Cannot select - - - {selectedKeys !== 'all' && selectedKeys.has('dynamic1') ? ( - - ) : ( - - )} - Dynamic Illustration - Click to activate - - - {selectedKeys !== 'all' && selectedKeys.has('controllable') ? ( + render: (args) => { + return ( +
+ + + + Amazon Web Services + Reliable cloud infrastructure + + + + Microsoft Azure + + + + Google Cloud Platform + + - ) : ( - - )} - Controllable - External control available - - - -
- - - + IBM Cloud + Hybrid cloud solutions + +
-
- ); -} - -export const InteractiveExamples: Story = { - render: () => + ); + } }; export const AllSlotCombinations: Story = { @@ -331,7 +119,6 @@ export const AllSlotCombinations: Story = {

Text Only

Simple Text @@ -344,7 +131,6 @@ export const AllSlotCombinations: Story = {

Illustration + Text

@@ -358,7 +144,6 @@ export const AllSlotCombinations: Story = {

Text + Description

Main Text @@ -372,7 +157,6 @@ export const AllSlotCombinations: Story = {

Illustration + Description

@@ -386,7 +170,6 @@ export const AllSlotCombinations: Story = {

Illustration + Text + Description

@@ -409,7 +192,6 @@ export const AllSlotCombinations: Story = {

Text Only

Simple Horizontal Text @@ -422,7 +204,6 @@ export const AllSlotCombinations: Story = {

Illustration + Text

@@ -436,7 +217,6 @@ export const AllSlotCombinations: Story = {

Text + Description

Main Horizontal Text @@ -450,7 +230,6 @@ export const AllSlotCombinations: Story = {

Illustration + Text + Description

@@ -467,8 +246,6 @@ export const AllSlotCombinations: Story = {

Side-by-Side Comparison

{/* Vertical examples */} @@ -502,8 +279,6 @@ export const AllSlotCombinations: Story = {
{/* Horizontal examples */} @@ -539,57 +314,3 @@ export const AllSlotCombinations: Story = {
) }; - -export const TextSlots: Story = { - args: { - orientation: 'horizontal' - }, - render: (args) => ( -
-

Text Slots Example

- - - - Amazon Web Services - Reliable cloud infrastructure - - - - Microsoft Azure - Enterprise cloud solutions - - - - Google Cloud Platform - Modern cloud services - - - - Oracle Cloud - Database-focused cloud - - -
- ) -}; - -export const WithDescription: Story = { - args: { - orientation: 'horizontal' - }, - render: (args) => ( -
-

With Description

- - - - Reliable cloud infrastructure - - - - Microsoft Azure - - -
- ) -}; diff --git a/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx b/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx index 8100384d039..8767055bf3d 100644 --- a/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx +++ b/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx @@ -1,11 +1,12 @@ -import {act, render, screen, waitFor} from '@react-spectrum/test-utils-internal'; +import {act, pointerMap, render, screen, waitFor} from '@react-spectrum/test-utils-internal'; import Calendar from '../spectrum-illustrations/linear/Calendar'; import React from 'react'; import {SelectBox, SelectBoxGroup, Text} from '../src'; import {Selection} from '@react-types/shared'; +import {User} from '@react-aria/test-utils'; import userEvent from '@testing-library/user-event'; -function SingleSelectBox() { +function ControlledSingleSelectBox() { const [selectedKeys, setSelectedKeys] = React.useState(new Set()); return ( (new Set()); return ( + + Option 1 + + + Option 2 + + + Option 3 + + + ); +} + function DisabledSelectBox() { return ( { + let user; + let testUtilUser = new User(); + + beforeAll(function () { + jest.useFakeTimers(); + user = userEvent.setup({delay: null, pointerMap}); + }); + + afterEach(() => { + jest.clearAllMocks(); + act(() => jest.runAllTimers()); + }); + + afterAll(function () { + jest.restoreAllMocks(); + }); + describe('Basic functionality', () => { it('renders as a listbox with options', () => { - render(); + render(); expect(screen.getByRole('listbox')).toBeInTheDocument(); expect(screen.getAllByRole('option')).toHaveLength(3); expect(screen.getByText('Option 1')).toBeInTheDocument(); }); it('renders multiple selection mode', () => { - render(); + render(); expect(screen.getByRole('listbox')).toBeInTheDocument(); expect(screen.getAllByRole('option')).toHaveLength(3); expect(screen.getByText('Option 1')).toBeInTheDocument(); }); + }); - it('handles selection in single mode', async () => { - render(); - const options = screen.getAllByRole('option'); - const option1 = options.find(option => option.textContent?.includes('Option 1'))!; + describe('Uncontrolled behavior', () => { + it('handles uncontrolled click selection in single mode', async () => { + render(); + let listboxTester = testUtilUser.createTester('ListBox', {root: screen.getByRole('listbox')}); - await userEvent.click(option1); - expect(option1).toHaveAttribute('aria-selected', 'true'); + await listboxTester.toggleOptionSelection({option: 0}); + expect(listboxTester.options()[0]).toHaveAttribute('aria-selected', 'true'); }); - it('handles multiple selection', async () => { - render(); - const options = screen.getAllByRole('option'); - const option1 = options.find(option => option.textContent?.includes('Option 1'))!; - const option2 = options.find(option => option.textContent?.includes('Option 2'))!; + it('handles uncontrolled click selection in multiple mode', async () => { + render(); + let listboxTester = testUtilUser.createTester('ListBox', {root: screen.getByRole('listbox')}); + + await listboxTester.toggleOptionSelection({option: 0}); + await listboxTester.toggleOptionSelection({option: 1}); + + expect(listboxTester.options()[0]).toHaveAttribute('aria-selected', 'true'); + expect(listboxTester.options()[1]).toHaveAttribute('aria-selected', 'true'); + }); + + it('handles uncontrolled selection toggle', async () => { + render(); + let listboxTester = testUtilUser.createTester('ListBox', {root: screen.getByRole('listbox')}); - await userEvent.click(option1); - await userEvent.click(option2); + await listboxTester.toggleOptionSelection({option: 0}); + expect(listboxTester.options()[0]).toHaveAttribute('aria-selected', 'true'); + // Toggle off in single mode by selecting another + await listboxTester.toggleOptionSelection({option: 1}); + expect(listboxTester.options()[0]).toHaveAttribute('aria-selected', 'false'); + expect(listboxTester.options()[1]).toHaveAttribute('aria-selected', 'true'); + }); + + it('handles uncontrolled keyboard selection', async () => { + render(); + const listbox = screen.getByRole('listbox'); + + await act(async () => { + listbox.focus(); + }); + + await act(async () => { + await user.keyboard(' '); + }); + + const option1 = screen.getByRole('option', {name: 'Option 1'}); expect(option1).toHaveAttribute('aria-selected', 'true'); - expect(option2).toHaveAttribute('aria-selected', 'true'); + }); + }); + + describe('Controlled behavior', () => { + it('handles controlled selection in single mode', async () => { + render(); + let listboxTester = testUtilUser.createTester('ListBox', {root: screen.getByRole('listbox')}); + + await listboxTester.toggleOptionSelection({option: 0}); + expect(listboxTester.options()[0]).toHaveAttribute('aria-selected', 'true'); + }); + + it('handles controlled multiple selection', async () => { + render(); + let listboxTester = testUtilUser.createTester('ListBox', {root: screen.getByRole('listbox')}); + + await listboxTester.toggleOptionSelection({option: 0}); + await listboxTester.toggleOptionSelection({option: 1}); + + expect(listboxTester.options()[0]).toHaveAttribute('aria-selected', 'true'); + expect(listboxTester.options()[1]).toHaveAttribute('aria-selected', 'true'); + }); + + it('calls onSelectionChange when selection changes in controlled mode', async () => { + const onSelectionChange = jest.fn(); + const TestComponent = () => { + const [selectedKeys, setSelectedKeys] = React.useState(new Set()); + return ( + { + setSelectedKeys(keys); + onSelectionChange(keys); + }} + selectedKeys={selectedKeys}> + + Option 1 + + + Option 2 + + + ); + }; + + render(); + const option1 = screen.getByRole('option', {name: 'Option 1'}); + await user.click(option1); + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + const receivedSelection = onSelectionChange.mock.calls[0][0]; + expect(Array.from(receivedSelection)).toEqual(['option1']); }); - it('handles disabled state', () => { + it('calls onSelectionChange with Set for multiple selection in controlled mode', async () => { + const onSelectionChange = jest.fn(); + const TestComponent = () => { + const [selectedKeys, setSelectedKeys] = React.useState(new Set()); + return ( + { + setSelectedKeys(keys); + onSelectionChange(keys); + }} + selectedKeys={selectedKeys}> + + Option 1 + + + Option 2 + + + ); + }; + + render(); + const option1 = screen.getByRole('option', {name: 'Option 1'}); + await user.click(option1); + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + const receivedSelection = onSelectionChange.mock.calls[0][0]; + expect(Array.from(receivedSelection)).toEqual(['option1']); + }); + }); + + describe('Disabled behavior', () => { + it('renders disabled state correctly', () => { render(); const listbox = screen.getByRole('listbox'); expect(listbox).toBeInTheDocument(); @@ -132,27 +283,51 @@ describe('SelectBoxGroup', () => { const option1 = screen.getByRole('option', {name: 'Option 1'}); const option2 = screen.getByRole('option', {name: 'Option 2'}); + await user.click(option1); + await user.click(option2); + expect(onSelectionChange).not.toHaveBeenCalled(); - await userEvent.click(option1); - await userEvent.click(option2); + expect(option1).toHaveAttribute('aria-disabled', 'true'); + expect(option2).toHaveAttribute('aria-disabled', 'true'); + }); + + it('prevents uncontrolled interaction when group is disabled', async () => { + render( + + + Option 1 + + + Option 2 + + + ); + + const option1 = screen.getByRole('option', {name: 'Option 1'}); + const option2 = screen.getByRole('option', {name: 'Option 2'}); - expect(onSelectionChange).not.toHaveBeenCalled(); + await user.click(option1); + await user.click(option2); - // Items should have disabled attributes + // should have disabled attributes and no selection expect(option1).toHaveAttribute('aria-disabled', 'true'); expect(option2).toHaveAttribute('aria-disabled', 'true'); + expect(option1).toHaveAttribute('aria-selected', 'false'); + expect(option2).toHaveAttribute('aria-selected', 'false'); }); }); describe('Checkbox functionality', () => { - it('shows checkbox when showCheckbox=true and item is selected', async () => { + it('shows checkbox when item is selected in controlled mode', async () => { render( {}} - selectedKeys={new Set(['option1'])} - showCheckbox> + selectedKeys={new Set(['option1'])}> Option 1 @@ -169,14 +344,24 @@ describe('SelectBoxGroup', () => { expect(checkboxDiv).toBeInTheDocument(); }); - it('shows checkbox on hover when showCheckbox=true for non-disabled items', async () => { + it('shows checkbox when item is selected in uncontrolled mode', async () => { + render(); + + const option1 = screen.getByRole('option', {name: 'Option 1'}); + await user.click(option1); + + expect(option1).toHaveAttribute('aria-selected', 'true'); + const checkboxDiv = option1.querySelector('[aria-hidden="true"]'); + expect(checkboxDiv).toBeInTheDocument(); + }); + + it('shows checkbox on hover for non-disabled items in controlled mode', async () => { render( {}} - selectedKeys={new Set()} - showCheckbox> + selectedKeys={new Set()}> Option 1 @@ -185,40 +370,32 @@ describe('SelectBoxGroup', () => { const row = screen.getByRole('option', {name: 'Option 1'}); - await userEvent.hover(row); + await user.hover(row); await waitFor(() => { const checkboxDiv = row.querySelector('[aria-hidden="true"]'); expect(checkboxDiv).toBeInTheDocument(); }); }); - it('does not show checkbox when showCheckbox=false', async () => { - render( - {}} - selectedKeys={new Set(['option1'])}> - - Option 1 - - - ); + it('shows checkbox on hover for non-disabled items in uncontrolled mode', async () => { + render(); const row = screen.getByRole('option', {name: 'Option 1'}); - const checkboxDiv = row.querySelector('[aria-hidden="true"]'); - expect(checkboxDiv).not.toBeInTheDocument(); + await user.hover(row); + await waitFor(() => { + const checkboxDiv = row.querySelector('[aria-hidden="true"]'); + expect(checkboxDiv).toBeInTheDocument(); + }); }); - it('shows checkbox for disabled but selected items when showCheckbox=true', () => { + it('shows checkbox for disabled but selected items', () => { render( {}} - defaultSelectedKeys={new Set(['option1'])} - showCheckbox> + defaultSelectedKeys={new Set(['option1'])}> Option 1 @@ -231,14 +408,13 @@ describe('SelectBoxGroup', () => { expect(checkboxDiv).toBeInTheDocument(); }); - it('does not show checkbox on hover for disabled items', async () => { + it('shows checkbox for disabled items (always show checkboxes)', async () => { render( {}} - selectedKeys={new Set()} - showCheckbox> + selectedKeys={new Set()}> Option 1 @@ -247,12 +423,9 @@ describe('SelectBoxGroup', () => { const row = screen.getByRole('option', {name: 'Option 1'}); - await userEvent.hover(row); - - await waitFor(() => { - const checkboxDiv = row.querySelector('[aria-hidden="true"]'); - expect(checkboxDiv).not.toBeInTheDocument(); - }, {timeout: 1000}); + // checkbox always present + const checkboxDiv = row.querySelector('[aria-hidden="true"]'); + expect(checkboxDiv).toBeInTheDocument(); }); }); @@ -273,44 +446,53 @@ describe('SelectBoxGroup', () => { expect(screen.getByRole('listbox')).toBeInTheDocument(); }); - it('supports different gutter widths', () => { + it('auto-fits columns based on orientation (vertical)', () => { render( - {}} - selectedKeys={new Set()} - gutterWidth="compact"> - - Option 1 - - +
+ {}} + selectedKeys={new Set()} + orientation="vertical"> + + Option 1 + + + Option 2 + + + Option 3 + + +
); - expect(screen.getByRole('listbox')).toBeInTheDocument(); + + const listbox = screen.getByRole('listbox'); + expect(listbox).toBeInTheDocument(); }); - it('supports custom number of columns', () => { + it('auto-fits columns based on orientation (horizontal)', () => { render( - {}} - selectedKeys={new Set()} - numColumns={3}> - - Option 1 - - - Option 2 - - - Option 3 - - +
+ {}} + selectedKeys={new Set()} + orientation="horizontal"> + + Option 1 + + + Option 2 + + +
); const listbox = screen.getByRole('listbox'); - expect(listbox).toHaveStyle('grid-template-columns: repeat(3, 1fr)'); + expect(listbox).toBeInTheDocument(); }); }); @@ -366,14 +548,12 @@ describe('SelectBoxGroup', () => { expect(option3).toHaveAttribute('aria-selected', 'false'); }); - it('calls onSelectionChange when selection changes', async () => { - const onSelectionChange = jest.fn(); + it('handles uncontrolled selection with defaultSelectedKeys', async () => { render( - + Option 1 @@ -382,38 +562,55 @@ describe('SelectBoxGroup', () => { ); - + const option1 = screen.getByRole('option', {name: 'Option 1'}); - await userEvent.click(option1); + const option2 = screen.getByRole('option', {name: 'Option 2'}); - expect(onSelectionChange).toHaveBeenCalledTimes(1); - const receivedSelection = onSelectionChange.mock.calls[0][0]; - expect(Array.from(receivedSelection)).toEqual(['option1']); + expect(option1).toHaveAttribute('aria-selected', 'true'); + expect(option2).toHaveAttribute('aria-selected', 'false'); + + // click should update selection + await user.click(option2); + expect(option1).toHaveAttribute('aria-selected', 'false'); + expect(option2).toHaveAttribute('aria-selected', 'true'); }); - it('calls onSelectionChange with Set for multiple selection', async () => { - const onSelectionChange = jest.fn(); + it('handles uncontrolled multiple selection with defaultSelectedKeys', async () => { render( - + Option 1 Option 2 + + Option 3 + ); - + const option1 = screen.getByRole('option', {name: 'Option 1'}); - await userEvent.click(option1); + const option2 = screen.getByRole('option', {name: 'Option 2'}); + const option3 = screen.getByRole('option', {name: 'Option 3'}); - expect(onSelectionChange).toHaveBeenCalledTimes(1); - const receivedSelection = onSelectionChange.mock.calls[0][0]; - expect(Array.from(receivedSelection)).toEqual(['option1']); + expect(option1).toHaveAttribute('aria-selected', 'true'); + expect(option2).toHaveAttribute('aria-selected', 'true'); + expect(option3).toHaveAttribute('aria-selected', 'false'); + + await user.click(option3); + expect(option1).toHaveAttribute('aria-selected', 'true'); + expect(option2).toHaveAttribute('aria-selected', 'true'); + expect(option3).toHaveAttribute('aria-selected', 'true'); + + // click should remove from selection + await user.click(option1); + expect(option1).toHaveAttribute('aria-selected', 'false'); + expect(option2).toHaveAttribute('aria-selected', 'true'); + expect(option3).toHaveAttribute('aria-selected', 'true'); }); it('handles controlled component updates', async () => { @@ -444,8 +641,8 @@ describe('SelectBoxGroup', () => { render(); const button = screen.getByRole('button', {name: 'Select Option 2'}); - await userEvent.click(button); - + await user.click(button); + const option2 = screen.getByRole('option', {name: 'Option 2'}); expect(option2).toHaveAttribute('aria-selected', 'true'); }); @@ -474,51 +671,6 @@ describe('SelectBoxGroup', () => { }); }); - describe('Individual SelectBox behavior', () => { - it('handles disabled individual items', () => { - render( - {}} - selectedKeys={new Set()}> - - Option 1 - - - Option 2 - - - ); - - const rows = screen.getAllByRole('option'); - expect(rows.length).toBe(2); - - const option1 = screen.getByRole('option', {name: 'Option 1'}); - expect(option1).toHaveAttribute('aria-disabled', 'true'); - }); - - it('prevents interaction with disabled items', async () => { - const onSelectionChange = jest.fn(); - render( - - - Option 1 - - - ); - - const option1 = screen.getByRole('option', {name: 'Option 1'}); - await userEvent.click(option1); - - expect(onSelectionChange).not.toHaveBeenCalled(); - }); - }); - describe('Grid navigation', () => { it('supports keyboard navigation and grid layout', async () => { render( @@ -526,8 +678,7 @@ describe('SelectBoxGroup', () => { aria-label="Grid navigation test" selectionMode="single" onSelectionChange={() => {}} - selectedKeys={new Set()} - numColumns={2}> + selectedKeys={new Set()}> Option 1 @@ -548,40 +699,19 @@ describe('SelectBoxGroup', () => { expect(listbox).toBeInTheDocument(); expect(options).toHaveLength(4); - - expect(listbox).toHaveStyle('grid-template-columns: repeat(2, 1fr)'); - - expect(screen.getByRole('option', {name: 'Option 1'})).toBeInTheDocument(); - expect(screen.getByRole('option', {name: 'Option 2'})).toBeInTheDocument(); - expect(screen.getByRole('option', {name: 'Option 3'})).toBeInTheDocument(); - expect(screen.getByRole('option', {name: 'Option 4'})).toBeInTheDocument(); }); - it('supports space key selection', async () => { - const onSelectionChange = jest.fn(); - render( - - - Option 1 - - - ); - + it('supports space key selection in uncontrolled mode', async () => { + render(); const listbox = screen.getByRole('listbox'); + const option1 = screen.getByRole('option', {name: 'Option 1'}); + await act(async () => { listbox.focus(); + await user.keyboard(' '); }); - await act(async () => { - await userEvent.keyboard(' '); - }); - expect(onSelectionChange).toHaveBeenCalledTimes(1); - const receivedSelection = onSelectionChange.mock.calls[0][0]; - expect(Array.from(receivedSelection)).toEqual(['option1']); + expect(option1).toHaveAttribute('aria-selected', 'true'); }); it('supports arrow key navigation', async () => { @@ -605,46 +735,14 @@ describe('SelectBoxGroup', () => { listbox.focus(); }); - // Navigate to second option - await userEvent.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); - // Check that navigation works by verifying an option has focus + // check that navigation works by verifying an option has focus const option1 = screen.getByRole('option', {name: 'Option 1'}); expect(option1).toHaveFocus(); }); }); - describe('Children validation', () => { - let consoleSpy: jest.SpyInstance; - - beforeEach(() => { - consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); - }); - - afterEach(() => { - consoleSpy.mockRestore(); - }); - - it('does not warn with valid number of children', () => { - render( - {}} - selectedKeys={new Set()}> - - Option 1 - - - Option 2 - - - ); - - expect(console.warn).not.toHaveBeenCalled(); - }); - }); - describe('Accessibility', () => { it('has proper listbox structure', () => { render( @@ -683,7 +781,7 @@ describe('SelectBoxGroup', () => { ); const listbox = screen.getByRole('listbox'); - // Just verify the listbox has an aria-labelledby attribute + // verify the listbox has an aria-labelledby attribute expect(listbox).toHaveAttribute('aria-labelledby'); expect(listbox.getAttribute('aria-labelledby')).toBeTruthy(); }); @@ -753,15 +851,55 @@ describe('SelectBoxGroup', () => { expect(screen.getByText('Valid Option')).toBeInTheDocument(); }); - it('handles uncontrolled selection with defaultSelectedKeys', async () => { + it('handles individual disabled items', () => { + render( + {}} + selectedKeys={new Set()}> + + Option 1 + + + Option 2 + + + ); + + const rows = screen.getAllByRole('option'); + expect(rows.length).toBe(2); + + const option1 = screen.getByRole('option', {name: 'Option 1'}); + expect(option1).toHaveAttribute('aria-disabled', 'true'); + }); + + it('prevents interaction with individually disabled items', async () => { const onSelectionChange = jest.fn(); render( - - + selectedKeys={new Set()}> + + Option 1 + + + ); + + const option1 = screen.getByRole('option', {name: 'Option 1'}); + await user.click(option1); + + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + + it('prevents uncontrolled interaction with individually disabled items', async () => { + render( + + Option 1 @@ -769,19 +907,17 @@ describe('SelectBoxGroup', () => { ); - + const option1 = screen.getByRole('option', {name: 'Option 1'}); const option2 = screen.getByRole('option', {name: 'Option 2'}); - expect(option1).toHaveAttribute('aria-selected', 'true'); - expect(option2).toHaveAttribute('aria-selected', 'false'); + await user.click(option1); + expect(option1).toHaveAttribute('aria-disabled', 'true'); + expect(option1).toHaveAttribute('aria-selected', 'false'); - await userEvent.click(option2); - expect(onSelectionChange).toHaveBeenCalledTimes(1); - const receivedSelection = onSelectionChange.mock.calls[0][0]; - expect(Array.from(receivedSelection)).toEqual(['option2']); + // clicking enabled item should still work + await user.click(option2); + expect(option2).toHaveAttribute('aria-selected', 'true'); }); }); }); - - diff --git a/packages/@react-types/button/src/index.d.ts b/packages/@react-types/button/src/index.d.ts index db5ff9fe37d..53b096a2689 100644 --- a/packages/@react-types/button/src/index.d.ts +++ b/packages/@react-types/button/src/index.d.ts @@ -47,6 +47,8 @@ export interface LinkButtonProps extends AriaB } interface AriaBaseButtonProps extends FocusableDOMProps, AriaLabelingProps { + /** Indicates whether the element is disabled to users of assistive technology. */ + 'aria-disabled'?: boolean | 'true' | 'false', /** Indicates whether the element, or another grouping element it controls, is currently expanded or collapsed. */ 'aria-expanded'?: boolean | 'true' | 'false', /** Indicates the availability and type of interactive popup element, such as menu or dialog, that can be triggered by an element. */ diff --git a/packages/react-aria-components/docs/Popover.mdx b/packages/react-aria-components/docs/Popover.mdx index f9de584beeb..f3d213b980d 100644 --- a/packages/react-aria-components/docs/Popover.mdx +++ b/packages/react-aria-components/docs/Popover.mdx @@ -438,12 +438,12 @@ The `className` and `style` props also accept functions which receive states for ``` -Popovers also support entry and exit animations via states exposed as data attributes and render props. `Popover` will automatically wait for any exit animations to complete before it is removed from the DOM. The `--trigger-origin` variable is set to the position of the trigger relative to the popover, which is useful for origin-aware animations. See the [animation guide](styling.html#animation) for more details. +Popovers also support entry and exit animations via states exposed as data attributes and render props. `Popover` will automatically wait for any exit animations to complete before it is removed from the DOM. The `--trigger-anchor-point` variable is set to the position of the trigger relative to the popover, which is useful for origin-aware animations. See the [animation guide](styling.html#animation) for more details. ```css render=false .react-aria-Popover { transition: opacity 300ms, scale 300ms; - transform-origin: var(--trigger-origin); + transform-origin: var(--trigger-anchor-point); &[data-entering], &[data-exiting] { @@ -461,7 +461,7 @@ A `Popover` can be targeted with the `.react-aria-Popover` CSS selector, or by o -Within a DialogTrigger, the popover will have the `data-trigger="DialogTrigger"` attribute. In addition, the `--trigger-width` CSS custom property will be set on the popover, which you can use to make the popover match the width of the trigger button. The `--trigger-origin` variable is set to the position of the trigger relative to the popover, which is useful for origin-aware animations. +Within a DialogTrigger, the popover will have the `data-trigger="DialogTrigger"` attribute. In addition, the `--trigger-width` CSS custom property will be set on the popover, which you can use to make the popover match the width of the trigger button. The `--trigger-anchor-point` variable is set to the position of the trigger relative to the popover, which is useful for origin-aware animations. ```css render=false .react-aria-Popover[data-trigger=DialogTrigger] { diff --git a/packages/react-aria-components/docs/Tooltip.mdx b/packages/react-aria-components/docs/Tooltip.mdx index 925b0842e65..a61c1241c3f 100644 --- a/packages/react-aria-components/docs/Tooltip.mdx +++ b/packages/react-aria-components/docs/Tooltip.mdx @@ -396,12 +396,12 @@ The `className` and `style` props also accept functions which receive states for ``` -Tooltips also support entry and exit animations via states exposed as data attributes and render props. `Tooltip` will automatically wait for any exit animations to complete before it is removed from the DOM. The `--trigger-origin` variable is set to the position of the trigger relative to the popover, which is useful for origin-aware animations. See the [animation guide](styling.html#animation) for more details. +Tooltips also support entry and exit animations via states exposed as data attributes and render props. `Tooltip` will automatically wait for any exit animations to complete before it is removed from the DOM. The `--trigger-anchor-point` variable is set to the position of the trigger relative to the popover, which is useful for origin-aware animations. See the [animation guide](styling.html#animation) for more details. ```css render=false .react-aria-Tooltip { transition: opacity 300ms, scale 300ms; - transform-origin: var(--trigger-origin); + transform-origin: var(--trigger-anchor-point); &[data-entering], &[data-exiting] { diff --git a/packages/react-aria-components/src/Breadcrumbs.tsx b/packages/react-aria-components/src/Breadcrumbs.tsx index e7a0cb0693d..41e7a5e6b8c 100644 --- a/packages/react-aria-components/src/Breadcrumbs.tsx +++ b/packages/react-aria-components/src/Breadcrumbs.tsx @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ import {AriaBreadcrumbsProps, useBreadcrumbs} from 'react-aria'; -import {Collection, CollectionBuilder, createLeafComponent, FilterLessNode} from '@react-aria/collections'; +import {Collection, CollectionBuilder, CollectionNode, createLeafComponent} from '@react-aria/collections'; import {CollectionProps, CollectionRendererContext} from './Collection'; import {ContextValue, RenderProps, SlotProps, StyleProps, useContextProps, useRenderProps, useSlottedContext} from './utils'; import {filterDOMProps, mergeProps} from '@react-aria/utils'; @@ -73,7 +73,7 @@ export interface BreadcrumbProps extends RenderProps, Glo id?: Key } -class BreadcrumbNode extends FilterLessNode { +class BreadcrumbNode extends CollectionNode { static readonly type = 'item'; } diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index 5798c5d43bc..6e4e17c27c5 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -241,6 +241,7 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne ref={ref as RefObject} slot={props.slot || undefined} data-empty={state.collection.size === 0 || undefined} + data-react-aria-incomplete={!(state.collection as BaseCollection).isComplete || undefined} onScroll={props.onScroll}> extends StyleRenderProps } -class TableHeaderNode extends FilterLessNode { +class TableHeaderNode extends CollectionNode { static readonly type = 'tableheader'; } @@ -694,7 +694,7 @@ export interface ColumnProps extends RenderProps, GlobalDOMAt maxWidth?: ColumnStaticSize | null } -class TableColumnNode extends FilterLessNode { +class TableColumnNode extends CollectionNode { static readonly type = 'column'; } @@ -934,7 +934,7 @@ export interface TableBodyProps extends Omit, 'disabledKey renderEmptyState?: (props: TableBodyRenderProps) => ReactNode } -class TableBodyNode extends CollectionNode { +class TableBodyNode extends FilterableNode { static readonly type = 'tablebody'; } @@ -1240,7 +1240,7 @@ export interface CellProps extends RenderProps, GlobalDOMAttrib colSpan?: number } -class TableCellNode extends FilterLessNode { +class TableCellNode extends CollectionNode { static readonly type = 'cell'; } diff --git a/packages/react-aria-components/src/Tabs.tsx b/packages/react-aria-components/src/Tabs.tsx index d0a4dc8780d..7a0114a8606 100644 --- a/packages/react-aria-components/src/Tabs.tsx +++ b/packages/react-aria-components/src/Tabs.tsx @@ -12,7 +12,7 @@ import {AriaLabelingProps, forwardRefType, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, PressEvents, RefObject} from '@react-types/shared'; import {AriaTabListProps, AriaTabPanelProps, mergeProps, Orientation, useFocusRing, useHover, useTab, useTabList, useTabPanel} from 'react-aria'; -import {Collection, CollectionBuilder, createHideableComponent, createLeafComponent, FilterLessNode} from '@react-aria/collections'; +import {Collection, CollectionBuilder, CollectionNode, createHideableComponent, createLeafComponent} from '@react-aria/collections'; import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, usePersistedKeys} from './Collection'; import {ContextValue, Provider, RenderProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps, useSlottedContext} from './utils'; import {filterDOMProps, inertValue, useObjectRef} from '@react-aria/utils'; @@ -235,7 +235,7 @@ function TabListInner({props, forwardedRef: ref}: TabListInner ); } -class TabItemNode extends FilterLessNode { +class TabItemNode extends CollectionNode { static readonly type = 'item'; } diff --git a/packages/react-aria-components/src/Tooltip.tsx b/packages/react-aria-components/src/Tooltip.tsx index f0f68cfe611..0be1d79de4e 100644 --- a/packages/react-aria-components/src/Tooltip.tsx +++ b/packages/react-aria-components/src/Tooltip.tsx @@ -123,7 +123,7 @@ function TooltipInner(props: TooltipProps & {isExiting: boolean, tooltipRef: Ref let state = useContext(TooltipTriggerStateContext)!; let arrowRef = useRef(null); - let {overlayProps, arrowProps, placement, triggerOrigin} = useOverlayPosition({ + let {overlayProps, arrowProps, placement, triggerAnchorPoint} = useOverlayPosition({ placement: props.placement || 'top', targetRef: props.triggerRef!, overlayRef: props.tooltipRef, @@ -160,7 +160,7 @@ function TooltipInner(props: TooltipProps & {isExiting: boolean, tooltipRef: Ref ref={props.tooltipRef} style={{ ...overlayProps.style, - '--trigger-origin': triggerOrigin ? `${triggerOrigin.x}px ${triggerOrigin.y}px` : undefined, + '--trigger-anchor-point': triggerAnchorPoint ? `${triggerAnchorPoint.x}px ${triggerAnchorPoint.y}px` : undefined, ...renderProps.style } as CSSProperties} data-placement={placement ?? undefined} diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index f3d3dc7421d..6073eaa05d3 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -13,7 +13,7 @@ import {AriaTreeItemOptions, AriaTreeProps, DraggableItemResult, DropIndicatorAria, DropIndicatorProps, DroppableCollectionResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocusRing, useGridListSelectionCheckbox, useHover, useId, useLocale, useTree, useTreeItem, useVisuallyHidden} from 'react-aria'; import {ButtonContext} from './Button'; import {CheckboxContext} from './RSPContexts'; -import {Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent, FilterLessNode, LoaderNode, useCachedChildren} from '@react-aria/collections'; +import {Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent, LoaderNode, useCachedChildren} from '@react-aria/collections'; import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps} from './Collection'; import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps} from './utils'; import {DisabledBehavior, DragPreviewRenderer, Expandable, forwardRefType, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, MultipleSelection, PressEvents, RefObject, SelectionMode} from '@react-types/shared'; @@ -448,7 +448,7 @@ export interface TreeItemContentRenderProps extends TreeItemRenderProps {} // need to do a bunch of check to figure out what is the Content and what are the actual collection elements (aka child rows) of the TreeItem export interface TreeItemContentProps extends Pick, 'children'> {} -class TreeContentNode extends FilterLessNode { +class TreeContentNode extends CollectionNode { static readonly type = 'content'; } @@ -487,7 +487,7 @@ export interface TreeItemProps extends StyleRenderProps void } -class TreeItemNode extends FilterLessNode { +class TreeItemNode extends CollectionNode { static readonly type = 'item'; } diff --git a/packages/react-aria-components/stories/Modal.stories.tsx b/packages/react-aria-components/stories/Modal.stories.tsx index 7469403fd33..36e8193359a 100644 --- a/packages/react-aria-components/stories/Modal.stories.tsx +++ b/packages/react-aria-components/stories/Modal.stories.tsx @@ -12,7 +12,7 @@ import {Button, ComboBox, Dialog, DialogTrigger, Heading, Input, Label, ListBox, Modal, ModalOverlay, Popover, TextField} from 'react-aria-components'; import {Meta, StoryFn} from '@storybook/react'; -import React, {useEffect} from 'react'; +import React from 'react'; import './styles.css'; import {MyListBoxItem} from './utils'; import styles from '../example/index.css'; @@ -69,129 +69,6 @@ export const ModalExample: ModalStory = () => ( ); -export const ModalInteractionOutsideExample: ModalStory = () => { - - useEffect(() => { - let button = document.createElement('button'); - button.id = 'test-button'; - button.textContent = 'Click to close'; - button.style.position = 'fixed'; - button.style.top = '0'; - button.style.right = '0'; - button.style.zIndex = '200'; - document.body.appendChild(button); - - return () => { - document.body.removeChild(button); - }; - }, []); - - return ( - - - { - if (el.id === 'test-button') {return true;} - return false; - }} - style={{ - position: 'fixed', - zIndex: 100, - top: 0, - left: 0, - bottom: 0, - right: 0, - background: 'rgba(0, 0, 0, 0.5)', - display: 'flex', - alignItems: 'center', - justifyContent: 'center' - }}> - - - {({close}) => ( -
- Sign up - - - -
- )} -
-
-
-
- ); -}; - -export const ModalInteractionOutsideDefaultOverlayExample: ModalStory = () => { - - useEffect(() => { - let button = document.createElement('button'); - button.id = 'test-button'; - button.textContent = 'Click to close'; - button.style.position = 'fixed'; - button.style.top = '0'; - button.style.right = '0'; - button.style.zIndex = '200'; - document.body.appendChild(button); - return () => { - document.body.removeChild(button); - }; - }, []); - - return ( - - - { - if (el.id === 'test-button') {return true;} - return false; - }} - style={{ - position: 'fixed', - top: '50%', - left: '50%', - transform: 'translate(-50%, -50%)', - background: 'Canvas', - color: 'CanvasText', - border: '1px solid gray', - padding: 30 - }}> - - {({close}) => ( -
- Sign up - - - -
- )} -
-
-
- ); -}; - function InertTest() { return ( diff --git a/packages/react-aria-components/stories/styles.css b/packages/react-aria-components/stories/styles.css index c2c4cccfc11..bfd24989077 100644 --- a/packages/react-aria-components/stories/styles.css +++ b/packages/react-aria-components/stories/styles.css @@ -402,8 +402,8 @@ .popover, .tooltip { transition: scale 300ms, opacity 300ms; - transform-origin: var(--trigger-origin); - + transform-origin: var(--trigger-anchor-point); + &[data-entering], &[data-exiting] { opacity: 0; diff --git a/packages/react-aria-components/test/Button.test.js b/packages/react-aria-components/test/Button.test.js index 93e28b5e484..f9a44115122 100644 --- a/packages/react-aria-components/test/Button.test.js +++ b/packages/react-aria-components/test/Button.test.js @@ -57,6 +57,18 @@ describe('Button', () => { expect(button).toHaveAttribute('aria-current', 'page'); }); + it('should not have aria-disabled defined by default', () => { + let {getByRole} = render(); + let button = getByRole('button'); + expect(button).not.toHaveAttribute('aria-disabled'); + }); + + it('should support aria-disabled passthrough', () => { + let {getByRole} = render(); + let button = getByRole('button'); + expect(button).toHaveAttribute('aria-disabled', 'true'); + }); + it('should support slot', () => { let {getByRole} = render(