From f75223a04382fd410636d615cfb859fd2452098e Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 3 Apr 2025 13:06:25 -0700 Subject: [PATCH 01/25] chore: Fix generated code sample for S2 TooltipTrigger docs (#8000) * Fix generated code sample for S2 TooltipTrigger docs * review * inlining --- .../s2/stories/Tooltip.stories.tsx | 59 +++++++++++++++++-- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/packages/@react-spectrum/s2/stories/Tooltip.stories.tsx b/packages/@react-spectrum/s2/stories/Tooltip.stories.tsx index e1a59705fc0..a13c5862846 100644 --- a/packages/@react-spectrum/s2/stories/Tooltip.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Tooltip.stories.tsx @@ -14,7 +14,7 @@ import {ActionButton, Button, Provider, Tooltip, TooltipTrigger} from '../src'; import {CombinedTooltip} from '../src/Tooltip'; import Crop from '../s2wf-icons/S2_Icon_Crop_20_N.svg'; import LassoSelect from '../s2wf-icons/S2_Icon_LassoSelect_20_N.svg'; -import type {Meta} from '@storybook/react'; +import type {Meta, StoryObj} from '@storybook/react'; import {style} from '../style' with {type: 'macro'}; const meta: Meta = { @@ -80,13 +80,32 @@ const ExampleRender = (args: any) => { ); }; -export const Example = { +type Story = StoryObj; + +export const Example: Story = { render: (args) => , argTypes: { isOpen: { control: 'select', options: [true, false, undefined] } + }, + parameters: { + docs: { + source: { + transform: () => { + return ` + + + Crop + + + + Lasso +`; + } + } + } } }; @@ -126,17 +145,30 @@ const LongLabelRender = (args: any) => { ); }; -export const LongLabel = { +export const LongLabel: Story = { render: (args) => , argTypes: { isOpen: { control: 'select', options: [true, false, undefined] } + }, + parameters: { + docs: { + source: { + transform: () => { + return ` + + + Checkbox with very long label so we can see wrapping +`; + } + } + } } }; -export const ColorScheme = { +export const ColorScheme: Story = { render: (args: any) => ( @@ -147,5 +179,24 @@ export const ColorScheme = { control: 'select', options: [true, false, undefined] } + }, + parameters: { + docs: { + source: { + transform: () => { + return ` + + + + Crop + + + + Lasso + +`; + } + } + } } }; From 148fcf175e22df3fff9cda45e9194bf245bbc0d0 Mon Sep 17 00:00:00 2001 From: Trevor Howell <25328178+ToyWalrus@users.noreply.github.com> Date: Fri, 4 Apr 2025 08:28:28 -0600 Subject: [PATCH 02/25] fix: export SortDescriptor type from S2 (#8030) --- packages/@react-spectrum/s2/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-spectrum/s2/src/index.ts b/packages/@react-spectrum/s2/src/index.ts index 69f8619dcbe..adc682d6ac1 100644 --- a/packages/@react-spectrum/s2/src/index.ts +++ b/packages/@react-spectrum/s2/src/index.ts @@ -148,4 +148,4 @@ export type {ToggleButtonProps} from './ToggleButton'; export type {ToggleButtonGroupProps} from './ToggleButtonGroup'; export type {TooltipProps} from './Tooltip'; export type {TreeViewProps, TreeViewItemProps, TreeViewItemContentProps} from './TreeView'; -export type {FileTriggerProps, TooltipTriggerComponentProps as TooltipTriggerProps} from 'react-aria-components'; +export type {FileTriggerProps, TooltipTriggerComponentProps as TooltipTriggerProps, SortDescriptor} from 'react-aria-components'; From dd22a5312c4213f890194f3cd3c3497a16591263 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 7 Apr 2025 11:18:17 -0700 Subject: [PATCH 03/25] chore: Deprecate UNSTABLE_portalContainer in favor for PortalProvider (#7976) * Initial refactor to tear out UNSTABLE_portalContainer in favor of the PortalContainer * yarn.lock update * switch to deprecating UNSTABLE_portalContainer * prefer deprecated prop over context to make this a non-breaking change * add rough docs * updating copy to include explaination of UNSTABLE * rename to UNSAFE_PortalProvider * update copy and split out example * use styles from RAC examples --------- Co-authored-by: Robert Snow --- .../overlays/docs/PortalProvider.mdx | 138 ++++++++++++++++++ packages/@react-aria/overlays/src/Overlay.tsx | 7 +- .../overlays/src/PortalProvider.tsx | 19 ++- packages/@react-aria/overlays/src/index.ts | 3 +- .../@react-aria/overlays/src/useModal.tsx | 6 + .../dialog/test/DialogContainer.test.js | 6 +- .../dialog/test/DialogTrigger.test.js | 6 +- .../menu/test/MenuTrigger.test.js | 6 +- .../@react-spectrum/table/src/Resizer.tsx | 6 +- .../table/test/TableSizing.test.tsx | 6 +- .../@react-spectrum/toast/src/Toaster.tsx | 4 +- .../toast/stories/Toast.stories.tsx | 6 +- .../tooltip/test/TooltipTrigger.test.js | 14 +- packages/react-aria-components/package.json | 1 + packages/react-aria-components/src/Modal.tsx | 1 + .../react-aria-components/src/Popover.tsx | 3 +- packages/react-aria-components/src/Toast.tsx | 17 ++- .../react-aria-components/src/Tooltip.tsx | 1 + .../react-aria-components/test/Dialog.test.js | 45 +++++- .../react-aria-components/test/Menu.test.tsx | 6 +- .../test/Popover.test.js | 43 +++++- .../react-aria-components/test/Toast.test.js | 21 +-- .../test/Tooltip.test.js | 46 +++++- yarn.lock | 1 + 24 files changed, 351 insertions(+), 61 deletions(-) create mode 100644 packages/@react-aria/overlays/docs/PortalProvider.mdx diff --git a/packages/@react-aria/overlays/docs/PortalProvider.mdx b/packages/@react-aria/overlays/docs/PortalProvider.mdx new file mode 100644 index 00000000000..69b63e72fc8 --- /dev/null +++ b/packages/@react-aria/overlays/docs/PortalProvider.mdx @@ -0,0 +1,138 @@ +{/* Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. */} + +import {Layout} from '@react-spectrum/docs'; +export default Layout; + +import docs from 'docs:@react-aria/overlays'; +import {HeaderInfo, PropTable, FunctionAPI, PageDescription} from '@react-spectrum/docs'; +import packageData from '@react-aria/overlays/package.json'; + +--- +category: Utilities +keywords: [overlays, portals] +--- + +# PortalProvider + +{docs.exports.UNSAFE_PortalProvider.description} + + + +## Introduction + +`UNSAFE_PortalProvider` is a utility wrapper component that can be used to set where components like +Modals, Popovers, Toasts, and Tooltips will portal their overlay element to. This is typically used when +your app is already portalling other elements to a location other than the `document.body` and thus requires +your React Aria components to send their overlays to the same container. + +Please note that `UNSAFE_PortalProvider` is considered `UNSAFE` because it is an escape hatch, and there are +many places that an application could portal to. Not all of them will work, either with styling, accessibility, +or for a variety of other reasons. Typically, it is best to portal to the root of the entire application, e.g. the `body` element, +outside of any possible overflow or stacking contexts. We envision `UNSAFE_PortalProvider` being used to group all of the portalled +elements into a single container at the root of the app or to control the order of children of the `body` element, but you may have use cases +that need to do otherwise. + +## Props + + + +## Example + +The example below shows how you can use `UNSAFE_PortalProvider` to portal your Toasts to an arbitrary container. Note that +the Toast in this example is taken directly from the [React Aria Components Toast documentation](Toast.html#example), please visit that page for +a detailed explanation of its implementation. + +```tsx import +import {UNSTABLE_ToastRegion as ToastRegion, UNSTABLE_Toast as Toast, UNSTABLE_ToastQueue as ToastQueue, UNSTABLE_ToastContent as ToastContent, Button, Text} from 'react-aria-components'; + + +// Define the type for your toast content. +interface MyToastContent { + title: string, + description?: string +} + +// Create a global ToastQueue. +const queue = new ToastQueue(); + +function MyToastRegion() { + return ( + + {({toast}) => ( + + + {toast.content.title} + {toast.content.description} + + + + )} + + + ); +} +``` + +```tsx example +import {UNSAFE_PortalProvider} from '@react-aria/overlays'; + +// See the above Toast docs link for the ToastRegion implementation +function App() { + let container = React.useRef(null); + return ( + <> + container.current}> + + + +
+ Toasts are portalled here! +
+ + ); +} + + +``` + +```css hidden +@import '../../../react-aria-components/docs/Button.mdx' layer(button); +@import '../../../react-aria-components/docs/Toast.mdx' layer(toast); +@import "@react-aria/example-theme"; + +.react-aria-ToastRegion { + position: unset; +} +``` + +## Contexts + +The `getContainer` set by the nearest PortalProvider can be accessed by calling `useUNSAFE_PortalContext`. This can be +used by custom overlay components to ensure that they are also being consistently portalled throughout your app. + + + +```tsx +import {useUNSAFE_PortalContext} from '@react-aria/overlays'; + +function MyOverlay(props) { + let {children} = props; + let {getContainer} = useUNSAFE_PortalContext(); + return ReactDOM.createPortal(children, getContainer()); +} +``` diff --git a/packages/@react-aria/overlays/src/Overlay.tsx b/packages/@react-aria/overlays/src/Overlay.tsx index 61711422c25..ee818bcefc6 100644 --- a/packages/@react-aria/overlays/src/Overlay.tsx +++ b/packages/@react-aria/overlays/src/Overlay.tsx @@ -16,12 +16,13 @@ import React, {ReactNode, useContext, useMemo, useState} from 'react'; import ReactDOM from 'react-dom'; import {useIsSSR} from '@react-aria/ssr'; import {useLayoutEffect} from '@react-aria/utils'; -import {useUNSTABLE_PortalContext} from './PortalProvider'; +import {useUNSAFE_PortalContext} from './PortalProvider'; export interface OverlayProps { /** * The container element in which the overlay portal will be placed. * @default document.body + * @deprecated - Use a parent UNSAFE_PortalProvider to set your portal container instead. */ portalContainer?: Element, /** The overlay to render in the portal. */ @@ -55,8 +56,8 @@ export function Overlay(props: OverlayProps): ReactNode | null { let [contain, setContain] = useState(false); let contextValue = useMemo(() => ({contain, setContain}), [contain, setContain]); - let {getContainer} = useUNSTABLE_PortalContext(); - if (!props.portalContainer && getContainer) { + let {getContainer} = useUNSAFE_PortalContext(); + if (!props.portalContainer && getContainer) { portalContainer = getContainer(); } diff --git a/packages/@react-aria/overlays/src/PortalProvider.tsx b/packages/@react-aria/overlays/src/PortalProvider.tsx index ab0e59a8c6e..8721a00df5f 100644 --- a/packages/@react-aria/overlays/src/PortalProvider.tsx +++ b/packages/@react-aria/overlays/src/PortalProvider.tsx @@ -13,15 +13,22 @@ import React, {createContext, ReactNode, useContext} from 'react'; export interface PortalProviderProps { - /* Should return the element where we should portal to. Can clear the context by passing null. */ - getContainer?: () => HTMLElement | null + /** Should return the element where we should portal to. Can clear the context by passing null. */ + getContainer?: () => HTMLElement | null, + /** The content of the PortalProvider. Should contain all children that want to portal their overlays to the element returned by the provided `getContainer()`. */ + children: ReactNode } -export const PortalContext = createContext({}); +export interface PortalProviderContextValue extends Omit{}; -export function UNSTABLE_PortalProvider(props: PortalProviderProps & {children: ReactNode}): ReactNode { +export const PortalContext = createContext({}); + +/** + * Sets the portal container for all overlay elements rendered by its children. + */ +export function UNSAFE_PortalProvider(props: PortalProviderProps): ReactNode { let {getContainer} = props; - let {getContainer: ctxGetContainer} = useUNSTABLE_PortalContext(); + let {getContainer: ctxGetContainer} = useUNSAFE_PortalContext(); return ( {props.children} @@ -29,6 +36,6 @@ export function UNSTABLE_PortalProvider(props: PortalProviderProps & {children: ); } -export function useUNSTABLE_PortalContext(): PortalProviderProps { +export function useUNSAFE_PortalContext(): PortalProviderContextValue { return useContext(PortalContext) ?? {}; } diff --git a/packages/@react-aria/overlays/src/index.ts b/packages/@react-aria/overlays/src/index.ts index d73f2def7eb..cf37e048e7d 100644 --- a/packages/@react-aria/overlays/src/index.ts +++ b/packages/@react-aria/overlays/src/index.ts @@ -19,7 +19,7 @@ export {ariaHideOutside} from './ariaHideOutside'; export {usePopover} from './usePopover'; export {useModalOverlay} from './useModalOverlay'; export {Overlay, useOverlayFocusContain} from './Overlay'; -export {UNSTABLE_PortalProvider, useUNSTABLE_PortalContext} from './PortalProvider'; +export {UNSAFE_PortalProvider, useUNSAFE_PortalContext} from './PortalProvider'; export type {AriaPositionProps, PositionAria} from './useOverlayPosition'; export type {AriaOverlayProps, OverlayAria} from './useOverlay'; @@ -30,3 +30,4 @@ export type {AriaPopoverProps, PopoverAria} from './usePopover'; export type {AriaModalOverlayProps, ModalOverlayAria} from './useModalOverlay'; export type {OverlayProps} from './Overlay'; export type {Placement, PlacementAxis, PositionProps} from '@react-types/overlays'; +export type {PortalProviderProps, PortalProviderContextValue} from './PortalProvider'; diff --git a/packages/@react-aria/overlays/src/useModal.tsx b/packages/@react-aria/overlays/src/useModal.tsx index 5360a93cad8..5a5e8188b8a 100644 --- a/packages/@react-aria/overlays/src/useModal.tsx +++ b/packages/@react-aria/overlays/src/useModal.tsx @@ -14,6 +14,7 @@ import {DOMAttributes} from '@react-types/shared'; import React, {AriaAttributes, ReactNode, useContext, useEffect, useMemo, useState} from 'react'; import ReactDOM from 'react-dom'; import {useIsSSR} from '@react-aria/ssr'; +import {useUNSAFE_PortalContext} from './PortalProvider'; export interface ModalProviderProps extends DOMAttributes { children: ReactNode @@ -112,6 +113,7 @@ export interface OverlayContainerProps extends ModalProviderProps { /** * The container element in which the overlay portal will be placed. * @default document.body + * @deprecated - Use a parent UNSAFE_PortalProvider to set your portal container instead. */ portalContainer?: Element } @@ -126,6 +128,10 @@ export interface OverlayContainerProps extends ModalProviderProps { export function OverlayContainer(props: OverlayContainerProps): React.ReactPortal | null { let isSSR = useIsSSR(); let {portalContainer = isSSR ? null : document.body, ...rest} = props; + let {getContainer} = useUNSAFE_PortalContext(); + if (!props.portalContainer && getContainer) { + portalContainer = getContainer(); + } React.useEffect(() => { if (portalContainer?.closest('[data-overlay-container]')) { diff --git a/packages/@react-spectrum/dialog/test/DialogContainer.test.js b/packages/@react-spectrum/dialog/test/DialogContainer.test.js index 972055d8105..4fe0a03c01d 100644 --- a/packages/@react-spectrum/dialog/test/DialogContainer.test.js +++ b/packages/@react-spectrum/dialog/test/DialogContainer.test.js @@ -21,7 +21,7 @@ import {Heading, Text} from '@react-spectrum/text'; import {Provider} from '@react-spectrum/provider'; import React, {useRef, useState} from 'react'; import {theme} from '@react-spectrum/theme-default'; -import {UNSTABLE_PortalProvider} from '@react-aria/overlays'; +import {UNSAFE_PortalProvider} from '@react-aria/overlays'; import userEvent from '@testing-library/user-event'; describe('DialogContainer', function () { @@ -254,13 +254,13 @@ describe('DialogContainer', function () { return ( setOpen(true)}>Open dialog - container.current}> + container.current}> setOpen(false)} {...props}> {isOpen && } - +
); diff --git a/packages/@react-spectrum/dialog/test/DialogTrigger.test.js b/packages/@react-spectrum/dialog/test/DialogTrigger.test.js index 15ee46ec88f..f0eb6ff8efc 100644 --- a/packages/@react-spectrum/dialog/test/DialogTrigger.test.js +++ b/packages/@react-spectrum/dialog/test/DialogTrigger.test.js @@ -21,7 +21,7 @@ import {Provider} from '@react-spectrum/provider'; import React from 'react'; import {TextField} from '@react-spectrum/textfield'; import {theme} from '@react-spectrum/theme-default'; -import {UNSTABLE_PortalProvider} from '@react-aria/overlays'; +import {UNSAFE_PortalProvider} from '@react-aria/overlays'; import userEvent from '@testing-library/user-event'; @@ -1031,12 +1031,12 @@ describe('DialogTrigger', function () { let {container} = props; return ( - container.current}> + container.current}> Trigger contents - + ); } diff --git a/packages/@react-spectrum/menu/test/MenuTrigger.test.js b/packages/@react-spectrum/menu/test/MenuTrigger.test.js index 2e6752da3b0..827aa598dd1 100644 --- a/packages/@react-spectrum/menu/test/MenuTrigger.test.js +++ b/packages/@react-spectrum/menu/test/MenuTrigger.test.js @@ -29,7 +29,7 @@ import {Link} from '@react-spectrum/link'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; import {theme} from '@react-spectrum/theme-default'; -import {UNSTABLE_PortalProvider} from '@react-aria/overlays'; +import {UNSAFE_PortalProvider} from '@react-aria/overlays'; import {User} from '@react-aria/test-utils'; import userEvent from '@testing-library/user-event'; @@ -735,7 +735,7 @@ describe('MenuTrigger', function () { function InfoMenu(props) { return ( - props.container.current}> + props.container.current}> @@ -744,7 +744,7 @@ describe('MenuTrigger', function () { Three - + ); } diff --git a/packages/@react-spectrum/table/src/Resizer.tsx b/packages/@react-spectrum/table/src/Resizer.tsx index c80cb5b26c0..defc24868ea 100644 --- a/packages/@react-spectrum/table/src/Resizer.tsx +++ b/packages/@react-spectrum/table/src/Resizer.tsx @@ -1,4 +1,4 @@ - + import {classNames} from '@react-spectrum/utils'; import {ColumnSize} from '@react-types/table'; import eCursor from 'bundle-text:./cursors/Cur_MoveToRight_9_9.svg'; @@ -16,7 +16,7 @@ import {TableColumnResizeState} from '@react-stately/table'; import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; import {useTableColumnResize} from '@react-aria/table'; import {useTableContext, useVirtualizerContext} from './TableViewBase'; -import {useUNSTABLE_PortalContext} from '@react-aria/overlays'; +import {useUNSAFE_PortalContext} from '@react-aria/overlays'; // @ts-ignore import wCursor from 'bundle-text:./cursors/Cur_MoveToLeft_9_9.svg'; @@ -132,6 +132,6 @@ export const Resizer = React.forwardRef(function Resizer(props: ResizerProps< function CursorOverlay(props) { let {show, children} = props; - let {getContainer} = useUNSTABLE_PortalContext(); + let {getContainer} = useUNSAFE_PortalContext(); return show ? ReactDOM.createPortal(children, getContainer?.() ?? document.body) : null; } diff --git a/packages/@react-spectrum/table/test/TableSizing.test.tsx b/packages/@react-spectrum/table/test/TableSizing.test.tsx index 7a36427b394..cddb75e8ea6 100644 --- a/packages/@react-spectrum/table/test/TableSizing.test.tsx +++ b/packages/@react-spectrum/table/test/TableSizing.test.tsx @@ -26,7 +26,7 @@ import {resizingTests} from '@react-aria/table/test/tableResizingTests'; import {Scale} from '@react-types/provider'; import {setInteractionModality} from '@react-aria/interactions'; import {theme} from '@react-spectrum/theme-default'; -import {UNSTABLE_PortalProvider} from '@react-aria/overlays'; +import {UNSAFE_PortalProvider} from '@react-aria/overlays'; import userEvent from '@testing-library/user-event'; let columns = [ @@ -1048,7 +1048,7 @@ describe('TableViewSizing', function () { let Example = (props) => { let container = useRef(null); return ( - container.current}> + container.current}> Foo @@ -1064,7 +1064,7 @@ describe('TableViewSizing', function () {
- + ); }; let customPortalRender = (props) => render(); diff --git a/packages/@react-spectrum/toast/src/Toaster.tsx b/packages/@react-spectrum/toast/src/Toaster.tsx index 356fc97fc4c..0fb2ae7ad2e 100644 --- a/packages/@react-spectrum/toast/src/Toaster.tsx +++ b/packages/@react-spectrum/toast/src/Toaster.tsx @@ -20,7 +20,7 @@ import ReactDOM from 'react-dom'; import toastContainerStyles from './toastContainer.css'; import type {ToastPlacement} from './ToastContainer'; import {ToastState} from '@react-stately/toast'; -import {useUNSTABLE_PortalContext} from '@react-aria/overlays'; +import {useUNSAFE_PortalContext} from '@react-aria/overlays'; interface ToastContainerProps extends AriaToastRegionProps { children: ReactNode, @@ -39,7 +39,7 @@ export function Toaster(props: ToastContainerProps): ReactElement { let ref = useRef(null); let {regionProps} = useToastRegion(props, state, ref); let {focusProps, isFocusVisible} = useFocusRing(); - let {getContainer} = useUNSTABLE_PortalContext(); + let {getContainer} = useUNSAFE_PortalContext(); let [position, placement] = useMemo(() => { let [pos = 'bottom', place = 'center'] = props.placement?.split(' ') || []; diff --git a/packages/@react-spectrum/toast/stories/Toast.stories.tsx b/packages/@react-spectrum/toast/stories/Toast.stories.tsx index 9e7d0cb26e1..a4e1e697151 100644 --- a/packages/@react-spectrum/toast/stories/Toast.stories.tsx +++ b/packages/@react-spectrum/toast/stories/Toast.stories.tsx @@ -21,8 +21,8 @@ import {Heading} from '@react-spectrum/text'; import React, {SyntheticEvent, useEffect, useMemo, useRef, useState} from 'react'; import {SpectrumToastOptions, ToastPlacement} from '../src/ToastContainer'; import {ToastContainer, ToastQueue} from '../'; +import {UNSAFE_PortalProvider} from '@react-aria/overlays'; import {UNSTABLE_createLandmarkController, useLandmark} from '@react-aria/landmark'; -import {UNSTABLE_PortalProvider} from '@react-aria/overlays'; export default { title: 'Toast', @@ -384,11 +384,11 @@ function FullscreenApp(props) { }, []); return (
- ref.current}> + ref.current}> Enter fullscreen {isFullscreen && } - + {!isFullscreen && }
); diff --git a/packages/@react-spectrum/tooltip/test/TooltipTrigger.test.js b/packages/@react-spectrum/tooltip/test/TooltipTrigger.test.js index c6be0220a08..86eb44719c7 100644 --- a/packages/@react-spectrum/tooltip/test/TooltipTrigger.test.js +++ b/packages/@react-spectrum/tooltip/test/TooltipTrigger.test.js @@ -16,7 +16,7 @@ import {Provider} from '@react-spectrum/provider'; import React from 'react'; import {theme} from '@react-spectrum/theme-default'; import {Tooltip, TooltipTrigger} from '../'; -import {UNSTABLE_PortalProvider} from '@react-aria/overlays'; +import {UNSAFE_PortalProvider} from '@react-aria/overlays'; import userEvent from '@testing-library/user-event'; // Sync with useTooltipTriggerState.ts @@ -1003,14 +1003,14 @@ describe('TooltipTrigger', function () { describe('portalContainer', () => { function InfoTooltip(props) { return ( - props.container.current}> + props.container.current}>
hello
-
+ ); } @@ -1049,24 +1049,24 @@ describe('TooltipTrigger', function () { describe('portalContainer overwrite', () => { function InfoTooltip(props) { return ( - +
hello
-
+ ); } function App() { let container = React.useRef(null); return ( <> - container.current}> + container.current}>
- + ); } diff --git a/packages/react-aria-components/package.json b/packages/react-aria-components/package.json index aef626ec8c9..6b1cd161e77 100644 --- a/packages/react-aria-components/package.json +++ b/packages/react-aria-components/package.json @@ -45,6 +45,7 @@ "@react-aria/focus": "^3.20.1", "@react-aria/interactions": "^3.24.1", "@react-aria/live-announcer": "^3.4.1", + "@react-aria/overlays": "^3.26.1", "@react-aria/ssr": "^3.9.7", "@react-aria/toolbar": "3.0.0-beta.14", "@react-aria/utils": "^3.28.1", diff --git a/packages/react-aria-components/src/Modal.tsx b/packages/react-aria-components/src/Modal.tsx index 713d458ecb5..e76520fc10b 100644 --- a/packages/react-aria-components/src/Modal.tsx +++ b/packages/react-aria-components/src/Modal.tsx @@ -30,6 +30,7 @@ export interface ModalOverlayProps extends AriaModalOverlayProps, OverlayTrigger /** * The container element in which the overlay portal will be placed. This may have unknown behavior depending on where it is portalled to. * @default document.body + * @deprecated - Use a parent UNSAFE_PortalProvider to set your portal container instead. */ UNSTABLE_portalContainer?: Element } diff --git a/packages/react-aria-components/src/Popover.tsx b/packages/react-aria-components/src/Popover.tsx index d5f10fdf8b4..33946892d9c 100644 --- a/packages/react-aria-components/src/Popover.tsx +++ b/packages/react-aria-components/src/Popover.tsx @@ -46,6 +46,7 @@ export interface PopoverProps extends Omit, Omit | null>(null); @@ -42,12 +43,7 @@ export interface ToastRegionProps extends AriaToastRegionProps, StyleRenderPr /** The queue of toasts to display. */ queue: ToastQueue, /** A function to render each toast. */ - children: (renderProps: {toast: QueuedToast}) => ReactElement, - /** - * The container element in which the toast region portal will be placed. - * @default document.body - */ - portalContainer?: Element + children: (renderProps: {toast: QueuedToast}) => ReactElement } /** @@ -71,7 +67,14 @@ export const ToastRegion = /*#__PURE__*/ (forwardRef as forwardRefType)(function } }); - let {portalContainer = isSSR ? null : document.body} = props; + let portalContainer; + let {getContainer} = useUNSAFE_PortalContext(); + if (!isSSR) { + portalContainer = document.body; + if (getContainer) { + portalContainer = getContainer(); + } + } let region = ( diff --git a/packages/react-aria-components/src/Tooltip.tsx b/packages/react-aria-components/src/Tooltip.tsx index 18eb46f978d..0e4302da8b0 100644 --- a/packages/react-aria-components/src/Tooltip.tsx +++ b/packages/react-aria-components/src/Tooltip.tsx @@ -41,6 +41,7 @@ export interface TooltipProps extends PositionProps, Pick { @@ -302,6 +303,46 @@ describe('Dialog', () => { expect(modal).not.toBeInTheDocument(); }); + describe('portalProvider', () => { + function InfoDialog() { + return ( + + + + + {({close}) => ( + <> + Alert + + + )} + + + + ); + } + function App() { + let container = useRef(null); + return ( + <> + container.current}> + + +
+ + ); + } + it('should render the dialog in the portal container provided by the PortalProvider', async () => { + let {getByRole, getByTestId} = render(); + let button = getByRole('button'); + await user.click(button); + + expect(getByRole('alertdialog').closest('[data-testid="custom-container"]')).toBe(getByTestId('custom-container')); + await user.click(document.body); + }); + }); + + // TODO: delete this test when we get rid of the deprecated prop describe('portalContainer', () => { function InfoDialog(props) { return ( @@ -329,7 +370,7 @@ describe('Dialog', () => { ); } - it('should render the tooltip in the portal container', async () => { + it('should render the dialog in the portal container', async () => { let {getByRole, getByTestId} = render(); let button = getByRole('button'); await user.click(button); diff --git a/packages/react-aria-components/test/Menu.test.tsx b/packages/react-aria-components/test/Menu.test.tsx index 79b84f8f525..fc30b7796e8 100644 --- a/packages/react-aria-components/test/Menu.test.tsx +++ b/packages/react-aria-components/test/Menu.test.tsx @@ -15,7 +15,7 @@ import {AriaMenuTests} from './AriaMenu.test-util'; import {Button, Collection, Header, Heading, Input, Keyboard, Label, Menu, MenuContext, MenuItem, MenuSection, MenuTrigger, Popover, Pressable, Separator, SubmenuTrigger, Text, TextField} from '..'; import React, {useState} from 'react'; import {Selection, SelectionMode} from '@react-types/shared'; -import {UNSTABLE_PortalProvider} from '@react-aria/overlays'; +import {UNSAFE_PortalProvider} from '@react-aria/overlays'; import {User} from '@react-aria/test-utils'; import userEvent from '@testing-library/user-event'; @@ -1336,7 +1336,7 @@ describe('Menu', () => { describe('portalContainer', () => { function InfoMenu(props) { return ( - props.container.current}> + props.container.current}> )} - + ); } function App() { - let [container, setContainer] = React.useState(); + let container = useRef(null); return ( <> - -
+ container.current}> + + +
); } diff --git a/packages/react-aria-components/test/Tooltip.test.js b/packages/react-aria-components/test/Tooltip.test.js index 91c16871787..249124a234b 100644 --- a/packages/react-aria-components/test/Tooltip.test.js +++ b/packages/react-aria-components/test/Tooltip.test.js @@ -12,7 +12,8 @@ import {act, fireEvent, pointerMap, render} from '@react-spectrum/test-utils-internal'; import {Button, Focusable, OverlayArrow, Pressable, Tooltip, TooltipTrigger} from 'react-aria-components'; -import React from 'react'; +import React, {useRef} from 'react'; +import {UNSAFE_PortalProvider} from '@react-aria/overlays'; import userEvent from '@testing-library/user-event'; function TestTooltip(props) { @@ -168,6 +169,49 @@ describe('Tooltip', () => { expect(tooltip1).not.toBeVisible(); }); + describe('portalProvider', () => { + function InfoTooltip(props) { + return ( + + + + + + + + + Edit + + + ); + } + function App() { + let container = useRef(null); + return ( + <> + container.current}> + + +
+ + ); + } + it('should render the tooltip in the portal container provided by the PortalProvider', async () => { + let {getByRole, getByTestId} = render(); + let button = getByRole('button'); + + fireEvent.mouseMove(document.body); + await user.hover(button); + act(() => jest.runAllTimers()); + + expect(getByRole('tooltip').closest('[data-testid="custom-container"]')).toBe(getByTestId('custom-container')); + + await user.unhover(button); + act(() => jest.runAllTimers()); + }); + }); + + // TODO: delete this test when we get rid of the deprecated prop describe('portalContainer', () => { function InfoTooltip(props) { return ( diff --git a/yarn.lock b/yarn.lock index b98821a6f65..fc03054ddbd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -29253,6 +29253,7 @@ __metadata: "@react-aria/focus": "npm:^3.20.1" "@react-aria/interactions": "npm:^3.24.1" "@react-aria/live-announcer": "npm:^3.4.1" + "@react-aria/overlays": "npm:^3.26.1" "@react-aria/ssr": "npm:^3.9.7" "@react-aria/toolbar": "npm:3.0.0-beta.14" "@react-aria/utils": "npm:^3.28.1" From cce6f16f56dc327976a6c1524bdd8faf959afb34 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 7 Apr 2025 11:44:00 -0700 Subject: [PATCH 04/25] feat: Add escapeKeyBehavior to GridList/ListBox/Menu/Table/Tree (#7974) * Add disallowClearAll to Menu/ListBox so Autocomplete in Popover can close without clearing selection * add support for diallowClearAll to grid/tree/table * make sure RSP components also surface disallowClearAll * update api naming to escapeKeyBehavior * skip 17 tests for build, investigate later * review comments --- packages/@react-aria/grid/src/useGrid.ts | 17 +- .../@react-aria/gridlist/src/useGridList.ts | 17 +- .../selection/src/useSelectableCollection.ts | 8 +- .../list/test/ListView.test.js | 18 ++ .../listbox/test/ListBox.test.js | 24 +++ .../menu/test/MenuTrigger.test.js | 24 +++ .../@react-spectrum/table/test/Table.test.js | 24 ++- .../tree/test/TreeView.test.tsx | 21 +++ packages/@react-types/listbox/src/index.d.ts | 12 +- packages/@react-types/menu/src/index.d.ts | 12 +- packages/@react-types/table/src/index.d.ts | 11 +- .../stories/Autocomplete.stories.tsx | 154 +++++++++++------- .../stories/GridList.stories.tsx | 11 +- .../stories/ListBox.stories.tsx | 11 +- .../stories/Table.stories.tsx | 33 +++- .../stories/Tree.stories.tsx | 3 +- .../test/Autocomplete.test.tsx | 80 +++++++++ .../test/GridList.test.js | 17 ++ .../test/ListBox.test.js | 15 ++ .../react-aria-components/test/Menu.test.tsx | 13 ++ .../react-aria-components/test/Table.test.js | 22 +++ .../react-aria-components/test/Tree.test.tsx | 18 ++ 22 files changed, 482 insertions(+), 83 deletions(-) diff --git a/packages/@react-aria/grid/src/useGrid.ts b/packages/@react-aria/grid/src/useGrid.ts index 0ebab0ecab7..6e848fb51f0 100644 --- a/packages/@react-aria/grid/src/useGrid.ts +++ b/packages/@react-aria/grid/src/useGrid.ts @@ -53,7 +53,16 @@ export interface GridProps extends DOMProps, AriaLabelingProps { /** Handler that is called when a user performs an action on the row. */ onRowAction?: (key: Key) => void, /** Handler that is called when a user performs an action on the cell. */ - onCellAction?: (key: Key) => void + onCellAction?: (key: Key) => void, + /** + * Whether pressing the escape key should clear selection in the grid or not. + * + * Most experiences should not modify this option as it eliminates a keyboard user's ability to + * easily clear selection. Only use if the escape key is being handled externally or should not + * trigger selection clearing contextually. + * @default 'clearSelection' + */ + escapeKeyBehavior?: 'clearSelection' | 'none' } export interface GridAria { @@ -77,7 +86,8 @@ export function useGrid(props: GridProps, state: GridState(props: GridProps, state: GridState extends GridListProps, DOMProps, AriaLa * via the left/right arrow keys or the tab key. * @default 'arrow' */ - keyboardNavigationBehavior?: 'arrow' | 'tab' + keyboardNavigationBehavior?: 'arrow' | 'tab', + /** + * Whether pressing the escape key should clear selection in the grid list or not. + * + * Most experiences should not modify this option as it eliminates a keyboard user's ability to + * easily clear selection. Only use if the escape key is being handled externally or should not + * trigger selection clearing contextually. + * @default 'clearSelection' + */ + escapeKeyBehavior?: 'clearSelection' | 'none' } export interface AriaGridListOptions extends Omit, 'children'> { @@ -105,7 +114,8 @@ export function useGridList(props: AriaGridListOptions, state: ListState(props: AriaGridListOptions, state: ListState { - // Temporarily disabling these tests in React 16 because they run into a memory limit and crash. + // Temporarily disabling these tests in React 16/17 because they run into a memory limit and crash. // TODO: investigate. - if (parseInt(React.version, 10) <= 16) { + if (parseInt(React.version, 10) <= 17) { return; } @@ -2217,6 +2217,26 @@ export let tableTests = () => { checkSelectAll(tree, 'indeterminate'); }); + it('should prevent Esc from clearing selection if escapeKeyBehavior is "none"', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange, selectionMode: 'multiple', escapeKeyBehavior: 'none'}); + let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')}); + tableTester.setInteractionType('keyboard'); + + + await tableTester.toggleRowSelection({row: 0}); + expect(tableTester.selectedRows).toHaveLength(1); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + + await tableTester.toggleRowSelection({row: 1}); + expect(tableTester.selectedRows).toHaveLength(2); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + + await user.keyboard('{Escape}'); + expect(tableTester.selectedRows).toHaveLength(2); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + }); + it('should not allow selection of a disabled row via checkbox click', async function () { let onSelectionChange = jest.fn(); let tree = renderTable({onSelectionChange, disabledKeys: ['Foo 1']}); diff --git a/packages/@react-spectrum/tree/test/TreeView.test.tsx b/packages/@react-spectrum/tree/test/TreeView.test.tsx index 5d2fee7c61d..8f411a81163 100644 --- a/packages/@react-spectrum/tree/test/TreeView.test.tsx +++ b/packages/@react-spectrum/tree/test/TreeView.test.tsx @@ -463,6 +463,27 @@ describe('Tree', () => { expect(treeTester.selectedRows[0]).toBe(row1); }); + it('should prevent Esc from clearing selection if escapeKeyBehavior is "none"', async () => { + let {getByRole} = render(); + let treeTester = testUtilUser.createTester('Tree', {user, root: getByRole('treegrid')}); + let rows = treeTester.rows; + let row1 = rows[1]; + await treeTester.toggleRowSelection({row: row1}); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['Projects'])); + expect(treeTester.selectedRows).toHaveLength(1); + + let row2 = rows[2]; + await treeTester.toggleRowSelection({row: row2}); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(new Set(onSelectionChange.mock.calls[1][0])).toEqual(new Set(['Projects', 'Projects-1'])); + expect(treeTester.selectedRows).toHaveLength(2); + + await user.keyboard('{Escape}'); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(treeTester.selectedRows).toHaveLength(2); + }); + it('should render a chevron for an expandable row marked with hasChildItems', () => { let {getAllByRole} = render( diff --git a/packages/@react-types/listbox/src/index.d.ts b/packages/@react-types/listbox/src/index.d.ts index ed887021781..082b44669f8 100644 --- a/packages/@react-types/listbox/src/index.d.ts +++ b/packages/@react-types/listbox/src/index.d.ts @@ -20,7 +20,17 @@ export interface ListBoxProps extends CollectionBase, MultipleSelection, F shouldFocusWrap?: boolean } -interface AriaListBoxPropsBase extends ListBoxProps, DOMProps, AriaLabelingProps {} +interface AriaListBoxPropsBase extends ListBoxProps, DOMProps, AriaLabelingProps { + /** + * Whether pressing the escape key should clear selection in the listbox or not. + * + * Most experiences should not modify this option as it eliminates a keyboard user's ability to + * easily clear selection. Only use if the escape key is being handled externally or should not + * trigger selection clearing contextually. + * @default 'clearSelection' + */ + escapeKeyBehavior?: 'clearSelection' | 'none' +} export interface AriaListBoxProps extends AriaListBoxPropsBase { /** * An optional visual label for the listbox. diff --git a/packages/@react-types/menu/src/index.d.ts b/packages/@react-types/menu/src/index.d.ts index d2564749978..a3b4ae883eb 100644 --- a/packages/@react-types/menu/src/index.d.ts +++ b/packages/@react-types/menu/src/index.d.ts @@ -62,7 +62,17 @@ export interface MenuProps extends CollectionBase, MultipleSelection { onClose?: () => void } -export interface AriaMenuProps extends MenuProps, DOMProps, AriaLabelingProps {} +export interface AriaMenuProps extends MenuProps, DOMProps, AriaLabelingProps { + /** + * Whether pressing the escape key should clear selection in the menu or not. + * + * Most experiences should not modify this option as it eliminates a keyboard user's ability to + * easily clear selection. Only use if the escape key is being handled externally or should not + * trigger selection clearing contextually. + * @default 'clearSelection' + */ + escapeKeyBehavior?: 'clearSelection' | 'none' +} export interface SpectrumMenuProps extends AriaMenuProps, StyleProps {} export interface SpectrumActionMenuProps extends CollectionBase, Omit, StyleProps, DOMProps, AriaLabelingProps { diff --git a/packages/@react-types/table/src/index.d.ts b/packages/@react-types/table/src/index.d.ts index 003caa9f291..656b82dd185 100644 --- a/packages/@react-types/table/src/index.d.ts +++ b/packages/@react-types/table/src/index.d.ts @@ -29,7 +29,16 @@ export interface TableProps extends MultipleSelection, Sortable { /** The elements that make up the table. Includes the TableHeader, TableBody, Columns, and Rows. */ children: [ReactElement>, ReactElement>], /** A list of row keys to disable. */ - disabledKeys?: Iterable + disabledKeys?: Iterable, + /** + * Whether pressing the escape key should clear selection in the table or not. + * + * Most experiences should not modify this option as it eliminates a keyboard user's ability to + * easily clear selection. Only use if the escape key is being handled externally or should not + * trigger selection clearing contextually. + * @default 'clearSelection' + */ + escapeKeyBehavior?: 'clearSelection' | 'none' } /** diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index 7489a98db93..4c299fe80a0 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -22,7 +22,8 @@ export default { title: 'React Aria Components', args: { onAction: action('onAction'), - selectionMode: 'multiple' + selectionMode: 'multiple', + escapeKeyBehavior: 'clearSelection' }, argTypes: { onAction: { @@ -38,6 +39,10 @@ export default { selectionMode: { control: 'radio', options: ['none', 'single', 'multiple'] + }, + escapeKeyBehavior: { + control: 'radio', + options: ['clearSelection', 'none'] } } }; @@ -109,8 +114,6 @@ function AutocompleteWrapper(props) { export const AutocompleteExample = { render: (args) => { - let {onAction, onSelectionChange, selectionMode} = args; - return (
@@ -119,7 +122,7 @@ export const AutocompleteExample = { Please select an option below. - +
); @@ -129,7 +132,6 @@ export const AutocompleteExample = { export const AutocompleteSearchfield = { render: (args) => { - let {onAction, onSelectionChange, selectionMode} = args; return (
@@ -138,7 +140,7 @@ export const AutocompleteSearchfield = { Please select an option below. - +
); @@ -288,8 +290,6 @@ let dynamicRenderFuncSections = (item: ItemNode) => { export const AutocompleteMenuDynamic = { render: (args) => { - let {onAction, onSelectionChange, selectionMode} = args; - return ( <> @@ -300,7 +300,7 @@ export const AutocompleteMenuDynamic = { Please select an option below. - + {item => dynamicRenderFuncSections(item)}
@@ -314,7 +314,6 @@ export const AutocompleteMenuDynamic = { export const AutocompleteOnActionOnMenuItems = { render: (args) => { - let {onSelectionChange, selectionMode} = args; return (
@@ -323,7 +322,7 @@ export const AutocompleteOnActionOnMenuItems = { Please select an option below. - + Foo Bar Baz @@ -344,7 +343,6 @@ let items: AutocompleteItem[] = [{id: '1', name: 'Foo'}, {id: '2', name: 'Bar'}, export const AutocompleteDisabledKeys = { render: (args) => { - let {onAction, onSelectionChange, selectionMode} = args; return (
@@ -353,8 +351,8 @@ export const AutocompleteDisabledKeys = { Please select an option below. - - {item => {item.name}} + + {(item: AutocompleteItem) => {item.name}}
@@ -386,7 +384,7 @@ const AsyncExample = (args) => { }; } }); - let {onSelectionChange, selectionMode, includeLoadState} = args; + let {onSelectionChange, selectionMode, includeLoadState, escapeKeyBehavior} = args; let renderEmptyState; if (includeLoadState) { renderEmptyState = list.isLoading ? () => 'Loading' : () => 'No results found.'; @@ -401,6 +399,7 @@ const AsyncExample = (args) => { Please select an option below. + escapeKeyBehavior={escapeKeyBehavior} renderEmptyState={renderEmptyState} items={includeLoadState && list.isLoading ? [] : list.items} className={styles.menu} @@ -428,7 +427,7 @@ const CaseSensitiveFilter = (args) => { sensitivity: 'case' }); let defaultFilter = (itemText, input) => contains(itemText, input); - let {onAction, onSelectionChange, selectionMode} = args; + return (
@@ -437,8 +436,8 @@ const CaseSensitiveFilter = (args) => { Please select an option below. - - {item => {item.name}} + + {(item: AutocompleteItem) => {item.name}}
@@ -454,35 +453,51 @@ export const AutocompleteCaseSensitive = { export const AutocompleteWithListbox = { render: (args) => { - let {onSelectionChange, selectionMode} = args; return ( - -
- - - - Please select an option below. - - - -
Section 1
- Foo - Bar - Baz - Google -
- - - Copy - Paste - Cut - -
-
-
+ + + + {() => ( + +
+ + + + Please select an option below. + + + +
Section 1
+ Foo + Bar + Baz + Google +
+ + + Copy + Paste + Cut + +
+
+
+ )} +
+
); }, - name: 'Autocomplete with ListBox' + name: 'Autocomplete with ListBox + Popover' }; function VirtualizedListBox(props) { @@ -495,15 +510,16 @@ function VirtualizedListBox(props) { initialItems: items }); - let {onSelectionChange, selectionMode} = props; + let {onSelectionChange, selectionMode, escapeKeyBehavior} = props; return ( {item => {item.name}} @@ -514,21 +530,37 @@ function VirtualizedListBox(props) { export const AutocompleteWithVirtualizedListbox = { render: (args) => { - let {onSelectionChange, selectionMode} = args; return ( - -
- - - - Please select an option below. - - -
-
+ + + + {() => ( + +
+ + + + Please select an option below. + + +
+
+ )} +
+
); }, - name: 'Autocomplete with ListBox, virtualized' + name: 'Autocomplete with ListBox + Popover, virtualized' }; let lotsOfSections: any[] = []; @@ -751,9 +783,11 @@ export function AutocompleteWithExtraButtons() { ); } +// TODO: note that Space is used to select an item in a multiselect menu but that is also reserved for the +// autocomplete input field. Should we add logic to allow Space to select menu items when focus is in the Menu +// or is that a rare/unlikely use case for menus in general? export const AutocompleteMenuInPopoverDialogTrigger = { render: (args) => { - let {onAction, onSelectionChange, selectionMode} = args; return (
diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index ee2685554df..ab868c925c3 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -65,7 +65,8 @@ const MyGridListItem = (props: GridListItemProps) => { GridListExample.story = { args: { - layout: 'stack' + layout: 'stack', + escapeKeyBehavior: 'clearSelection' }, argTypes: { layout: { @@ -83,6 +84,10 @@ GridListExample.story = { selectionBehavior: { control: 'radio', options: ['toggle', 'replace'] + }, + escapeKeyBehavior: { + control: 'radio', + options: ['clearSelection', 'none'] } } }; @@ -158,7 +163,7 @@ export function VirtualizedGridListGrid() { } return ( - Actions - 1,3 + 1,3 Tag 1 diff --git a/packages/react-aria-components/stories/ListBox.stories.tsx b/packages/react-aria-components/stories/ListBox.stories.tsx index b4008633204..d815ab2ba00 100644 --- a/packages/react-aria-components/stories/ListBox.stories.tsx +++ b/packages/react-aria-components/stories/ListBox.stories.tsx @@ -35,7 +35,8 @@ ListBoxExample.story = { args: { selectionMode: 'none', selectionBehavior: 'toggle', - shouldFocusOnHover: false + shouldFocusOnHover: false, + escapeKeyBehavior: 'clearSelection' }, argTypes: { selectionMode: { @@ -45,6 +46,10 @@ ListBoxExample.story = { selectionBehavior: { control: 'radio', options: ['toggle', 'replace'] + }, + escapeKeyBehavior: { + control: 'radio', + options: ['clearSelection', 'none'] } }, parameters: { @@ -363,7 +368,7 @@ function VirtualizedListBoxGridExample({minSize = 80, maxSize = 100, preserveAsp return (
- - ( ); -export const TableExample = () => { +const TableExample = (args) => { let list = useListData({ initialItems: [ {id: 1, name: 'Games', date: '6/7/2020', type: 'File folder'}, @@ -112,10 +112,11 @@ export const TableExample = () => { }); return ( - - + +
- Name + + Name Type Date Modified Actions @@ -123,6 +124,7 @@ export const TableExample = () => { {item => ( + {item.name} {item.type} {item.date} @@ -175,6 +177,29 @@ export const TableExample = () => { ); }; +export const TableExampleStory = { + render: TableExample, + args: { + selectionMode: 'none', + selectionBehavior: 'toggle', + escapeKeyBehavior: 'clearSelection' + }, + argTypes: { + selectionMode: { + control: 'radio', + options: ['none', 'single', 'multiple'] + }, + selectionBehavior: { + control: 'radio', + options: ['toggle', 'replace'] + }, + escapeKeyBehavior: { + control: 'radio', + options: ['clearSelection', 'none'] + } + } +}; + let columns = [ {name: 'Name', id: 'name', isRowHeader: true}, {name: 'Type', id: 'type'}, diff --git a/packages/react-aria-components/stories/Tree.stories.tsx b/packages/react-aria-components/stories/Tree.stories.tsx index 4bf2c53a1b0..de7df787d95 100644 --- a/packages/react-aria-components/stories/Tree.stories.tsx +++ b/packages/react-aria-components/stories/Tree.stories.tsx @@ -147,7 +147,8 @@ export const TreeExampleStatic = { args: { selectionMode: 'none', selectionBehavior: 'toggle', - disabledBehavior: 'selection' + disabledBehavior: 'selection', + disallowClearAll: false }, argTypes: { selectionMode: { diff --git a/packages/react-aria-components/test/Autocomplete.test.tsx b/packages/react-aria-components/test/Autocomplete.test.tsx index 7dc1f5476dc..eaa2c274347 100644 --- a/packages/react-aria-components/test/Autocomplete.test.tsx +++ b/packages/react-aria-components/test/Autocomplete.test.tsx @@ -621,6 +621,86 @@ describe('Autocomplete', () => { let options = within(menu).getAllByRole('menuitem'); expect(input).toHaveAttribute('aria-activedescendant', options[0].id); }); + + it('should close the Dialog on the second press of Escape if the inner ListBox has escapeKeyBehavior: "none" ', async () => { + const DialogExample = (props) => { + let {contains} = useFilter({sensitivity: 'base'}); + + return ( + + + + + + + + + + + + + + + + ); + }; + + let {getByRole, getAllByRole, rerender, queryAllByRole} = render(); + let button = getByRole('button'); + await user.tab(); + expect(document.activeElement).toBe(button); + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); + + let dialogs = getAllByRole('dialog'); + expect(dialogs).toHaveLength(1); + let options = getAllByRole('option'); + expect(options[0]).toHaveAttribute('aria-selected', 'true'); + + let input = getByRole('searchbox'); + expect(document.activeElement).toBe(input); + await user.keyboard('I'); + expect(input).toHaveValue('I'); + + await user.keyboard('{Escape}'); + expect(input).toHaveValue(''); + + await user.keyboard('{Escape}'); + act(() => jest.runAllTimers()); + dialogs = queryAllByRole('dialog'); + expect(dialogs).toHaveLength(0); + + // Test without escapeKeyBehavior, 2nd Escape should clear selection instead of closing the dialog + rerender(); + button = getByRole('button'); + await user.click(button); + act(() => jest.runAllTimers()); + + dialogs = getAllByRole('dialog'); + expect(dialogs).toHaveLength(1); + options = getAllByRole('option'); + expect(options[0]).toHaveAttribute('aria-selected', 'true'); + + input = getByRole('searchbox'); + expect(document.activeElement).toBe(input); + await user.keyboard('I'); + expect(input).toHaveValue('I'); + + await user.keyboard('{Escape}'); + expect(input).toHaveValue(''); + + await user.keyboard('{Escape}'); + act(() => jest.runAllTimers()); + dialogs = queryAllByRole('dialog'); + expect(dialogs).toHaveLength(1); + options = getAllByRole('option'); + expect(options[0]).not.toHaveAttribute('aria-selected', 'true'); + + await user.keyboard('{Escape}'); + act(() => jest.runAllTimers()); + dialogs = queryAllByRole('dialog'); + expect(dialogs).toHaveLength(0); + }); }); AriaAutocompleteTests({ diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index 9a4d106fe3c..70ee7803246 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -248,6 +248,23 @@ describe('GridList', () => { expect(within(row).getByRole('checkbox')).not.toBeChecked(); }); + it('should prevent Esc from clearing selection if escapeKeyBehavior is "none"', async () => { + let {getByRole} = renderGridList({selectionMode: 'multiple', escapeKeyBehavior: 'none'}); + let gridListTester = testUtilUser.createTester('GridList', {root: getByRole('grid')}); + + let row = gridListTester.rows[0]; + expect(within(row).getByRole('checkbox')).not.toBeChecked(); + + await gridListTester.toggleRowSelection({row: 0}); + expect(gridListTester.selectedRows).toHaveLength(1); + + await gridListTester.toggleRowSelection({row: 1}); + expect(gridListTester.selectedRows).toHaveLength(2); + + await user.keyboard('{Escape}'); + expect(gridListTester.selectedRows).toHaveLength(2); + }); + it('should support disabled state', () => { let {getAllByRole} = renderGridList({selectionMode: 'multiple', disabledKeys: ['cat']}, {className: ({isDisabled}) => isDisabled ? 'disabled' : ''}); let row = getAllByRole('row')[0]; diff --git a/packages/react-aria-components/test/ListBox.test.js b/packages/react-aria-components/test/ListBox.test.js index 869f2d841d9..88070aef887 100644 --- a/packages/react-aria-components/test/ListBox.test.js +++ b/packages/react-aria-components/test/ListBox.test.js @@ -821,6 +821,21 @@ describe('ListBox', () => { expect(options.map(r => r.textContent)).toEqual(['Item 7', 'Item 8', 'Item 9', 'Item 10', 'Item 11', 'Item 12', 'Item 13', 'Item 14', 'Item 49']); }); + it('should prevent Esc from clearing selection if escapeKeyBehavior is "none"', async () => { + let {getByRole} = renderListbox({selectionMode: 'multiple', escapeKeyBehavior: 'none'}); + + let listboxTester = testUtilUser.createTester('ListBox', {root: getByRole('listbox')}); + let option = listboxTester.options()[0]; + expect(option).not.toHaveAttribute('aria-selected', 'true'); + expect(option).not.toHaveClass('selected'); + + await listboxTester.toggleOptionSelection({option}); + expect(option).toHaveAttribute('aria-selected', 'true'); + + await user.keyboard('{Escape}'); + expect(option).toHaveAttribute('aria-selected', 'true'); + }); + describe('drag and drop', () => { it('should support draggable items', () => { let {getAllByRole} = render(); diff --git a/packages/react-aria-components/test/Menu.test.tsx b/packages/react-aria-components/test/Menu.test.tsx index fc30b7796e8..f308b014715 100644 --- a/packages/react-aria-components/test/Menu.test.tsx +++ b/packages/react-aria-components/test/Menu.test.tsx @@ -386,6 +386,19 @@ describe('Menu', () => { } }); + it('should prevent Esc from clearing selection if escapeKeyBehavior is "none"', async () => { + let {getAllByRole} = renderMenu({selectionMode: 'multiple', escapeKeyBehavior: 'none'}); + let menuitem = getAllByRole('menuitemcheckbox')[0]; + + expect(menuitem).not.toHaveAttribute('aria-checked', 'true'); + + await user.click(menuitem); + expect(menuitem).toHaveAttribute('aria-checked', 'true'); + + await user.keyboard('{Escape}'); + expect(menuitem).toHaveAttribute('aria-checked', 'true'); + }); + it('should support disabled state', () => { let {getAllByRole} = renderMenu({disabledKeys: ['cat']}, {className: ({isDisabled}) => isDisabled ? 'disabled' : ''}); let menuitem = getAllByRole('menuitem')[0]; diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 3d72ef98283..1c1648c396e 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -335,6 +335,28 @@ describe('Table', () => { } }); + it('should prevent Esc from clearing selection if escapeKeyBehavior is "none"', async () => { + let onSelectionChange = jest.fn(); + let {getAllByRole} = renderTable({ + tableProps: {selectionMode: 'multiple', escapeKeyBehavior: 'none', onSelectionChange} + }); + + let checkbox1 = getAllByRole('checkbox')[1]; + await user.click(checkbox1); + expect(checkbox1).toBeChecked(); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['1'])); + + let checkbox2 = getAllByRole('checkbox')[2]; + await user.click(checkbox2); + expect(checkbox2).toBeChecked(); + expect(new Set(onSelectionChange.mock.calls[1][0])).toEqual(new Set(['1', '2'])); + + await user.keyboard('{Escape}'); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(checkbox1).toBeChecked(); + expect(checkbox2).toBeChecked(); + }); + it('should not render checkboxes for selection with selectionBehavior=replace', async () => { let {getAllByRole} = renderTable({ tableProps: { diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index f22e70f8ff8..6d74c669547 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -629,6 +629,24 @@ describe('Tree', () => { expect(onSelectionChange).toHaveBeenCalledTimes(0); }); + it('should prevent Esc from clearing selection if escapeKeyBehavior is "none"', async () => { + let {getAllByRole} = render(); + + let rows = getAllByRole('row'); + await user.click(rows[0]); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['Photos'])); + + await user.click(rows[1]); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(new Set(onSelectionChange.mock.calls[1][0])).toEqual(new Set(['Photos', 'projects'])); + + await user.keyboard('{Escape}'); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(rows[0]).toHaveAttribute('data-selected'); + expect(rows[1]).toHaveAttribute('data-selected'); + }); + it('should support onScroll', () => { let onScroll = jest.fn(); let {getByRole} = render(); From 5e7a5af475a8485d2ecbe00f1afd5f5f3831ca9e Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Mon, 7 Apr 2025 11:50:18 -0700 Subject: [PATCH 05/25] fix: useMove broken by NODE_ENV check (#8046) --- packages/@react-aria/interactions/src/useMove.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/interactions/src/useMove.ts b/packages/@react-aria/interactions/src/useMove.ts index afda9607f56..f7f32acfdbb 100644 --- a/packages/@react-aria/interactions/src/useMove.ts +++ b/packages/@react-aria/interactions/src/useMove.ts @@ -93,7 +93,7 @@ export function useMove(props: MoveEvents): MoveResult { state.current.didMove = false; }; - if (typeof PointerEvent === 'undefined') { + if (typeof PointerEvent === 'undefined' && process.env.NODE_ENV === 'test') { let onMouseMove = (e: MouseEvent) => { if (e.button === 0) { move(e, 'mouse', e.pageX - (state.current.lastPosition?.pageX ?? 0), e.pageY - (state.current.lastPosition?.pageY ?? 0)); @@ -151,7 +151,7 @@ export function useMove(props: MoveEvents): MoveResult { addGlobalListener(window, 'touchend', onTouchEnd, false); addGlobalListener(window, 'touchcancel', onTouchEnd, false); }; - } else if (process.env.NODE_ENV === 'test') { + } else { let onPointerMove = (e: PointerEvent) => { if (e.pointerId === state.current.id) { let pointerType = (e.pointerType || 'mouse') as PointerType; From ad4681f7462e2e604e6911eeb7f1c4e73724f4ca Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Tue, 8 Apr 2025 07:17:29 +1000 Subject: [PATCH 06/25] fix: ColorWheel track click (#8049) --- packages/react-aria-components/src/ColorWheel.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react-aria-components/src/ColorWheel.tsx b/packages/react-aria-components/src/ColorWheel.tsx index 993125285f6..9f39c93445c 100644 --- a/packages/react-aria-components/src/ColorWheel.tsx +++ b/packages/react-aria-components/src/ColorWheel.tsx @@ -78,6 +78,8 @@ export const ColorWheelTrackContext = createContext) { [props, ref] = useContextProps(props, ref, ColorWheelTrackContext); let state = useContext(ColorWheelStateContext)!; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let {className, style, ...rest} = props; let renderProps = useRenderProps({ ...props, @@ -87,11 +89,10 @@ export const ColorWheelTrack = forwardRef(function ColorWheelTrack(props: ColorW state } }); - let DOMProps = filterDOMProps(props); return (
From 7e7e33d38e5333abca50cf0cb414023ea07de104 Mon Sep 17 00:00:00 2001 From: DarkstarXDD <108657985+DarkstarXDD@users.noreply.github.com> Date: Tue, 8 Apr 2025 20:11:04 +0530 Subject: [PATCH 07/25] fix: minor typo in CalendarDate docs (#8043) * fix: minor typo in CalendarDate docs * fix second example as well --------- Co-authored-by: Robert Snow --- packages/@internationalized/date/docs/CalendarDate.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@internationalized/date/docs/CalendarDate.mdx b/packages/@internationalized/date/docs/CalendarDate.mdx index ab51b533a97..a14440ba735 100644 --- a/packages/@internationalized/date/docs/CalendarDate.mdx +++ b/packages/@internationalized/date/docs/CalendarDate.mdx @@ -72,7 +72,7 @@ date.toString(); // '2022-02-03' By default, `CalendarDate` uses the Gregorian calendar system, but many other calendar systems that are used around the world are supported, such as Hebrew, Indian, Islamic, Buddhist, Ethiopic, and more. A instance can be passed to the `CalendarDate` constructor to represent dates in that calendar system. -This example creates a date in the Buddhist calendar system, which is equivalent to April 4th, 2020 in the Gregorian calendar. +This example creates a date in the Buddhist calendar system, which is equivalent to April 30th, 2020 in the Gregorian calendar. ```tsx import {BuddhistCalendar} from '@internationalized/date'; @@ -86,7 +86,7 @@ See the [Calendar](Calendar.html#implementations) docs for details about the sup Many calendar systems have only one era, or a modern era and a pre-modern era (e.g. AD and BC in the Gregorian calendar). However, other calendar systems may have many eras. For example, the Japanese calendar has eras for the reign of each Emperor. `CalendarDate` represents eras using string identifiers, which can be passed as an additional parameter to the constructor before the year. When eras are present, years are numbered starting from 1 within the era. -This example creates a date in the Japanese calendar system, which is equivalent to April 4th, 2020 in the Gregorian calendar. +This example creates a date in the Japanese calendar system, which is equivalent to April 30th, 2019 in the Gregorian calendar. ```tsx import {JapaneseCalendar} from '@internationalized/date'; From 8fbe17f4fb581a5e01f1ed49265a916d4d4d3981 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 8 Apr 2025 09:18:00 -0700 Subject: [PATCH 08/25] fix: Updating collection when items change parents (#8052) --- .../@react-aria/collections/src/Document.ts | 10 ++-- .../test/ListBox.test.js | 48 +++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/packages/@react-aria/collections/src/Document.ts b/packages/@react-aria/collections/src/Document.ts index d01d4170e91..1385e18a413 100644 --- a/packages/@react-aria/collections/src/Document.ts +++ b/packages/@react-aria/collections/src/Document.ts @@ -460,9 +460,13 @@ export class Document = BaseCollection> extend } updateCollection(): void { - // First, update the indices of dirty element children. + // First, remove disconnected nodes and update the indices of dirty element children. for (let element of this.dirtyNodes) { - element.updateChildIndices(); + if (element instanceof ElementNode && (!element.isConnected || element.isHidden)) { + this.removeNode(element); + } else { + element.updateChildIndices(); + } } // Next, update dirty collection nodes. @@ -471,8 +475,6 @@ export class Document = BaseCollection> extend if (element.isConnected && !element.isHidden) { element.updateNode(); this.addNode(element); - } else { - this.removeNode(element); } element.isMutated = false; diff --git a/packages/react-aria-components/test/ListBox.test.js b/packages/react-aria-components/test/ListBox.test.js index 88070aef887..17e7df8a62e 100644 --- a/packages/react-aria-components/test/ListBox.test.js +++ b/packages/react-aria-components/test/ListBox.test.js @@ -337,6 +337,54 @@ describe('ListBox', () => { expect(getAllByRole('option').map(o => o.textContent)).toEqual(['Hi']); }); + it('should update collection when moving item to a different section', () => { + let {getAllByRole, rerender} = render( + + +
Veggies
+ Lettuce + Tomato + Onion +
+ +
Meats
+ Ham + Tuna + Tofu +
+
+ ); + + let sections = getAllByRole('group'); + let items = within(sections[0]).getAllByRole('option'); + expect(items).toHaveLength(3); + items = within(sections[1]).getAllByRole('option'); + expect(items).toHaveLength(3); + + rerender( + + +
Veggies
+ Lettuce + Tomato + Onion + Ham +
+ +
Meats
+ Tuna + Tofu +
+
+ ); + + sections = getAllByRole('group'); + items = within(sections[0]).getAllByRole('option'); + expect(items).toHaveLength(4); + items = within(sections[1]).getAllByRole('option'); + expect(items).toHaveLength(2); + }); + it('should support autoFocus', () => { let {getByRole} = renderListbox({autoFocus: true}); let listbox = getByRole('listbox'); From 4bcf541873fde28aba23a0da5ac82018b5ea712f Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Tue, 8 Apr 2025 11:42:19 -0500 Subject: [PATCH 09/25] export Autocomplete from S2 (#8050) --- packages/@react-spectrum/s2/src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/@react-spectrum/s2/src/index.ts b/packages/@react-spectrum/s2/src/index.ts index adc682d6ac1..717ccf75515 100644 --- a/packages/@react-spectrum/s2/src/index.ts +++ b/packages/@react-spectrum/s2/src/index.ts @@ -81,6 +81,7 @@ export {TreeView, TreeViewItem, TreeViewItemContent} from './TreeView'; export {pressScale} from './pressScale'; +export {Autocomplete, AutocompleteContext, AutocompleteStateContext} from 'react-aria-components'; export {Collection} from 'react-aria-components'; export {FileTrigger} from 'react-aria-components'; @@ -148,4 +149,4 @@ export type {ToggleButtonProps} from './ToggleButton'; export type {ToggleButtonGroupProps} from './ToggleButtonGroup'; export type {TooltipProps} from './Tooltip'; export type {TreeViewProps, TreeViewItemProps, TreeViewItemContentProps} from './TreeView'; -export type {FileTriggerProps, TooltipTriggerComponentProps as TooltipTriggerProps, SortDescriptor} from 'react-aria-components'; +export type {AutocompleteProps, FileTriggerProps, TooltipTriggerComponentProps as TooltipTriggerProps, SortDescriptor} from 'react-aria-components'; From 2edef8989822c91a62fe76c2bb52c4012bb1f272 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 8 Apr 2025 09:49:57 -0700 Subject: [PATCH 10/25] chore: Optimize table test performance (#8051) --- .../table/src/TableViewBase.tsx | 1 + .../table/stories/TreeGridTable.stories.tsx | 26 +- .../@react-spectrum/table/test/Table.test.js | 5055 +--------------- .../table/test/TableDnd.test.js | 8 +- .../table/test/TableNestedRows.test.js | 2 +- .../@react-spectrum/table/test/TableTests.js | 5070 +++++++++++++++++ .../table/test/TestTableUtils.test.js | 4 + .../table/test/TreeGridTable.test.tsx | 75 +- 8 files changed, 5137 insertions(+), 5104 deletions(-) create mode 100644 packages/@react-spectrum/table/test/TableTests.js diff --git a/packages/@react-spectrum/table/src/TableViewBase.tsx b/packages/@react-spectrum/table/src/TableViewBase.tsx index 3a78f963f8e..038742935f2 100644 --- a/packages/@react-spectrum/table/src/TableViewBase.tsx +++ b/packages/@react-spectrum/table/src/TableViewBase.tsx @@ -1050,6 +1050,7 @@ function TableSelectAllCell({column}) { } diff --git a/packages/@react-spectrum/table/stories/TreeGridTable.stories.tsx b/packages/@react-spectrum/table/stories/TreeGridTable.stories.tsx index ce4d3a3554d..424bbe7f357 100644 --- a/packages/@react-spectrum/table/stories/TreeGridTable.stories.tsx +++ b/packages/@react-spectrum/table/stories/TreeGridTable.stories.tsx @@ -14,7 +14,7 @@ import {action} from '@storybook/addon-actions'; import {ActionButton} from '@react-spectrum/button'; import {Cell, Column, Row, SpectrumTableProps, TableBody, TableHeader, TableView} from '../'; import {chain} from '@react-aria/utils'; -import {ComponentMeta} from '@storybook/react'; +import {ComponentMeta, ComponentStoryObj} from '@storybook/react'; import defaultConfig, {columns, EmptyStateTable, TableStory} from './Table.stories'; import {enableTableNestedRows} from '@react-stately/flags'; import {Flex} from '@react-spectrum/layout'; @@ -162,7 +162,6 @@ export const UserSetRowHeader: TableStory = { } }; -let manyRows: Record[] = []; function generateRow(lvlIndex, lvlLimit, rowIndex) { let row = {key: `Row ${rowIndex} Lvl ${lvlIndex}`}; for (let col of columns) { @@ -175,19 +174,25 @@ function generateRow(lvlIndex, lvlLimit, rowIndex) { return row; } -for (let i = 1; i < 20; i++) { - let row = generateRow(1, 3, i); - manyRows.push(row); +function generateRows(count = 5) { + let manyRows: Record[] = []; + for (let i = 1; i <= count; i++) { + let row = generateRow(1, 3, i); + manyRows.push(row); + } + return manyRows; } interface ManyExpandableRowsProps extends SpectrumTableProps { allowsResizing?: boolean, - showDivider?: boolean + showDivider?: boolean, + rowCount?: number } function ManyExpandableRows(props: ManyExpandableRowsProps) { let {allowsResizing, showDivider, ...otherProps} = props; let [expandedKeys, setExpandedKeys] = useState<'all' | Set>('all'); + let manyRows = generateRows(props.rowCount ?? 5); return ( @@ -211,11 +216,12 @@ function ManyExpandableRows(props: ManyExpandableRowsProps) { ); } -export const ManyExpandableRowsStory: TableStory = { +export const ManyExpandableRowsStory: ComponentStoryObj = { args: { 'aria-label': 'TableView with many dynamic expandable rows', width: 500, - height: 400 + height: 400, + rowCount: 5 }, render: (args) => ( @@ -230,7 +236,7 @@ export const EmptyTreeGridStory: TableStory = { height: 400 }, render: (args) => ( - + ), name: 'empty state' }; @@ -245,7 +251,7 @@ function LoadingStateTable(props) { {column => {column.name}} - + {item => ( {key => {item[key]}} diff --git a/packages/@react-spectrum/table/test/Table.test.js b/packages/@react-spectrum/table/test/Table.test.js index 5a461f65cd8..53de184f870 100644 --- a/packages/@react-spectrum/table/test/Table.test.js +++ b/packages/@react-spectrum/table/test/Table.test.js @@ -10,5060 +10,7 @@ * governing permissions and limitations under the License. */ -jest.mock('@react-aria/live-announcer'); -jest.mock('@react-aria/utils/src/scrollIntoView'); -import {act, fireEvent, installPointerEvent, mockClickDefault, pointerMap, render as renderComponent, User, within} from '@react-spectrum/test-utils-internal'; -import {ActionButton, Button} from '@react-spectrum/button'; -import Add from '@spectrum-icons/workflow/Add'; -import {announce} from '@react-aria/live-announcer'; -import {ButtonGroup} from '@react-spectrum/buttongroup'; -import {Cell, Column, Row, TableBody, TableHeader, TableView} from '../'; -import {composeStories} from '@storybook/react'; -import {Content} from '@react-spectrum/view'; -import {CRUDExample} from '../stories/CRUDExample'; -import {Dialog, DialogTrigger} from '@react-spectrum/dialog'; -import {Divider} from '@react-spectrum/divider'; -import {getFocusableTreeWalker} from '@react-aria/focus'; -import {Heading} from '@react-spectrum/text'; -import {Item, Picker} from '@react-spectrum/picker'; -import {Link} from '@react-spectrum/link'; -import {Provider} from '@react-spectrum/provider'; -import React from 'react'; -import {scrollIntoView} from '@react-aria/utils'; -import * as stories from '../stories/Table.stories'; -import {Switch} from '@react-spectrum/switch'; -import {TextField} from '@react-spectrum/textfield'; -import {theme} from '@react-spectrum/theme-default'; -import userEvent from '@testing-library/user-event'; - -let { - InlineDeleteButtons: DeletableRowsTable, - EmptyStateStory: EmptyStateTable, - WithBreadcrumbNavigation: TableWithBreadcrumbs, - TypeaheadWithDialog: TypeaheadWithDialog, - ColumnHeaderFocusRingTable -} = composeStories(stories); - - -let columns = [ - {name: 'Foo', key: 'foo'}, - {name: 'Bar', key: 'bar'}, - {name: 'Baz', key: 'baz'} -]; - -let nestedColumns = [ - {name: 'Test', key: 'test'}, - {name: 'Tiered One Header', key: 'tier1', children: [ - {name: 'Tier Two Header A', key: 'tier2a', children: [ - {name: 'Foo', key: 'foo'}, - {name: 'Bar', key: 'bar'} - ]}, - {name: 'Yay', key: 'yay'}, - {name: 'Tier Two Header B', key: 'tier2b', children: [ - {name: 'Baz', key: 'baz'} - ]} - ]} -]; - -let items = [ - {test: 'Test 1', foo: 'Foo 1', bar: 'Bar 1', yay: 'Yay 1', baz: 'Baz 1'}, - {test: 'Test 2', foo: 'Foo 2', bar: 'Bar 2', yay: 'Yay 2', baz: 'Baz 2'} -]; - -let itemsWithFalsyId = [ - {test: 'Test 1', foo: 'Foo 1', bar: 'Bar 1', yay: 'Yay 1', baz: 'Baz 1', id: 0}, - {test: 'Test 2', foo: 'Foo 2', bar: 'Bar 2', yay: 'Yay 2', baz: 'Baz 2', id: 1} -]; - -let manyItems = []; -for (let i = 1; i <= 100; i++) { - manyItems.push({id: i, foo: 'Foo ' + i, bar: 'Bar ' + i, baz: 'Baz ' + i}); -} - -let manyColumns = []; -for (let i = 1; i <= 100; i++) { - manyColumns.push({id: i, name: 'Column ' + i}); -} - -function ExampleSortTable() { - let [sortDescriptor, setSortDescriptor] = React.useState({column: 'bar', direction: 'ascending'}); - - return ( - - - Foo - Bar - Baz - - - - Foo 1 - Bar 1 - Baz 1 - - - - ); -} - -let defaultTable = ( - - - Foo - Bar - - - - Foo 1 - Bar 1 - - - Foo 2 - Bar 2 - - - -); - -function pointerEvent(type, opts) { - let evt = new Event(type, {bubbles: true, cancelable: true}); - Object.assign(evt, { - ctrlKey: false, - metaKey: false, - shiftKey: false, - altKey: false, - button: opts.button || 0, - width: opts.width == null ? undefined : opts.width ?? 1, - height: opts.height == null ? undefined : opts.height ?? 1 - }, opts); - return evt; -} - -export let tableTests = () => { - // Temporarily disabling these tests in React 16/17 because they run into a memory limit and crash. - // TODO: investigate. - if (parseInt(React.version, 10) <= 17) { - return; - } - - let offsetWidth, offsetHeight; - let user; - let testUtilUser = new User({advanceTimer: (time) => jest.advanceTimersByTime(time)}); - - beforeAll(function () { - user = userEvent.setup({delay: null, pointerMap}); - offsetWidth = jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 1000); - offsetHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 1000); - jest.useFakeTimers(); - }); - - afterAll(function () { - offsetWidth.mockReset(); - offsetHeight.mockReset(); - }); - - afterEach(() => { - act(() => {jest.runAllTimers();}); - }); - - let render = (children, scale = 'medium') => { - let tree = renderComponent( - - {children} - - ); - // account for table column resizing to do initial pass due to relayout from useTableColumnResizeState render - act(() => {jest.runAllTimers();}); - return tree; - }; - - let rerender = (tree, children, scale = 'medium') => { - let newTree = tree.rerender( - - {children} - - ); - act(() => {jest.runAllTimers();}); - return newTree; - }; - // I'd use tree.getByRole(role, {name: text}) here, but it's unbearably slow. - let getCell = (tree, text) => { - // Find by text, then go up to the element with the cell role. - let el = tree.getByText(text); - while (el && !/gridcell|rowheader|columnheader/.test(el.getAttribute('role'))) { - el = el.parentElement; - } - - return el; - }; - - let focusCell = (tree, text) => act(() => getCell(tree, text).focus()); - let moveFocus = (key, opts = {}) => {fireEvent.keyDown(document.activeElement, {key, ...opts});}; - - it('renders a static table', function () { - let {getByRole} = render( - - - Foo - Bar - Baz - - - - Foo 1 - Bar 1 - Baz 1 - - - Foo 2 - Bar 2 - Baz 2 - - - - ); - - let grid = getByRole('grid'); - expect(grid).toBeVisible(); - expect(grid).toHaveAttribute('aria-label', 'Table'); - expect(grid).toHaveAttribute('data-testid', 'test'); - - expect(grid).toHaveAttribute('aria-rowcount', '3'); - expect(grid).toHaveAttribute('aria-colcount', '3'); - - let rowgroups = within(grid).getAllByRole('rowgroup'); - expect(rowgroups).toHaveLength(2); - - let headerRows = within(rowgroups[0]).getAllByRole('row'); - expect(headerRows).toHaveLength(1); - expect(headerRows[0]).toHaveAttribute('aria-rowindex', '1'); - - let headers = within(grid).getAllByRole('columnheader'); - expect(headers).toHaveLength(3); - expect(headers[0]).toHaveAttribute('aria-colindex', '1'); - expect(headers[1]).toHaveAttribute('aria-colindex', '2'); - expect(headers[2]).toHaveAttribute('aria-colindex', '3'); - - for (let header of headers) { - expect(header).not.toHaveAttribute('aria-sort'); - expect(header).not.toHaveAttribute('aria-describedby'); - } - - expect(headers[0]).toHaveTextContent('Foo'); - expect(headers[1]).toHaveTextContent('Bar'); - expect(headers[2]).toHaveTextContent('Baz'); - - let rows = within(rowgroups[1]).getAllByRole('row'); - expect(rows).toHaveLength(2); - expect(rows[0]).toHaveAttribute('aria-rowindex', '2'); - expect(rows[1]).toHaveAttribute('aria-rowindex', '3'); - - let rowheader = within(rows[0]).getByRole('rowheader'); - let cellSpan = within(rowheader).getByText('Foo 1'); - expect(rowheader).toHaveTextContent('Foo 1'); - expect(rowheader).toHaveAttribute('aria-colindex', '1'); - - expect(rows[0]).toHaveAttribute('aria-labelledby', cellSpan.id); - - rowheader = within(rows[1]).getByRole('rowheader'); - cellSpan = within(rowheader).getByText('Foo 2'); - expect(rowheader).toHaveTextContent('Foo 2'); - expect(rowheader).toHaveAttribute('aria-colindex', '1'); - - expect(rows[1]).not.toHaveAttribute('aria-selected'); - expect(rows[1]).toHaveAttribute('aria-labelledby', cellSpan.id); - - - let cells = within(rowgroups[1]).getAllByRole('gridcell'); - expect(cells).toHaveLength(4); - - expect(cells[0]).toHaveAttribute('aria-colindex', '2'); - expect(cells[1]).toHaveAttribute('aria-colindex', '3'); - expect(cells[2]).toHaveAttribute('aria-colindex', '2'); - expect(cells[3]).toHaveAttribute('aria-colindex', '3'); - }); - - it('renders a static table with selection', function () { - let {getByRole} = render( - - - Foo - Bar - Baz - - - - Foo 1 - Bar 1 - Baz 1 - - - Foo 2 - Bar 2 - Baz 2 - - - - ); - - let grid = getByRole('grid'); - expect(grid).toBeVisible(); - expect(grid).toHaveAttribute('aria-label', 'Table'); - expect(grid).toHaveAttribute('data-testid', 'test'); - expect(grid).toHaveAttribute('aria-multiselectable', 'true'); - expect(grid).toHaveAttribute('aria-rowcount', '3'); - expect(grid).toHaveAttribute('aria-colcount', '4'); - - let rowgroups = within(grid).getAllByRole('rowgroup'); - expect(rowgroups).toHaveLength(2); - - let headerRows = within(rowgroups[0]).getAllByRole('row'); - expect(headerRows).toHaveLength(1); - expect(headerRows[0]).toHaveAttribute('aria-rowindex', '1'); - - let headers = within(grid).getAllByRole('columnheader'); - expect(headers).toHaveLength(4); - expect(headers[0]).toHaveAttribute('aria-colindex', '1'); - expect(headers[1]).toHaveAttribute('aria-colindex', '2'); - expect(headers[2]).toHaveAttribute('aria-colindex', '3'); - expect(headers[3]).toHaveAttribute('aria-colindex', '4'); - - for (let header of headers) { - expect(header).not.toHaveAttribute('aria-sort'); - expect(header).not.toHaveAttribute('aria-describedby'); - } - - let checkbox = within(headers[0]).getByRole('checkbox'); - expect(checkbox).toHaveAttribute('aria-label', 'Select All'); - - expect(headers[1]).toHaveTextContent('Foo'); - expect(headers[2]).toHaveTextContent('Bar'); - expect(headers[3]).toHaveTextContent('Baz'); - - let rows = within(rowgroups[1]).getAllByRole('row'); - expect(rows).toHaveLength(2); - expect(rows[0]).toHaveAttribute('aria-rowindex', '2'); - expect(rows[1]).toHaveAttribute('aria-rowindex', '3'); - - let rowheader = within(rows[0]).getByRole('rowheader'); - let cellSpan = within(rowheader).getByText('Foo 1'); - expect(rowheader).toHaveTextContent('Foo 1'); - expect(rowheader).toHaveAttribute('aria-colindex', '2'); - - expect(rows[0]).toHaveAttribute('aria-selected', 'false'); - expect(rows[0]).toHaveAttribute('aria-labelledby', cellSpan.id); - - checkbox = within(rows[0]).getByRole('checkbox'); - expect(checkbox).toHaveAttribute('aria-label', 'Select'); - expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${cellSpan.id}`); - - rowheader = within(rows[1]).getByRole('rowheader'); - cellSpan = within(rowheader).getByText('Foo 2'); - expect(rowheader).toHaveTextContent('Foo 2'); - expect(rowheader).toHaveAttribute('aria-colindex', '2'); - - expect(rows[1]).toHaveAttribute('aria-selected', 'false'); - expect(rows[1]).toHaveAttribute('aria-labelledby', cellSpan.id); - - - checkbox = within(rows[1]).getByRole('checkbox'); - expect(checkbox).toHaveAttribute('aria-label', 'Select'); - expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${cellSpan.id}`); - - let cells = within(rowgroups[1]).getAllByRole('gridcell'); - expect(cells).toHaveLength(6); - - expect(cells[0]).toHaveAttribute('aria-colindex', '1'); - expect(cells[1]).toHaveAttribute('aria-colindex', '3'); - expect(cells[2]).toHaveAttribute('aria-colindex', '4'); - expect(cells[3]).toHaveAttribute('aria-colindex', '1'); - expect(cells[4]).toHaveAttribute('aria-colindex', '3'); - expect(cells[5]).toHaveAttribute('aria-colindex', '4'); - }); - - it('accepts a UNSAFE_className', function () { - let {getByRole} = render( - - - Foo - Bar - Baz - - - - Foo 1 - Bar 1 - Baz 1 - - - Foo 2 - Bar 2 - Baz 2 - - - - ); - - let grid = getByRole('grid'); - expect(grid).toHaveAttribute('class', expect.stringContaining('test-class')); - }); - - it('renders a dynamic table', function () { - let {getByRole} = render( - - - {column => {column.name}} - - - {item => - ( - {key => {item[key]}} - ) - } - - - ); - - let grid = getByRole('grid'); - expect(grid).toBeVisible(); - expect(grid).toHaveAttribute('aria-rowcount', '3'); - expect(grid).toHaveAttribute('aria-colcount', '3'); - - let rowgroups = within(grid).getAllByRole('rowgroup'); - expect(rowgroups).toHaveLength(2); - - let headerRows = within(rowgroups[0]).getAllByRole('row'); - expect(headerRows).toHaveLength(1); - expect(headerRows[0]).toHaveAttribute('aria-rowindex', '1'); - - let headers = within(grid).getAllByRole('columnheader'); - expect(headers).toHaveLength(3); - expect(headers[0]).toHaveAttribute('aria-colindex', '1'); - expect(headers[1]).toHaveAttribute('aria-colindex', '2'); - expect(headers[2]).toHaveAttribute('aria-colindex', '3'); - - expect(headers[0]).toHaveTextContent('Foo'); - expect(headers[1]).toHaveTextContent('Bar'); - expect(headers[2]).toHaveTextContent('Baz'); - - let rows = within(rowgroups[1]).getAllByRole('row'); - expect(rows).toHaveLength(2); - - let rowheader = within(rows[0]).getByRole('rowheader'); - let cellSpan = within(rowheader).getByText('Foo 1'); - expect(rowheader).toHaveTextContent('Foo 1'); - expect(rowheader).toHaveAttribute('aria-colindex', '1'); - - expect(rows[0]).toHaveAttribute('aria-labelledby', cellSpan.id); - - rowheader = within(rows[1]).getByRole('rowheader'); - cellSpan = within(rowheader).getByText('Foo 2'); - expect(rowheader).toHaveTextContent('Foo 2'); - expect(rowheader).toHaveAttribute('aria-colindex', '1'); - - expect(rows[1]).not.toHaveAttribute('aria-selected'); - expect(rows[1]).toHaveAttribute('aria-labelledby', cellSpan.id); - - let cells = within(rowgroups[1]).getAllByRole('gridcell'); - expect(cells).toHaveLength(4); - - expect(cells[0]).toHaveAttribute('aria-colindex', '2'); - expect(cells[1]).toHaveAttribute('aria-colindex', '3'); - expect(cells[2]).toHaveAttribute('aria-colindex', '2'); - expect(cells[3]).toHaveAttribute('aria-colindex', '3'); - }); - - it('renders a dynamic table with selection', function () { - let {getByRole} = render( - - - {column => {column.name}} - - - {item => - ( - {key => {item[key]}} - ) - } - - - ); - - let grid = getByRole('grid'); - expect(grid).toBeVisible(); - expect(grid).toHaveAttribute('aria-multiselectable', 'true'); - expect(grid).toHaveAttribute('aria-rowcount', '3'); - expect(grid).toHaveAttribute('aria-colcount', '4'); - - let rowgroups = within(grid).getAllByRole('rowgroup'); - expect(rowgroups).toHaveLength(2); - - let headerRows = within(rowgroups[0]).getAllByRole('row'); - expect(headerRows).toHaveLength(1); - expect(headerRows[0]).toHaveAttribute('aria-rowindex', '1'); - - let headers = within(grid).getAllByRole('columnheader'); - expect(headers).toHaveLength(4); - expect(headers[0]).toHaveAttribute('aria-colindex', '1'); - expect(headers[1]).toHaveAttribute('aria-colindex', '2'); - expect(headers[2]).toHaveAttribute('aria-colindex', '3'); - expect(headers[3]).toHaveAttribute('aria-colindex', '4'); - - let checkbox = within(headers[0]).getByRole('checkbox'); - expect(checkbox).toHaveAttribute('aria-label', 'Select All'); - - expect(headers[1]).toHaveTextContent('Foo'); - expect(headers[2]).toHaveTextContent('Bar'); - expect(headers[3]).toHaveTextContent('Baz'); - - let rows = within(rowgroups[1]).getAllByRole('row'); - expect(rows).toHaveLength(2); - - let rowheader = within(rows[0]).getByRole('rowheader'); - let cellSpan = within(rowheader).getByText('Foo 1'); - expect(rowheader).toHaveTextContent('Foo 1'); - expect(rowheader).toHaveAttribute('aria-colindex', '2'); - - expect(rows[0]).toHaveAttribute('aria-selected', 'false'); - expect(rows[0]).toHaveAttribute('aria-labelledby', cellSpan.id); - - checkbox = within(rows[0]).getByRole('checkbox'); - expect(checkbox).toHaveAttribute('aria-label', 'Select'); - expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${cellSpan.id}`); - - rowheader = within(rows[1]).getByRole('rowheader'); - cellSpan = within(rowheader).getByText('Foo 2'); - expect(rowheader).toHaveTextContent('Foo 2'); - expect(rowheader).toHaveAttribute('aria-colindex', '2'); - - expect(rows[1]).toHaveAttribute('aria-selected', 'false'); - expect(rows[1]).toHaveAttribute('aria-labelledby', cellSpan.id); - - - checkbox = within(rows[1]).getByRole('checkbox'); - expect(checkbox).toHaveAttribute('aria-label', 'Select'); - expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${cellSpan.id}`); - - let cells = within(rowgroups[1]).getAllByRole('gridcell'); - expect(cells).toHaveLength(6); - - expect(cells[0]).toHaveAttribute('aria-colindex', '1'); - expect(cells[1]).toHaveAttribute('aria-colindex', '3'); - expect(cells[2]).toHaveAttribute('aria-colindex', '4'); - expect(cells[3]).toHaveAttribute('aria-colindex', '1'); - expect(cells[4]).toHaveAttribute('aria-colindex', '3'); - expect(cells[5]).toHaveAttribute('aria-colindex', '4'); - }); - - it('renders contents even with falsy row ids', function () { - // TODO: doesn't support empty string ids, fix for that to come - let {getByRole} = render( - - - {column => {column.name}} - - - {item => - ( - {key => {item[key]}} - ) - } - - - ); - - let grid = getByRole('grid'); - let rows = within(grid).getAllByRole('row'); - expect(rows).toHaveLength(3); - - for (let [i, row] of rows.entries()) { - if (i === 0) { - let columnheaders = within(row).getAllByRole('columnheader'); - expect(columnheaders).toHaveLength(3); - for (let [j, columnheader] of columnheaders.entries()) { - expect(within(columnheader).getByText(columns[j].name)).toBeTruthy(); - } - } else { - let rowheader = within(row).getByRole('rowheader'); - expect(within(rowheader).getByText(itemsWithFalsyId[i - 1][columns[0].key])).toBeTruthy(); - let cells = within(row).getAllByRole('gridcell'); - expect(cells).toHaveLength(2); - expect(within(cells[0]).getByText(itemsWithFalsyId[i - 1][columns[1].key])).toBeTruthy(); - expect(within(cells[1]).getByText(itemsWithFalsyId[i - 1][columns[2].key])).toBeTruthy(); - } - } - }); - - it('renders a static table with colspans', function () { - let {getByRole} = render( - - - Col 1 - Col 2 - Col 3 - Col 4 - - - - Cell - Span 2 - Cell - - - Cell - Cell - Cell - Cell - - - Span 4 - - - Span 3 - Cell - - - Cell - Span 3 - - - - ); - - let grid = getByRole('grid'); - expect(grid).toBeVisible(); - expect(grid).toHaveAttribute('aria-rowcount', '6'); - expect(grid).toHaveAttribute('aria-colcount', '4'); - - let rows = within(grid).getAllByRole('row'); - expect(rows).toHaveLength(6); - - let columnheaders = within(rows[0]).getAllByRole('columnheader'); - expect(columnheaders).toHaveLength(4); - - let cells1 = [...within(rows[1]).getAllByRole('rowheader'), ...within(rows[1]).getAllByRole('gridcell')]; - expect(cells1).toHaveLength(3); - expect(cells1[0]).toHaveAttribute('aria-colindex', '1'); - expect(cells1[1]).toHaveAttribute('aria-colindex', '2'); - expect(cells1[1]).toHaveAttribute('aria-colspan', '2'); - expect(cells1[2]).toHaveAttribute('aria-colindex', '4'); - - - let cells2 = [...within(rows[2]).getAllByRole('rowheader'), ...within(rows[2]).getAllByRole('gridcell')]; - expect(cells2).toHaveLength(4); - expect(cells2[0]).toHaveAttribute('aria-colindex', '1'); - expect(cells2[1]).toHaveAttribute('aria-colindex', '2'); - expect(cells2[2]).toHaveAttribute('aria-colindex', '3'); - expect(cells2[3]).toHaveAttribute('aria-colindex', '4'); - - let cells3 = within(rows[3]).getAllByRole('rowheader'); - expect(cells3).toHaveLength(1); - expect(cells3[0]).toHaveAttribute('aria-colindex', '1'); - expect(cells3[0]).toHaveAttribute('aria-colspan', '4'); - - let cells4 = [...within(rows[4]).getAllByRole('rowheader'), ...within(rows[4]).getAllByRole('gridcell')]; - expect(cells4).toHaveLength(2); - expect(cells4[0]).toHaveAttribute('aria-colindex', '1'); - expect(cells4[0]).toHaveAttribute('aria-colspan', '3'); - expect(cells4[1]).toHaveAttribute('aria-colindex', '4'); - - let cells5 = [...within(rows[5]).getAllByRole('rowheader'), ...within(rows[5]).getAllByRole('gridcell')]; - expect(cells5).toHaveLength(2); - expect(cells5[0]).toHaveAttribute('aria-colindex', '1'); - expect(cells5[1]).toHaveAttribute('aria-colindex', '2'); - expect(cells5[1]).toHaveAttribute('aria-colspan', '3'); - }); - - it('should throw error if number of cells do not match column count', () => { - jest.spyOn(console, 'error').mockImplementation(() => {}); - try { - render( - - - Col 1 - Col 2 - Col 3 - Col 4 - - - - Cell 11 - Cell 12 - Cell 14 - - - Cell 21 - Cell 22 - Cell 24 - Cell 25 - - - - ); - } catch (e) { - expect(e.message).toEqual('Cell count must match column count. Found 5 cells and 4 columns.'); - } - try { - render( - - - Col 1 - Col 2 - - - - Cell - - - - ); - } catch (e) { - expect(e.message).toEqual('Cell count must match column count. Found 1 cells and 2 columns.'); - } - }); - - it('renders a static table with nested columns', function () { - let {getByRole} = render( - - - Test - - Foo - Bar - - - Baz - - - - - Test 1 - Foo 1 - Bar 1 - Baz 1 - - - Test 2 - Foo 2 - Bar 2 - Baz 2 - - - - ); - - let grid = getByRole('grid'); - expect(grid).toBeVisible(); - expect(grid).toHaveAttribute('aria-multiselectable', 'true'); - expect(grid).toHaveAttribute('aria-rowcount', '4'); - expect(grid).toHaveAttribute('aria-colcount', '5'); - - let rowgroups = within(grid).getAllByRole('rowgroup'); - expect(rowgroups).toHaveLength(2); - - let headerRows = within(rowgroups[0]).getAllByRole('row'); - expect(headerRows).toHaveLength(2); - expect(headerRows[0]).toHaveAttribute('aria-rowindex', '1'); - expect(headerRows[1]).toHaveAttribute('aria-rowindex', '2'); - - let headers = within(headerRows[0]).getAllByRole('columnheader'); - let placeholderCells = within(headerRows[0]).getAllByRole('gridcell'); - expect(headers).toHaveLength(2); - expect(placeholderCells).toHaveLength(1); - - expect(placeholderCells[0]).toHaveTextContent(''); - expect(placeholderCells[0]).toHaveAttribute('aria-colspan', '2'); - expect(placeholderCells[0]).toHaveAttribute('aria-colindex', '1'); - - expect(headers[0]).toHaveTextContent('Group 1'); - expect(headers[0]).toHaveAttribute('aria-colspan', '2'); - expect(headers[0]).toHaveAttribute('aria-colindex', '3'); - expect(headers[1]).toHaveTextContent('Group 2'); - expect(headers[1]).toHaveAttribute('aria-colindex', '5'); - - headers = within(headerRows[1]).getAllByRole('columnheader'); - expect(headers).toHaveLength(5); - expect(headers[0]).toHaveAttribute('aria-colindex', '1'); - expect(headers[1]).toHaveAttribute('aria-colindex', '2'); - expect(headers[2]).toHaveAttribute('aria-colindex', '3'); - expect(headers[3]).toHaveAttribute('aria-colindex', '4'); - expect(headers[4]).toHaveAttribute('aria-colindex', '5'); - - let checkbox = within(headers[0]).getByRole('checkbox'); - expect(checkbox).toHaveAttribute('aria-label', 'Select All'); - - expect(headers[1]).toHaveTextContent('Test'); - expect(headers[2]).toHaveTextContent('Foo'); - expect(headers[3]).toHaveTextContent('Bar'); - expect(headers[4]).toHaveTextContent('Baz'); - - let rows = within(rowgroups[1]).getAllByRole('row'); - expect(rows).toHaveLength(2); - - let rowheader = within(rows[0]).getByRole('rowheader'); - let cellSpan = within(rowheader).getByText('Test 1'); - expect(rowheader).toHaveTextContent('Test 1'); - - expect(rows[0]).toHaveAttribute('aria-selected', 'false'); - expect(rows[0]).toHaveAttribute('aria-labelledby', cellSpan.id); - expect(rows[0]).toHaveAttribute('aria-rowindex', '3'); - - checkbox = within(rows[0]).getByRole('checkbox'); - expect(checkbox).toHaveAttribute('aria-label', 'Select'); - expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${cellSpan.id}`); - - rowheader = within(rows[1]).getByRole('rowheader'); - cellSpan = within(rowheader).getByText('Test 2'); - expect(rowheader).toHaveTextContent('Test 2'); - - expect(rows[1]).toHaveAttribute('aria-selected', 'false'); - expect(rows[1]).toHaveAttribute('aria-labelledby', cellSpan.id); - expect(rows[1]).toHaveAttribute('aria-rowindex', '4'); - - - checkbox = within(rows[1]).getByRole('checkbox'); - expect(checkbox).toHaveAttribute('aria-label', 'Select'); - expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${cellSpan.id}`); - - let cells = within(rowgroups[1]).getAllByRole('gridcell'); - expect(cells).toHaveLength(8); - }); - - it('renders a dynamic table with nested columns', function () { - let {getByRole} = render( - - - {column => - {column.name} - } - - - {item => - ( - {key => {item[key]}} - ) - } - - - ); - - let grid = getByRole('grid'); - expect(grid).toBeVisible(); - expect(grid).toHaveAttribute('aria-multiselectable', 'true'); - expect(grid).toHaveAttribute('aria-rowcount', '5'); - expect(grid).toHaveAttribute('aria-colcount', '6'); - - let rowgroups = within(grid).getAllByRole('rowgroup'); - expect(rowgroups).toHaveLength(2); - - let headerRows = within(rowgroups[0]).getAllByRole('row'); - expect(headerRows).toHaveLength(3); - expect(headerRows[0]).toHaveAttribute('aria-rowindex', '1'); - expect(headerRows[1]).toHaveAttribute('aria-rowindex', '2'); - expect(headerRows[2]).toHaveAttribute('aria-rowindex', '3'); - - let headers = within(headerRows[0]).getAllByRole('columnheader'); - let placeholderCells = within(headerRows[0]).getAllByRole('gridcell'); - expect(headers).toHaveLength(1); - expect(placeholderCells).toHaveLength(1); - - expect(placeholderCells[0]).toHaveTextContent(''); - expect(placeholderCells[0]).toHaveAttribute('aria-colspan', '2'); - expect(placeholderCells[0]).toHaveAttribute('aria-colindex', '1'); - expect(headers[0]).toHaveTextContent('Tiered One Header'); - expect(headers[0]).toHaveAttribute('aria-colspan', '4'); - expect(headers[0]).toHaveAttribute('aria-colindex', '3'); - - headers = within(headerRows[1]).getAllByRole('columnheader'); - placeholderCells = within(headerRows[1]).getAllByRole('gridcell'); - expect(headers).toHaveLength(2); - expect(placeholderCells).toHaveLength(2); - - expect(placeholderCells[0]).toHaveTextContent(''); - expect(placeholderCells[0]).toHaveAttribute('aria-colspan', '2'); - expect(placeholderCells[0]).toHaveAttribute('aria-colindex', '1'); - expect(headers[0]).toHaveTextContent('Tier Two Header A'); - expect(headers[0]).toHaveAttribute('aria-colspan', '2'); - expect(headers[0]).toHaveAttribute('aria-colindex', '3'); - expect(placeholderCells[1]).toHaveTextContent(''); - expect(placeholderCells[1]).toHaveAttribute('aria-colindex', '5'); - expect(headers[1]).toHaveTextContent('Tier Two Header B'); - expect(headers[1]).toHaveAttribute('aria-colindex', '6'); - - headers = within(headerRows[2]).getAllByRole('columnheader'); - expect(headers).toHaveLength(6); - - let checkbox = within(headers[0]).getByRole('checkbox'); - expect(checkbox).toHaveAttribute('aria-label', 'Select All'); - - expect(headers[1]).toHaveTextContent('Test'); - expect(headers[2]).toHaveTextContent('Foo'); - expect(headers[3]).toHaveTextContent('Bar'); - expect(headers[4]).toHaveTextContent('Yay'); - expect(headers[5]).toHaveTextContent('Baz'); - - let rows = within(rowgroups[1]).getAllByRole('row'); - expect(rows).toHaveLength(2); - - let rowheader = within(rows[0]).getByRole('rowheader'); - let cellSpan = within(rowheader).getByText('Test 1'); - expect(rowheader).toHaveTextContent('Test 1'); - - expect(rows[0]).toHaveAttribute('aria-selected', 'false'); - expect(rows[0]).toHaveAttribute('aria-labelledby', cellSpan.id); - expect(rows[0]).toHaveAttribute('aria-rowindex', '4'); - - checkbox = within(rows[0]).getByRole('checkbox'); - expect(checkbox).toHaveAttribute('aria-label', 'Select'); - expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${cellSpan.id}`); - - rowheader = within(rows[1]).getByRole('rowheader'); - cellSpan = within(rowheader).getByText('Test 2'); - expect(rowheader).toHaveTextContent('Test 2'); - - expect(rows[1]).toHaveAttribute('aria-selected', 'false'); - expect(rows[1]).toHaveAttribute('aria-labelledby', cellSpan.id); - expect(rows[1]).toHaveAttribute('aria-rowindex', '5'); - - - checkbox = within(rows[1]).getByRole('checkbox'); - expect(checkbox).toHaveAttribute('aria-label', 'Select'); - expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${cellSpan.id}`); - - let cells = within(rowgroups[1]).getAllByRole('gridcell'); - expect(cells).toHaveLength(10); - }); - - it('renders a table with multiple row headers', function () { - let {getByRole} = render( - - - First Name - Last Name - Birthday - - - - Sam - Smith - May 3 - - - Julia - Jones - February 10 - - - - ); - - let grid = getByRole('grid'); - let rowgroups = within(grid).getAllByRole('rowgroup'); - let rows = within(rowgroups[1]).getAllByRole('row'); - - let rowheaders = within(rows[0]).getAllByRole('rowheader'); - expect(rowheaders).toHaveLength(2); - expect(rowheaders[0]).toHaveTextContent('Sam'); - expect(rowheaders[1]).toHaveTextContent('Smith'); - let firstCellSpan = within(rowheaders[0]).getByText('Sam'); - let secondCellSpan = within(rowheaders[1]).getByText('Smith'); - - expect(rows[0]).toHaveAttribute('aria-labelledby', `${firstCellSpan.id} ${secondCellSpan.id}`); - - let checkbox = within(rows[0]).getByRole('checkbox'); - expect(checkbox).toHaveAttribute('aria-label', 'Select'); - expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${firstCellSpan.id} ${secondCellSpan.id}`); - - rowheaders = within(rows[1]).getAllByRole('rowheader'); - expect(rowheaders).toHaveLength(2); - expect(rowheaders[0]).toHaveTextContent('Julia'); - expect(rowheaders[1]).toHaveTextContent('Jones'); - firstCellSpan = within(rowheaders[0]).getByText('Julia'); - secondCellSpan = within(rowheaders[1]).getByText('Jones'); - - expect(rows[1]).toHaveAttribute('aria-labelledby', `${firstCellSpan.id} ${secondCellSpan.id}`); - - checkbox = within(rows[1]).getByRole('checkbox'); - expect(checkbox).toHaveAttribute('aria-label', 'Select'); - expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${firstCellSpan.id} ${secondCellSpan.id}`); - }); - - describe('keyboard focus', function () { - // locale is being set here, since we can't nest them, use original render function - let renderTable = (locale = 'en-US', props = {}) => { - let tree = renderComponent( - - - - {column => {column.name}} - - - {item => - ( - {key => {item[key]}} - ) - } - - - - ); - act(() => {jest.runAllTimers();}); - return tree; - }; - - // locale is being set here, since we can't nest them, use original render function - let renderNested = (locale = 'en-US') => { - let tree = renderComponent( - - - - {column => - {column.name} - } - - - {item => - ( - {key => {item[key]}} - ) - } - - - - ); - act(() => {jest.runAllTimers();}); - return tree; - }; - - let renderMany = () => render( - - - {column => - {column.name} - } - - - {item => - ( - {key => {item[key]}} - ) - } - - - ); - - let renderManyColumns = () => render( - - - {column => - {column.name} - } - - - {item => - ( - {key => {item.foo + ' ' + key}} - ) - } - - - ); - - describe('ArrowRight', function () { - it('should move focus to the next cell in a row with ArrowRight', async function () { - let tree = renderTable(); - focusCell(tree, 'Bar 1'); - await user.keyboard('{ArrowRight}'); - expect(document.activeElement).toBe(getCell(tree, 'Baz 1')); - }); - - it('should move focus to the previous cell in a row with ArrowRight in RTL', function () { - let tree = renderTable('ar-AE'); - focusCell(tree, 'Bar 1'); - moveFocus('ArrowRight'); - expect(document.activeElement).toBe(getCell(tree, 'Foo 1')); - }); - - it('should move focus to the row when on the last cell with ArrowRight', function () { - let tree = renderTable(); - focusCell(tree, 'Baz 1'); - moveFocus('ArrowRight'); - expect(document.activeElement).toBe(tree.getAllByRole('row')[1]); - }); - - it('should move focus to the row when on the first cell with ArrowRight in RTL', function () { - let tree = renderTable('ar-AE'); - focusCell(tree, 'Foo 1'); - moveFocus('ArrowRight'); - expect(document.activeElement).toBe(tree.getAllByRole('row')[1]); - }); - - it('should move focus from the row to the first cell with ArrowRight', function () { - let tree = renderTable(); - act(() => {tree.getAllByRole('row')[1].focus();}); - moveFocus('ArrowRight'); - expect(document.activeElement).toBe(getCell(tree, 'Foo 1')); - }); - - it('should move focus from the row to the last cell with ArrowRight in RTL', function () { - let tree = renderTable('ar-AE'); - act(() => {tree.getAllByRole('row')[1].focus();}); - moveFocus('ArrowRight'); - expect(document.activeElement).toBe(getCell(tree, 'Baz 1')); - }); - - it('should move to the next column header in a row with ArrowRight', function () { - let tree = renderTable(); - focusCell(tree, 'Bar'); - moveFocus('ArrowRight'); - expect(document.activeElement).toBe(getCell(tree, 'Baz')); - }); - - it('should move to the previous column header in a row with ArrowRight in RTL', function () { - let tree = renderTable('ar-AE'); - focusCell(tree, 'Bar'); - moveFocus('ArrowRight'); - expect(document.activeElement).toBe(getCell(tree, 'Foo')); - }); - - it('should move to the next nested column header in a row with ArrowRight', function () { - let tree = renderNested(); - focusCell(tree, 'Tier Two Header A'); - moveFocus('ArrowRight'); - expect(document.activeElement).toBe(getCell(tree, 'Tier Two Header B')); - }); - - it('should move to the previous nested column header in a row with ArrowRight in RTL', function () { - let tree = renderNested('ar-AE'); - focusCell(tree, 'Tier Two Header B'); - moveFocus('ArrowRight'); - expect(document.activeElement).toBe(getCell(tree, 'Tier Two Header A')); - }); - - it('should move to the first column header when focus is on the last column with ArrowRight', function () { - let tree = renderTable(); - focusCell(tree, 'Baz'); - moveFocus('ArrowRight'); - expect(document.activeElement).toBe(getCell(tree, 'Foo')); - }); - - it('should move to the last column header when focus is on the first column with ArrowRight in RTL', function () { - let tree = renderTable('ar-AE'); - focusCell(tree, 'Foo'); - moveFocus('ArrowRight'); - expect(document.activeElement).toBe(getCell(tree, 'Baz')); - }); - - it('should allow the user to focus disabled cells', function () { - let tree = renderTable('en-US', {disabledKeys: ['Foo 1']}); - focusCell(tree, 'Bar 1'); - moveFocus('ArrowRight'); - expect(document.activeElement).toBe(getCell(tree, 'Baz 1')); - }); - }); - - describe('ArrowLeft', function () { - it('should move focus to the previous cell in a row with ArrowLeft', function () { - let tree = renderTable(); - focusCell(tree, 'Bar 1'); - moveFocus('ArrowLeft'); - expect(document.activeElement).toBe(getCell(tree, 'Foo 1')); - }); - - it('should move focus to the next cell in a row with ArrowRight in RTL', function () { - let tree = renderTable('ar-AE'); - focusCell(tree, 'Bar 1'); - moveFocus('ArrowLeft'); - expect(document.activeElement).toBe(getCell(tree, 'Baz 1')); - }); - - it('should move focus to the row when on the first cell with ArrowLeft', function () { - let tree = renderTable(); - focusCell(tree, 'Foo 1'); - moveFocus('ArrowLeft'); - expect(document.activeElement).toBe(tree.getAllByRole('row')[1]); - }); - - it('should move focus to the row when on the last cell with ArrowLeft in RTL', function () { - let tree = renderTable('ar-AE'); - focusCell(tree, 'Baz 1'); - moveFocus('ArrowLeft'); - expect(document.activeElement).toBe(tree.getAllByRole('row')[1]); - }); - - it('should move focus from the row to the last cell with ArrowLeft', function () { - let tree = renderTable(); - act(() => {tree.getAllByRole('row')[1].focus();}); - moveFocus('ArrowLeft'); - expect(document.activeElement).toBe(getCell(tree, 'Baz 1')); - }); - - it('should move focus from the row to the first cell with ArrowLeft in RTL', function () { - let tree = renderTable('ar-AE'); - act(() => {tree.getAllByRole('row')[1].focus();}); - moveFocus('ArrowLeft'); - expect(document.activeElement).toBe(getCell(tree, 'Foo 1')); - }); - - it('should move to the previous column header in a row with ArrowLeft', function () { - let tree = renderTable(); - focusCell(tree, 'Bar'); - moveFocus('ArrowLeft'); - expect(document.activeElement).toBe(getCell(tree, 'Foo')); - }); - - it('should move to the next column header in a row with ArrowLeft in RTL', function () { - let tree = renderTable('ar-AE'); - focusCell(tree, 'Bar'); - moveFocus('ArrowLeft'); - expect(document.activeElement).toBe(getCell(tree, 'Baz')); - }); - - it('should move to the previous nested column header in a row with ArrowLeft', function () { - let tree = renderNested(); - focusCell(tree, 'Tier Two Header B'); - moveFocus('ArrowLeft'); - expect(document.activeElement).toBe(getCell(tree, 'Tier Two Header A')); - }); - - it('should move to the next nested column header in a row with ArrowLeft in RTL', function () { - let tree = renderNested('ar-AE'); - focusCell(tree, 'Tier Two Header A'); - moveFocus('ArrowLeft'); - expect(document.activeElement).toBe(getCell(tree, 'Tier Two Header B')); - }); - - it('should move to the last column header when focus is on the first column with ArrowLeft', function () { - let tree = renderTable(); - focusCell(tree, 'Foo'); - moveFocus('ArrowLeft'); - expect(document.activeElement).toBe(getCell(tree, 'Baz')); - }); - - it('should move to the first column header when focus is on the last column with ArrowLeft in RTL', function () { - let tree = renderTable('ar-AE'); - focusCell(tree, 'Baz'); - moveFocus('ArrowLeft'); - expect(document.activeElement).toBe(getCell(tree, 'Foo')); - }); - - it('should allow the user to focus disabled cells', function () { - let tree = renderTable('en-US', {disabledKeys: ['Foo 1']}); - focusCell(tree, 'Bar 1'); - moveFocus('ArrowLeft'); - expect(document.activeElement).toBe(getCell(tree, 'Foo 1')); - }); - }); - - describe('ArrowUp', function () { - it('should move focus to the cell above with ArrowUp', function () { - let tree = renderTable(); - focusCell(tree, 'Bar 2'); - moveFocus('ArrowUp'); - expect(document.activeElement).toBe(getCell(tree, 'Bar 1')); - }); - - it('should move focus to the row above with ArrowUp', function () { - let tree = renderTable(); - act(() => {tree.getAllByRole('row')[2].focus();}); - moveFocus('ArrowUp'); - expect(document.activeElement).toBe(tree.getAllByRole('row')[1]); - }); - - it('should move focus to the column header above a cell in the first row with ArrowUp', function () { - let tree = renderTable(); - focusCell(tree, 'Bar 1'); - moveFocus('ArrowUp'); - expect(document.activeElement).toBe(getCell(tree, 'Bar')); - }); - - it('should move focus to the column header above the first row with ArrowUp', function () { - let tree = renderTable(); - act(() => {tree.getAllByRole('row')[1].focus();}); - moveFocus('ArrowUp'); - expect(document.activeElement).toBe(getCell(tree, 'Foo')); - }); - - it('should move focus to the parent column header with ArrowUp', function () { - let tree = renderNested(); - focusCell(tree, 'Bar'); - moveFocus('ArrowUp'); - expect(document.activeElement).toBe(getCell(tree, 'Tier Two Header A')); - moveFocus('ArrowUp'); - expect(document.activeElement).toBe(getCell(tree, 'Tiered One Header')); - // do nothing when at the top - moveFocus('ArrowUp'); - expect(document.activeElement).toBe(getCell(tree, 'Tiered One Header')); - }); - - it('should allow the user to focus disabled rows', function () { - let tree = renderTable('en-US', {disabledKeys: ['Foo 1']}); - focusCell(tree, 'Bar 2'); - moveFocus('ArrowUp'); - expect(document.activeElement).toBe(getCell(tree, 'Bar 1')); - }); - }); - - describe('ArrowDown', function () { - it('should move focus to the cell below with ArrowDown', function () { - let tree = renderTable(); - focusCell(tree, 'Bar 1'); - moveFocus('ArrowDown'); - expect(document.activeElement).toBe(getCell(tree, 'Bar 2')); - }); - - it('should move focus to the row below with ArrowDown', function () { - let tree = renderTable(); - act(() => {tree.getAllByRole('row')[1].focus();}); - moveFocus('ArrowDown'); - expect(document.activeElement).toBe(tree.getAllByRole('row')[2]); - }); - - it('should move focus to the child column header with ArrowDown', function () { - let tree = renderNested(); - focusCell(tree, 'Tiered One Header'); - moveFocus('ArrowDown'); - expect(document.activeElement).toBe(getCell(tree, 'Tier Two Header A')); - moveFocus('ArrowDown'); - expect(document.activeElement).toBe(getCell(tree, 'Foo')); - }); - - it('should move focus to the cell below a column header with ArrowDown', function () { - let tree = renderTable(); - focusCell(tree, 'Bar'); - moveFocus('ArrowDown'); - expect(document.activeElement).toBe(getCell(tree, 'Bar 1')); - }); - - it('should allow the user to focus disabled cells', function () { - let tree = renderTable('en-US', {disabledKeys: ['Foo 2']}); - focusCell(tree, 'Bar 1'); - moveFocus('ArrowDown'); - expect(document.activeElement).toBe(getCell(tree, 'Bar 2')); - }); - }); - - describe('Home', function () { - it('should focus the first cell in a row with Home', function () { - let tree = renderTable(); - focusCell(tree, 'Bar 1'); - moveFocus('Home'); - expect(document.activeElement).toBe(getCell(tree, 'Foo 1')); - }); - - it('should focus the first cell in the first row with ctrl + Home', function () { - let tree = renderTable(); - focusCell(tree, 'Bar 2'); - moveFocus('Home', {ctrlKey: true}); - expect(document.activeElement).toBe(getCell(tree, 'Foo 1')); - }); - - it('should focus the first row with Home', function () { - let tree = renderTable(); - act(() => {tree.getAllByRole('row')[2].focus();}); - moveFocus('Home'); - expect(document.activeElement).toBe(tree.getAllByRole('row')[1]); - }); - }); - - describe('End', function () { - it('should focus the last cell in a row with End', function () { - let tree = renderTable(); - focusCell(tree, 'Foo 1'); - moveFocus('End'); - expect(document.activeElement).toBe(getCell(tree, 'Baz 1')); - }); - - it('should focus the last cell in the last row with ctrl + End', function () { - let tree = renderTable(); - focusCell(tree, 'Bar 1'); - moveFocus('End', {ctrlKey: true}); - expect(document.activeElement).toBe(getCell(tree, 'Baz 2')); - }); - - it('should focus the last row with End', function () { - let tree = renderTable(); - act(() => {tree.getAllByRole('row')[1].focus();}); - moveFocus('End'); - expect(document.activeElement).toBe(tree.getAllByRole('row')[2]); - }); - }); - - describe('PageDown', function () { - it('should focus the cell a page below', function () { - let tree = renderMany(); - focusCell(tree, 'Foo 1'); - moveFocus('PageDown'); - expect(document.activeElement).toBe(getCell(tree, 'Foo 25')); - moveFocus('PageDown'); - expect(document.activeElement).toBe(getCell(tree, 'Foo 49')); - moveFocus('PageDown'); - expect(document.activeElement).toBe(getCell(tree, 'Foo 73')); - moveFocus('PageDown'); - expect(document.activeElement).toBe(getCell(tree, 'Foo 97')); - moveFocus('PageDown'); - expect(document.activeElement).toBe(getCell(tree, 'Foo 100')); - }); - - it('should focus the row a page below', function () { - let tree = renderMany(); - act(() => {tree.getAllByRole('row')[1].focus();}); - moveFocus('PageDown'); - expect(document.activeElement).toBe(tree.getByRole('row', {name: 'Foo 25'})); - moveFocus('PageDown'); - expect(document.activeElement).toBe(tree.getByRole('row', {name: 'Foo 49'})); - moveFocus('PageDown'); - expect(document.activeElement).toBe(tree.getByRole('row', {name: 'Foo 73'})); - moveFocus('PageDown'); - expect(document.activeElement).toBe(tree.getByRole('row', {name: 'Foo 97'})); - moveFocus('PageDown'); - expect(document.activeElement).toBe(tree.getByRole('row', {name: 'Foo 100'})); - }); - }); - - describe('PageUp', function () { - it('should focus the cell a page above', function () { - let tree = renderMany(); - focusCell(tree, 'Foo 25'); - moveFocus('PageUp'); - expect(document.activeElement).toBe(getCell(tree, 'Foo 1')); - focusCell(tree, 'Foo 12'); - moveFocus('PageUp'); - expect(document.activeElement).toBe(getCell(tree, 'Foo 1')); - }); - - it('should focus the row a page above', function () { - let tree = renderMany(); - act(() => {tree.getAllByRole('row')[25].focus();}); - moveFocus('PageUp'); - expect(document.activeElement).toBe(tree.getAllByRole('row')[1]); - act(() => {tree.getAllByRole('row')[12].focus();}); - moveFocus('PageUp'); - expect(document.activeElement).toBe(tree.getAllByRole('row')[1]); - }); - }); - - describe('type to select', function () { - let renderTypeSelect = () => render( - - - First Name - Last Name - Birthday - - - - Sam - Smith - May 3 - - - Julia - Jones - February 10 - - - John - Doe - December 12 - - - - ); - - it('focuses cell by typing letters in rapid succession', function () { - let tree = renderTypeSelect(); - focusCell(tree, 'Sam'); - - moveFocus('J'); - expect(document.activeElement).toBe(getCell(tree, 'Julia')); - - moveFocus('o'); - expect(document.activeElement).toBe(getCell(tree, 'Jones')); - - moveFocus('h'); - expect(document.activeElement).toBe(getCell(tree, 'John')); - }); - - it('matches against all row header cells', function () { - let tree = renderTypeSelect(); - focusCell(tree, 'Sam'); - - moveFocus('D'); - expect(document.activeElement).toBe(getCell(tree, 'Doe')); - }); - - it('non row header columns don\'t match', function () { - let tree = renderTypeSelect(); - focusCell(tree, 'Sam'); - - moveFocus('F'); - expect(document.activeElement).toBe(getCell(tree, 'Sam')); - }); - - it('focuses row by typing letters in rapid succession', function () { - let tree = renderTypeSelect(); - act(() => {tree.getAllByRole('row')[1].focus();}); - - moveFocus('J'); - expect(document.activeElement).toBe(tree.getAllByRole('row')[2]); - - moveFocus('o'); - expect(document.activeElement).toBe(tree.getAllByRole('row')[2]); - - moveFocus('h'); - expect(document.activeElement).toBe(tree.getAllByRole('row')[3]); - }); - - it('matches row against all row header cells', function () { - let tree = renderTypeSelect(); - act(() => {tree.getAllByRole('row')[1].focus();}); - - moveFocus('D'); - expect(document.activeElement).toBe(tree.getAllByRole('row')[3]); - }); - - it('resets the search text after a timeout', function () { - let tree = renderTypeSelect(); - focusCell(tree, 'Sam'); - - moveFocus('J'); - expect(document.activeElement).toBe(getCell(tree, 'Julia')); - - act(() => {jest.runAllTimers();}); - - moveFocus('J'); - expect(document.activeElement).toBe(getCell(tree, 'Julia')); - }); - - it('wraps around when reaching the end of the collection', function () { - let tree = renderTypeSelect(); - focusCell(tree, 'Sam'); - - moveFocus('J'); - expect(document.activeElement).toBe(getCell(tree, 'Julia')); - - moveFocus('o'); - expect(document.activeElement).toBe(getCell(tree, 'Jones')); - - moveFocus('h'); - expect(document.activeElement).toBe(getCell(tree, 'John')); - - act(() => {jest.runAllTimers();}); - - moveFocus('J'); - expect(document.activeElement).toBe(getCell(tree, 'John')); - - moveFocus('u'); - expect(document.activeElement).toBe(getCell(tree, 'Julia')); - }); - - it('wraps around when no items past the current one match', function () { - let tree = renderTypeSelect(); - focusCell(tree, 'Sam'); - - moveFocus('J'); - expect(document.activeElement).toBe(getCell(tree, 'Julia')); - - act(() => {jest.runAllTimers();}); - - moveFocus('S'); - expect(document.activeElement).toBe(getCell(tree, 'Sam')); - }); - - describe('type ahead with dialog triggers', function () { - beforeEach(function () { - offsetHeight.mockRestore(); - offsetHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get') - .mockImplementationOnce(() => 20) - .mockImplementation(() => 100); - }); - afterEach(function () { - offsetHeight.mockRestore(); - offsetHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 1000); - }); - it('does not pick up typeahead from a dialog', async function () { - offsetHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get') - .mockImplementationOnce(() => 20) - .mockImplementation(() => 100); - let tree = render(); - let trigger = tree.getAllByRole('button')[0]; - await user.click(trigger); - act(() => { - jest.runAllTimers(); - }); - let textfield = tree.getByLabelText('Enter a J'); - act(() => {textfield.focus();}); - fireEvent.keyDown(textfield, {key: 'J'}); - fireEvent.keyUp(textfield, {key: 'J'}); - act(() => { - jest.runAllTimers(); - }); - expect(document.activeElement).toBe(textfield); - fireEvent.keyDown(document.activeElement, {key: 'Escape'}); - fireEvent.keyUp(document.activeElement, {key: 'Escape'}); - act(() => { - jest.runAllTimers(); - }); - }); - }); - }); - - describe('focus marshalling', function () { - let renderFocusable = () => render( - <> - - - - Foo - Bar - baz - - - - - Google - Baz 1 - - - - Yahoo - Baz 2 - - - - - - ); - - let renderWithPicker = () => render( - <> - - - Foo - Bar - baz - - - - - - - Yahoo - Google - DuckDuckGo - - - Baz 1 - - - - - ); - - it('should retain focus on the pressed child', async function () { - let tree = renderFocusable(); - let switchToPress = tree.getAllByRole('switch')[2]; - await user.click(switchToPress); - expect(document.activeElement).toBe(switchToPress); - }); - - it('should marshall focus to the focusable element inside a cell', function () { - let tree = renderFocusable(); - focusCell(tree, 'Baz 1'); - moveFocus('ArrowLeft'); - expect(document.activeElement).toBe(tree.getAllByRole('link')[0]); - - moveFocus('ArrowLeft'); - expect(document.activeElement).toBe(tree.getAllByRole('switch')[0]); - - moveFocus('ArrowDown'); - expect(document.activeElement).toBe(tree.getAllByRole('switch')[1]); - - moveFocus('ArrowRight'); - expect(document.activeElement).toBe(tree.getAllByRole('switch')[2]); - - moveFocus('ArrowRight'); - expect(document.activeElement).toBe(tree.getAllByRole('link')[1]); - - moveFocus('ArrowLeft'); - expect(document.activeElement).toBe(tree.getAllByRole('switch')[2]); - - moveFocus('ArrowLeft'); - expect(document.activeElement).toBe(tree.getAllByRole('switch')[1]); - - moveFocus('ArrowLeft'); - expect(document.activeElement).toBe(tree.getAllByRole('checkbox')[2]); - - moveFocus('ArrowUp'); - expect(document.activeElement).toBe(tree.getAllByRole('checkbox')[1]); - - moveFocus('ArrowUp'); - expect(document.activeElement).toBe(tree.getAllByRole('checkbox')[0]); - }); - - it('should support keyboard navigation after pressing focusable element inside a cell', async function () { - let tree = renderFocusable(); - await user.click(tree.getAllByRole('switch')[0]); - expect(document.activeElement).toBe(tree.getAllByRole('switch')[0]); - - moveFocus('ArrowDown'); - expect(document.activeElement).toBe(tree.getAllByRole('switch')[1]); - }); - - it('should move focus to the first row when tabbing into the table from the start', function () { - let tree = renderFocusable(); - - let table = tree.getByRole('grid'); - expect(table).toHaveAttribute('tabIndex', '0'); - - let before = tree.getByTestId('before'); - act(() => before.focus()); - - // Simulate tabbing to the first "tabbable" item inside the table - fireEvent.keyDown(before, {key: 'Tab'}); - act(() => {within(table).getAllByRole('switch')[0].focus();}); - fireEvent.keyUp(before, {key: 'Tab'}); - - expect(document.activeElement).toBe(within(table).getAllByRole('row')[1]); - }); - - it('should move focus to the last row when tabbing into the table from the end', function () { - let tree = renderFocusable(); - - let table = tree.getByRole('grid'); - expect(table).toHaveAttribute('tabIndex', '0'); - - let after = tree.getByTestId('after'); - act(() => after.focus()); - - // Simulate tabbing to the last "tabbable" item inside the table - act(() => { - fireEvent.keyDown(after, {key: 'Tab', shiftKey: true}); - within(table).getAllByRole('link')[1].focus(); - fireEvent.keyUp(after, {key: 'Tab', shiftKey: true}); - }); - - expect(document.activeElement).toBe(within(table).getAllByRole('row')[2]); - }); - - it('should move focus to the last focused cell when tabbing into the table from the start', function () { - let tree = renderFocusable(); - - let table = tree.getByRole('grid'); - expect(table).toHaveAttribute('tabIndex', '0'); - - let baz1 = getCell(tree, 'Baz 1'); - act(() => baz1.focus()); - - expect(table).toHaveAttribute('tabIndex', '-1'); - - let before = tree.getByTestId('before'); - act(() => before.focus()); - - // Simulate tabbing to the first "tabbable" item inside the table - fireEvent.keyDown(before, {key: 'Tab'}); - act(() => {within(table).getAllByRole('switch')[0].focus();}); - fireEvent.keyUp(before, {key: 'Tab'}); - - expect(document.activeElement).toBe(baz1); - }); - - it('should move focus to the last focused cell when tabbing into the table from the end', function () { - let tree = renderFocusable(); - - let table = tree.getByRole('grid'); - expect(table).toHaveAttribute('tabIndex', '0'); - - let baz1 = getCell(tree, 'Baz 1'); - act(() => baz1.focus()); - - expect(table).toHaveAttribute('tabIndex', '-1'); - - let after = tree.getByTestId('after'); - act(() => after.focus()); - - // Simulate tabbing to the last "tabbable" item inside the table - fireEvent.keyDown(after, {key: 'Tab'}); - act(() => {within(table).getAllByRole('link')[1].focus();}); - fireEvent.keyUp(after, {key: 'Tab'}); - - expect(document.activeElement).toBe(baz1); - }); - - it('should not trap focus when navigating through a cell with a picker using the arrow keys', function () { - let tree = renderWithPicker(); - focusCell(tree, 'Baz 1'); - moveFocus('ArrowLeft'); - expect(document.activeElement).toBe(tree.getByRole('button')); - moveFocus('ArrowLeft'); - expect(document.activeElement).toBe(tree.getByRole('switch')); - moveFocus('ArrowRight'); - expect(document.activeElement).toBe(tree.getByRole('button')); - moveFocus('ArrowRight'); - expect(document.activeElement).toBe(tree.getAllByRole('gridcell')[1]); - }); - - it('should move focus after the table when tabbing', async function () { - let tree = renderFocusable(); - - await user.click(tree.getAllByRole('switch')[1]); - expect(document.activeElement).toBe(tree.getAllByRole('switch')[1]); - - // Simulate tabbing within the table - fireEvent.keyDown(document.activeElement, {key: 'Tab'}); - let walker = getFocusableTreeWalker(document.body, {tabbable: true}); - walker.currentNode = document.activeElement; - act(() => {walker.nextNode().focus();}); - fireEvent.keyUp(document.activeElement, {key: 'Tab'}); - - let after = tree.getByTestId('after'); - expect(document.activeElement).toBe(after); - }); - - it('should move focus after the table when tabbing from the last row', function () { - let tree = renderFocusable(); - - act(() => tree.getAllByRole('row')[2].focus()); - expect(document.activeElement).toBe(tree.getAllByRole('row')[2]); - - // Simulate tabbing within the table - act(() => { - fireEvent.keyDown(document.activeElement, {key: 'Tab'}); - let walker = getFocusableTreeWalker(document.body, {tabbable: true}); - walker.currentNode = document.activeElement; - walker.nextNode().focus(); - fireEvent.keyUp(document.activeElement, {key: 'Tab'}); - }); - - let after = tree.getByTestId('after'); - expect(document.activeElement).toBe(after); - }); - - it('should move focus before the table when shift tabbing', async function () { - let tree = renderFocusable(); - - await user.click(tree.getAllByRole('switch')[1]); - expect(document.activeElement).toBe(tree.getAllByRole('switch')[1]); - - // Simulate shift tabbing within the table - fireEvent.keyDown(document.activeElement, {key: 'Tab', shiftKey: true}); - let walker = getFocusableTreeWalker(document.body, {tabbable: true}); - walker.currentNode = document.activeElement; - act(() => {walker.previousNode().focus();}); - fireEvent.keyUp(document.activeElement, {key: 'Tab', shiftKey: true}); - - let before = tree.getByTestId('before'); - expect(document.activeElement).toBe(before); - }); - - it('should send focus to the appropriate key below if the focused row is removed', async function () { - let tree = render(); - - let rows = tree.getAllByRole('row'); - await user.tab(); - expect(document.activeElement).toBe(rows[1]); - - fireEvent.keyDown(document.activeElement, {key: 'ArrowLeft'}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowLeft'}); - expect(document.activeElement).toBe(within(rows[1]).getByRole('button')); - - fireEvent.keyDown(document.activeElement, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement, {key: 'Enter'}); - act(() => {jest.runAllTimers();}); - - rows = tree.getAllByRole('row'); - expect(document.activeElement).toBe(within(rows[1]).getByRole('button')); - - fireEvent.keyDown(document.activeElement, {key: 'ArrowUp'}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowUp'}); - - rows = tree.getAllByRole('row'); - expect(document.activeElement).toBe(within(rows[0]).getAllByRole('columnheader')[4]); - }); - - it('should send focus to the appropriate key above if the focused last row is removed', async function () { - let tree = render(); - - let rows = tree.getAllByRole('row'); - await user.tab(); - expect(document.activeElement).toBe(rows[1]); - - fireEvent.keyDown(document.activeElement, {key: 'ArrowLeft'}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowLeft'}); - expect(document.activeElement).toBe(within(rows[1]).getByRole('button')); - - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); - expect(document.activeElement).toBe(within(rows[2]).getByRole('button')); - - fireEvent.keyDown(document.activeElement, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement, {key: 'Enter'}); - act(() => {jest.runAllTimers();}); - - rows = tree.getAllByRole('row'); - expect(document.activeElement).toBe(within(rows[1]).getByRole('button')); - - fireEvent.keyDown(document.activeElement, {key: 'ArrowUp'}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowUp'}); - - rows = tree.getAllByRole('row'); - expect(document.activeElement).toBe(within(rows[0]).getAllByRole('columnheader')[4]); - }); - - it('should send focus to the appropriate column and row if both the current row and column are removed', function () { - let itemsLocal = items; - let columnsLocal = columns; - let renderJSX = (props, items = itemsLocal, columns = columnsLocal) => ( - - - {column => {column.name}} - - - {item => - ( - {key => {item[key]}} - ) - } - - - ); - - let renderTable = (props, items = itemsLocal, columns = columnsLocal) => render(renderJSX(props, items, columns)); - - let tree = renderTable(); - focusCell(tree, 'Baz 1'); - - rerender(tree, renderJSX({}, [itemsLocal[1], itemsLocal[0]], columnsLocal.slice(0, 2))); - - expect(document.activeElement).toBe(tree.getAllByRole('row')[1], 'If column index with last focus is greater than the new number of columns, focus the row'); - - focusCell(tree, 'Bar 1'); - - rerender(tree, renderJSX({}, [itemsLocal[1]], columnsLocal.slice(0, 1))); - - expect(document.activeElement).toBe(tree.getAllByRole('row')[1], 'If column index with last focus is greater than the new number of columns, focus the row'); - - focusCell(tree, 'Foo 2'); - - rerender(tree, renderJSX({}, [itemsLocal[0], itemsLocal[0]], columnsLocal)); - - expect(document.activeElement).toBe(getCell(tree, 'Foo 1')); - }); - }); - - describe('scrolling', function () { - it('should scroll to a cell when it is focused', function () { - let tree = renderMany(); - let body = tree.getByRole('grid').childNodes[1]; - - focusCell(tree, 'Baz 25'); - expect(scrollIntoView).toHaveBeenLastCalledWith(body, document.activeElement); - }); - - it('should scroll to a cell when it is focused off screen', function () { - let tree = renderManyColumns(); - let body = tree.getByRole('grid').childNodes[1]; - - let cell = getCell(tree, 'Foo 5 5'); - act(() => cell.focus()); - expect(document.activeElement).toBe(cell); - expect(body.scrollTop).toBe(0); - - // When scrolling the focused item out of view, focus should remain on the item, - // virtualizer keeps focused items from being reused - body.scrollTop = 1000; - body.scrollLeft = 1000; - fireEvent.scroll(body); - act(() => jest.runAllTimers()); - - expect(body.scrollTop).toBe(1000); - expect(document.activeElement).toBe(cell); - expect(tree.queryByText('Foo 5 5')).toBe(cell.firstElementChild); - - // Ensure we have the correct sticky cells in the right order. - let row = cell.closest('[role=row]'); - let cells = within(row).getAllByRole('gridcell'); - let rowHeaders = within(row).getAllByRole('rowheader'); - expect(cells).toHaveLength(17); - expect(rowHeaders).toHaveLength(1); - expect(cells[0]).toHaveAttribute('aria-colindex', '1'); // checkbox - expect(rowHeaders[0]).toHaveAttribute('aria-colindex', '2'); // rowheader - expect(cells[1]).toHaveAttribute('aria-colindex', '6'); // persisted - expect(cells[1]).toBe(cell); - expect(cells[2]).toHaveAttribute('aria-colindex', '14'); // first visible - - // Moving focus should scroll the new focused item into view - moveFocus('ArrowLeft'); - expect(document.activeElement).toBe(getCell(tree, 'Foo 5 4')); - expect(scrollIntoView).toHaveBeenLastCalledWith(body, document.activeElement); - }); - - it('should not scroll when a column header receives focus', function () { - let tree = renderMany(); - let body = tree.getByRole('grid').childNodes[1]; - let cell = getCell(tree, 'Baz 5'); - - focusCell(tree, 'Baz 5'); - - body.scrollTop = 1000; - fireEvent.scroll(body); - - expect(body.scrollTop).toBe(1000); - expect(document.activeElement).toBe(cell); - - focusCell(tree, 'Bar'); - expect(document.activeElement).toHaveAttribute('role', 'columnheader'); - expect(document.activeElement).toHaveTextContent('Bar'); - expect(scrollIntoView).toHaveBeenLastCalledWith(body, document.activeElement); - }); - }); - }); - - describe('selection', function () { - afterEach(() => { - act(() => jest.runAllTimers()); - }); - - let renderJSX = (props, items = manyItems) => ( - - - {column => {column.name}} - - - {item => - ( - {key => {item[key]}} - ) - } - - - ); - - let renderTable = (props, items = manyItems) => render(renderJSX(props, items)); - - let checkSelection = (onSelectionChange, selectedKeys) => { - expect(onSelectionChange).toHaveBeenCalledTimes(1); - expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(selectedKeys)); - }; - - let checkSelectAll = (tree, state = 'indeterminate') => { - let checkbox = tree.getByLabelText('Select All'); - if (state === 'indeterminate') { - expect(checkbox.indeterminate).toBe(true); - } else { - expect(checkbox.checked).toBe(state === 'checked'); - } - }; - - let checkRowSelection = (rows, selected) => { - for (let row of rows) { - expect(row).toHaveAttribute('aria-selected', '' + selected); - } - }; - - describe('row selection', function () { - it('should select a row from checkbox', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - let row = tree.getAllByRole('row')[1]; - expect(row).toHaveAttribute('aria-selected', 'false'); - await user.click(within(row).getByRole('checkbox')); - - checkSelection(onSelectionChange, ['Foo 1']); - expect(row).toHaveAttribute('aria-selected', 'true'); - checkSelectAll(tree); - }); - - it('should select a row by pressing on a cell', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - let row = tree.getAllByRole('row')[1]; - expect(row).toHaveAttribute('aria-selected', 'false'); - await user.click(getCell(tree, 'Baz 1')); - - checkSelection(onSelectionChange, ['Foo 1']); - expect(row).toHaveAttribute('aria-selected', 'true'); - checkSelectAll(tree); - }); - - it('should select a row by pressing the Space key on a row', function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - let row = tree.getAllByRole('row')[1]; - expect(row).toHaveAttribute('aria-selected', 'false'); - fireEvent.keyDown(row, {key: ' '}); - - checkSelection(onSelectionChange, ['Foo 1']); - expect(row).toHaveAttribute('aria-selected', 'true'); - checkSelectAll(tree); - }); - - it('should select a row by pressing the Enter key on a row', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - let row = tree.getAllByRole('row')[1]; - await user.tab(); - await user.keyboard('{ArrowRight}'); - await user.keyboard('{Enter}'); - - checkSelection(onSelectionChange, ['Foo 1']); - expect(row).toHaveAttribute('aria-selected', 'true'); - checkSelectAll(tree); - }); - - it('should select a row by pressing the Space key on a cell', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - let row = tree.getAllByRole('row')[1]; - expect(row).toHaveAttribute('aria-selected', 'false'); - await user.tab(); - await user.keyboard('{ArrowRight}{ArrowRight}'); - await user.keyboard(' '); - - checkSelection(onSelectionChange, ['Foo 1']); - expect(row).toHaveAttribute('aria-selected', 'true'); - checkSelectAll(tree); - }); - - it('should select a row by pressing the Enter key on a cell', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - let row = tree.getAllByRole('row')[1]; - expect(row).toHaveAttribute('aria-selected', 'false'); - await user.tab(); - await user.keyboard('{ArrowRight}'); - await user.keyboard('{Enter}'); - - checkSelection(onSelectionChange, ['Foo 1']); - expect(row).toHaveAttribute('aria-selected', 'true'); - checkSelectAll(tree); - }); - - it('should support selecting multiple with a pointer', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - checkSelectAll(tree, 'unchecked'); - - let rows = tree.getAllByRole('row'); - checkRowSelection(rows.slice(1), false); - await user.click(getCell(tree, 'Baz 1')); - - checkSelection(onSelectionChange, ['Foo 1']); - expect(rows[1]).toHaveAttribute('aria-selected', 'true'); - checkRowSelection(rows.slice(2), false); - checkSelectAll(tree, 'indeterminate'); - - onSelectionChange.mockReset(); - await user.click(getCell(tree, 'Baz 2')); - - checkSelection(onSelectionChange, ['Foo 1', 'Foo 2']); - expect(rows[1]).toHaveAttribute('aria-selected', 'true'); - expect(rows[2]).toHaveAttribute('aria-selected', 'true'); - checkRowSelection(rows.slice(3), false); - checkSelectAll(tree, 'indeterminate'); - - // Deselect - onSelectionChange.mockReset(); - await user.click(getCell(tree, 'Baz 2')); - - checkSelection(onSelectionChange, ['Foo 1']); - expect(rows[1]).toHaveAttribute('aria-selected', 'true'); - checkRowSelection(rows.slice(2), false); - checkSelectAll(tree, 'indeterminate'); - }); - - it('should support selecting multiple with the Space key', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - checkSelectAll(tree, 'unchecked'); - - let rows = tree.getAllByRole('row'); - checkRowSelection(rows.slice(1), false); - await user.tab(); - await user.keyboard('{ArrowRight}{ArrowRight}'); - await user.keyboard(' '); - - checkSelection(onSelectionChange, ['Foo 1']); - expect(rows[1]).toHaveAttribute('aria-selected', 'true'); - checkRowSelection(rows.slice(2), false); - checkSelectAll(tree, 'indeterminate'); - - onSelectionChange.mockReset(); - await user.keyboard('{ArrowDown}'); - await user.keyboard(' '); - - checkSelection(onSelectionChange, ['Foo 1', 'Foo 2']); - expect(rows[1]).toHaveAttribute('aria-selected', 'true'); - expect(rows[2]).toHaveAttribute('aria-selected', 'true'); - checkRowSelection(rows.slice(3), false); - checkSelectAll(tree, 'indeterminate'); - - // Deselect - onSelectionChange.mockReset(); - await user.keyboard(' '); - - checkSelection(onSelectionChange, ['Foo 1']); - expect(rows[1]).toHaveAttribute('aria-selected', 'true'); - checkRowSelection(rows.slice(2), false); - checkSelectAll(tree, 'indeterminate'); - }); - - it('should prevent Esc from clearing selection if escapeKeyBehavior is "none"', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange, selectionMode: 'multiple', escapeKeyBehavior: 'none'}); - let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')}); - tableTester.setInteractionType('keyboard'); - - - await tableTester.toggleRowSelection({row: 0}); - expect(tableTester.selectedRows).toHaveLength(1); - expect(onSelectionChange).toHaveBeenCalledTimes(1); - - await tableTester.toggleRowSelection({row: 1}); - expect(tableTester.selectedRows).toHaveLength(2); - expect(onSelectionChange).toHaveBeenCalledTimes(2); - - await user.keyboard('{Escape}'); - expect(tableTester.selectedRows).toHaveLength(2); - expect(onSelectionChange).toHaveBeenCalledTimes(2); - }); - - it('should not allow selection of a disabled row via checkbox click', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange, disabledKeys: ['Foo 1']}); - - let row = tree.getAllByRole('row')[1]; - expect(row).toHaveAttribute('aria-selected', 'false'); - await user.click(within(row).getByRole('checkbox')); - - expect(onSelectionChange).not.toHaveBeenCalled(); - expect(row).toHaveAttribute('aria-selected', 'false'); - - let checkbox = tree.getByLabelText('Select All'); - expect(checkbox.checked).toBeFalsy(); - }); - - it('should not allow selection of a disabled row by pressing on a cell', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange, disabledKeys: ['Foo 1']}); - - let row = tree.getAllByRole('row')[1]; - expect(row).toHaveAttribute('aria-selected', 'false'); - await user.click(getCell(tree, 'Baz 1')); - - expect(onSelectionChange).not.toHaveBeenCalled(); - expect(row).toHaveAttribute('aria-selected', 'false'); - - let checkbox = tree.getByLabelText('Select All'); - expect(checkbox.checked).toBeFalsy(); - }); - - it('should not allow the user to select a disabled row via keyboard', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange, disabledKeys: ['Foo 1']}); - - let row = tree.getAllByRole('row')[1]; - expect(row).toHaveAttribute('aria-selected', 'false'); - - await user.tab(); - await user.keyboard(' '); - await user.keyboard('{Enter}'); - - expect(onSelectionChange).not.toHaveBeenCalled(); - expect(row).toHaveAttribute('aria-selected', 'false'); - - let checkbox = tree.getByLabelText('Select All'); - expect(checkbox.checked).toBeFalsy(); - }); - - describe('Space key with focus on a link within a cell', () => { - it('should toggle selection and prevent scrolling of the table', async () => { - let tree = render( - - - {column => {column.name}} - - - {item => - ( - {key => {item[key]}} - ) - } - - - ); - - let row = tree.getAllByRole('row')[1]; - expect(row).toHaveAttribute('aria-selected', 'false'); - - let link = within(row).getAllByRole('link')[0]; - expect(link.textContent).toBe('Foo 1'); - - await user.tab(); - await user.keyboard('{ArrowRight}'); - await user.keyboard('{ArrowRight}'); - expect(document.activeElement).toBe(link); - await user.keyboard(' '); - act(() => {jest.runAllTimers();}); - - row = tree.getAllByRole('row')[1]; - expect(row).toHaveAttribute('aria-selected', 'true'); - - await user.keyboard(' '); - act(() => {jest.runAllTimers();}); - - row = tree.getAllByRole('row')[1]; - link = within(row).getAllByRole('link')[0]; - - expect(row).toHaveAttribute('aria-selected', 'false'); - expect(link.textContent).toBe('Foo 1'); - }); - }); - }); - - describe('range selection', function () { - it('should support selecting a range with a pointer', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - checkSelectAll(tree, 'unchecked'); - - let rows = tree.getAllByRole('row'); - checkRowSelection(rows.slice(1), false); - await user.click(getCell(tree, 'Baz 1')); - - onSelectionChange.mockReset(); - await user.keyboard('[ShiftLeft>]'); - await user.click(getCell(tree, 'Baz 20')); - await user.keyboard('[/ShiftLeft]'); - - checkSelection(onSelectionChange, [ - 'Foo 1', 'Foo 2', 'Foo 3', 'Foo 4', 'Foo 5', 'Foo 6', 'Foo 7', 'Foo 8', 'Foo 9', 'Foo 10', - 'Foo 11', 'Foo 12', 'Foo 13', 'Foo 14', 'Foo 15', 'Foo 16', 'Foo 17', 'Foo 18', 'Foo 19', 'Foo 20' - ]); - - checkRowSelection(rows.slice(1, 21), true); - checkRowSelection(rows.slice(21), false); - }); - - it('should anchor range selections with a pointer', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - checkSelectAll(tree, 'unchecked'); - - let rows = tree.getAllByRole('row'); - checkRowSelection(rows.slice(1), false); - await user.click(getCell(tree, 'Baz 10')); - - onSelectionChange.mockReset(); - await user.keyboard('[ShiftLeft>]'); - await user.click(getCell(tree, 'Baz 20')); - await user.keyboard('[/ShiftLeft]'); - - checkSelection(onSelectionChange, [ - 'Foo 10', 'Foo 11', 'Foo 12', 'Foo 13', 'Foo 14', 'Foo 15', - 'Foo 16', 'Foo 17', 'Foo 18', 'Foo 19', 'Foo 20' - ]); - - checkRowSelection(rows.slice(11, 21), true); - checkRowSelection(rows.slice(21), false); - - onSelectionChange.mockReset(); - await user.keyboard('[ShiftLeft>]'); - await user.click(getCell(tree, 'Baz 1')); - await user.keyboard('[/ShiftLeft]'); - - checkSelection(onSelectionChange, [ - 'Foo 1', 'Foo 2', 'Foo 3', 'Foo 4', 'Foo 5', - 'Foo 6', 'Foo 7', 'Foo 8', 'Foo 9', 'Foo 10' - ]); - - checkRowSelection(rows.slice(1, 11), true); - checkRowSelection(rows.slice(11), false); - }); - - it('should extend a selection with Shift + ArrowDown', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - checkSelectAll(tree, 'unchecked'); - - let rows = tree.getAllByRole('row'); - checkRowSelection(rows.slice(1), false); - await user.tab(); - await user.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}'); - await user.keyboard('{ArrowDown}'.repeat(9)); - await user.keyboard(' '); - - onSelectionChange.mockReset(); - await user.keyboard('{Shift>}{ArrowDown}{/Shift}'); - - checkSelection(onSelectionChange, ['Foo 10', 'Foo 11']); - checkRowSelection(rows.slice(1, 10), false); - checkRowSelection(rows.slice(11, 12), true); - checkRowSelection(rows.slice(12), false); - }); - - it('should extend a selection with Shift + ArrowUp', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - checkSelectAll(tree, 'unchecked'); - - let rows = tree.getAllByRole('row'); - checkRowSelection(rows.slice(1), false); - await user.tab(); - await user.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}'); - await user.keyboard('{ArrowDown}'.repeat(9)); - await user.keyboard(' '); - - onSelectionChange.mockReset(); - await user.keyboard('{Shift>}{ArrowUp}{/Shift}'); - - checkSelection(onSelectionChange, ['Foo 9', 'Foo 10']); - checkRowSelection(rows.slice(1, 9), false); - checkRowSelection(rows.slice(9, 10), true); - checkRowSelection(rows.slice(11), false); - }); - - it('should extend a selection with Ctrl + Shift + Home', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - checkSelectAll(tree, 'unchecked'); - - let rows = tree.getAllByRole('row'); - checkRowSelection(rows.slice(1), false); - await user.tab(); - await user.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}'); - await user.keyboard('{ArrowDown}'.repeat(9)); - await user.keyboard(' '); - - onSelectionChange.mockReset(); - await user.keyboard('{Shift>}{Control>}{Home}{/Control}{/Shift}'); - - checkSelection(onSelectionChange, [ - 'Foo 1', 'Foo 2', 'Foo 3', 'Foo 4', 'Foo 5', - 'Foo 6', 'Foo 7', 'Foo 8', 'Foo 9', 'Foo 10' - ]); - - checkRowSelection(rows.slice(1, 11), true); - checkRowSelection(rows.slice(11), false); - }); - - it('should extend a selection with Ctrl + Shift + End', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - checkSelectAll(tree, 'unchecked'); - - let rows = tree.getAllByRole('row'); - checkRowSelection(rows.slice(1), false); - await user.tab(); - await user.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}'); - await user.keyboard('{ArrowDown}'.repeat(9)); - await user.keyboard(' '); - - onSelectionChange.mockReset(); - await user.keyboard('{Shift>}{Control>}{End}{/Control}{/Shift}'); - - let expected = []; - for (let i = 10; i <= 100; i++) { - expected.push('Foo ' + i); - } - - checkSelection(onSelectionChange, expected); - }); - - it('should extend a selection with Shift + PageDown', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - checkSelectAll(tree, 'unchecked'); - - let rows = tree.getAllByRole('row'); - checkRowSelection(rows.slice(1), false); - await user.tab(); - await user.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}'); - await user.keyboard('{ArrowDown}'.repeat(9)); - await user.keyboard(' '); - - onSelectionChange.mockReset(); - await user.keyboard('{Shift>}{PageDown}{/Shift}'); - - let expected = []; - for (let i = 10; i <= 34; i++) { - expected.push('Foo ' + i); - } - - checkSelection(onSelectionChange, expected); - }); - - it('should extend a selection with Shift + PageUp', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - checkSelectAll(tree, 'unchecked'); - - let rows = tree.getAllByRole('row'); - checkRowSelection(rows.slice(1), false); - await user.tab(); - await user.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}'); - await user.keyboard('{ArrowDown}'.repeat(9)); - await user.keyboard(' '); - - onSelectionChange.mockReset(); - await user.keyboard('{Shift>}{PageUp}{/Shift}'); - - checkSelection(onSelectionChange, [ - 'Foo 1', 'Foo 2', 'Foo 3', 'Foo 4', 'Foo 5', - 'Foo 6', 'Foo 7', 'Foo 8', 'Foo 9', 'Foo 10' - ]); - - checkRowSelection(rows.slice(1, 11), true); - checkRowSelection(rows.slice(11), false); - }); - - it('should not include disabled rows within a range selection', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange, disabledKeys: ['Foo 3', 'Foo 16']}); - checkSelectAll(tree, 'unchecked'); - - let rows = tree.getAllByRole('row'); - checkRowSelection(rows.slice(1), false); - await user.click(getCell(tree, 'Baz 1')); - - onSelectionChange.mockReset(); - await user.keyboard('[ShiftLeft>]'); - await user.click(getCell(tree, 'Baz 20')); - await user.keyboard('[/ShiftLeft]'); - - checkSelection(onSelectionChange, [ - 'Foo 1', 'Foo 2', 'Foo 4', 'Foo 5', 'Foo 6', 'Foo 7', 'Foo 8', 'Foo 9', 'Foo 10', - 'Foo 11', 'Foo 12', 'Foo 13', 'Foo 14', 'Foo 15', 'Foo 17', 'Foo 18', 'Foo 19', 'Foo 20' - ]); - - checkRowSelection(rows.slice(1, 3), true); - checkRowSelection(rows.slice(3, 4), false); - checkRowSelection(rows.slice(4, 16), true); - checkRowSelection(rows.slice(16, 17), false); - checkRowSelection(rows.slice(17, 21), true); - }); - }); - - describe('select all', function () { - it('should support selecting all via the checkbox', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')}); - tableTester.setInteractionType('keyboard'); - - checkSelectAll(tree, 'unchecked'); - - let rows = tableTester.rows; - checkRowSelection(rows.slice(1), false); - expect(tableTester.selectedRows).toHaveLength(0); - - await tableTester.toggleSelectAll(); - expect(tableTester.selectedRows).toHaveLength(tableTester.rows.length); - expect(onSelectionChange).toHaveBeenCalledTimes(1); - expect(onSelectionChange.mock.calls[0][0]).toEqual('all'); - checkRowSelection(rows.slice(1), true); - checkSelectAll(tree, 'checked'); - }); - - it('should support selecting all via ctrl + A', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - checkSelectAll(tree, 'unchecked'); - - let rows = tree.getAllByRole('row'); - checkRowSelection(rows.slice(1), false); - - await user.tab(); - await user.keyboard('{ArrowRight}'); - await user.keyboard('{Control>}a{/Control}'); - - expect(onSelectionChange).toHaveBeenCalledTimes(1); - expect(onSelectionChange.mock.calls[0][0]).toEqual('all'); - checkRowSelection(rows.slice(1), true); - checkSelectAll(tree, 'checked'); - - await user.keyboard('{Control>}a{/Control}'); - - expect(onSelectionChange).toHaveBeenCalledTimes(1); - expect(onSelectionChange.mock.calls[0][0]).toEqual('all'); - checkRowSelection(rows.slice(1), true); - checkSelectAll(tree, 'checked'); - }); - - it('should deselect an item after selecting all', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - checkSelectAll(tree, 'unchecked'); - - let rows = tree.getAllByRole('row'); - checkRowSelection(rows.slice(1), false); - - await user.click(tree.getByLabelText('Select All')); - - onSelectionChange.mockReset(); - await user.click(rows[4]); - - let expected = []; - for (let i = 1; i <= 100; i++) { - if (i !== 4) { - expected.push('Foo ' + i); - } - } - - checkSelection(onSelectionChange, expected); - expect(rows[4]).toHaveAttribute('aria-selected', 'false'); - }); - - it('should shift click on an item after selecting all', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - checkSelectAll(tree, 'unchecked'); - - let rows = tree.getAllByRole('row'); - checkRowSelection(rows.slice(1), false); - - await user.click(tree.getByLabelText('Select All')); - - onSelectionChange.mockReset(); - await user.keyboard('[ShiftLeft>]'); - await user.click(rows[4]); - await user.keyboard('[/ShiftLeft]'); - - checkSelection(onSelectionChange, ['Foo 4']); - checkRowSelection(rows.slice(1, 4), false); - expect(rows[4]).toHaveAttribute('aria-selected', 'true'); - checkRowSelection(rows.slice(5), false); - }); - - it('should support clearing selection via checkbox', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - checkSelectAll(tree, 'unchecked'); - - let rows = tree.getAllByRole('row'); - checkRowSelection(rows.slice(1), false); - - await user.click(tree.getByLabelText('Select All')); - checkSelectAll(tree, 'checked'); - - onSelectionChange.mockReset(); - await user.click(tree.getByLabelText('Select All')); - - expect(onSelectionChange).toHaveBeenCalledTimes(1); - expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set()); - checkRowSelection(rows.slice(1), false); - checkSelectAll(tree, 'unchecked'); - }); - - it('should support clearing selection via Escape', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - checkSelectAll(tree, 'unchecked'); - - let rows = tree.getAllByRole('row'); - checkRowSelection(rows.slice(1), false); - await user.click(getCell(tree, 'Baz 1')); - checkSelectAll(tree, 'indeterminate'); - - onSelectionChange.mockReset(); - await user.keyboard('{ArrowLeft}'); - await user.keyboard('{Escape}'); - - expect(onSelectionChange).toHaveBeenCalledTimes(1); - expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set()); - checkRowSelection(rows.slice(1), false); - checkSelectAll(tree, 'unchecked'); - }); - - it('should only call onSelectionChange if there are selections to clear', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - checkSelectAll(tree, 'unchecked'); - await user.tab(); - await user.keyboard('{ArrowRight}'); - await user.keyboard('{Escape}'); - expect(onSelectionChange).not.toHaveBeenCalled(); - - await user.click(tree.getByLabelText('Select All')); - checkSelectAll(tree, 'checked'); - expect(onSelectionChange).toHaveBeenLastCalledWith('all'); - - onSelectionChange.mockReset(); - await user.keyboard('{ArrowDown}{ArrowRight}{ArrowRight}'); - await user.keyboard('{Escape}'); - expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set()); - }); - - it('should automatically select new items when select all is active', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - checkSelectAll(tree, 'unchecked'); - - let rows = tree.getAllByRole('row'); - checkRowSelection(rows.slice(1), false); - - await user.click(tree.getByLabelText('Select All')); - checkSelectAll(tree, 'checked'); - checkRowSelection(rows.slice(1), true); - - rerender(tree, renderJSX({onSelectionChange}, [ - {foo: 'Foo 0', bar: 'Bar 0', baz: 'Baz 0'}, - ...manyItems - ])); - - act(() => jest.runAllTimers()); - - expect(getCell(tree, 'Foo 0')).toBeVisible(); - checkRowSelection(rows.slice(1), true); - }); - - it('manually selecting all should not auto select new items', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}, items); - - checkSelectAll(tree, 'unchecked'); - - let rows = tree.getAllByRole('row'); - checkRowSelection(rows.slice(1), false); - - await user.click(rows[1]); - checkSelectAll(tree, 'indeterminate'); - - await user.click(rows[2]); - checkSelectAll(tree, 'checked'); - - rerender(tree, renderJSX({onSelectionChange}, [ - {foo: 'Foo 0', bar: 'Bar 0', baz: 'Baz 0'}, - ...items - ])); - - act(() => jest.runAllTimers()); - - rows = tree.getAllByRole('row'); - expect(getCell(tree, 'Foo 0')).toBeVisible(); - expect(rows[1]).toHaveAttribute('aria-selected', 'false'); - checkRowSelection(rows.slice(2), true); - checkSelectAll(tree, 'indeterminate'); - }); - - it('should not included disabled rows when selecting all', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange, disabledKeys: ['Foo 3']}); - - checkSelectAll(tree, 'unchecked'); - - let rows = tree.getAllByRole('row'); - checkRowSelection(rows.slice(1), false); - - await user.click(tree.getByLabelText('Select All')); - - expect(onSelectionChange).toHaveBeenCalledTimes(1); - expect(onSelectionChange.mock.calls[0][0]).toEqual('all'); - checkRowSelection(rows.slice(1, 3), true); - checkRowSelection(rows.slice(3, 4), false); - checkRowSelection(rows.slice(4, 20), true); - }); - }); - - describe('annoucements', function () { - it('should announce the selected or deselected row', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - let row = tree.getAllByRole('row')[1]; - await user.click(row); - expect(announce).toHaveBeenLastCalledWith('Foo 1 selected.'); - - await user.click(row); - expect(announce).toHaveBeenLastCalledWith('Foo 1 not selected.'); - }); - - it('should announce the row and number of selected items when there are more than one', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - let rows = tree.getAllByRole('row'); - await user.click(rows[1]); - await user.click(rows[2]); - - expect(announce).toHaveBeenLastCalledWith('Foo 2 selected. 2 items selected.'); - - await user.click(rows[2]); - expect(announce).toHaveBeenLastCalledWith('Foo 2 not selected. 1 item selected.'); - }); - - it('should announce only the number of selected items when multiple are selected at once', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - let rows = tree.getAllByRole('row'); - await user.click(rows[1]); - await user.keyboard('[ShiftLeft>]'); - await user.click(rows[3]); - - expect(announce).toHaveBeenLastCalledWith('3 items selected.'); - - await user.click(rows[1]); - await user.keyboard('[/ShiftLeft]'); - expect(announce).toHaveBeenLastCalledWith('1 item selected.'); - }); - - it('should announce select all', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - await user.click(tree.getByLabelText('Select All')); - expect(announce).toHaveBeenLastCalledWith('All items selected.'); - - await user.click(tree.getByLabelText('Select All')); - expect(announce).toHaveBeenLastCalledWith('No items selected.'); - }); - - it('should announce all row header columns', async function () { - let tree = render( - - - First Name - Last Name - Birthday - - - - Sam - Smith - May 3 - - - Julia - Jones - February 10 - - - - ); - - let row = tree.getAllByRole('row')[1]; - await user.click(row); - expect(announce).toHaveBeenLastCalledWith('Sam Smith selected.'); - - await user.click(row); - expect(announce).toHaveBeenLastCalledWith('Sam Smith not selected.'); - }); - - it('should announce changes in sort order', async function () { - let tree = render(); - let table = tree.getByRole('grid'); - let columnheaders = within(table).getAllByRole('columnheader'); - expect(columnheaders).toHaveLength(3); - - await user.click(columnheaders[1]); - expect(announce).toHaveBeenLastCalledWith('sorted by column Bar in descending order', 'assertive', 500); - await user.click(columnheaders[1]); - expect(announce).toHaveBeenLastCalledWith('sorted by column Bar in ascending order', 'assertive', 500); - await user.click(columnheaders[0]); - expect(announce).toHaveBeenLastCalledWith('sorted by column Foo in ascending order', 'assertive', 500); - await user.click(columnheaders[0]); - expect(announce).toHaveBeenLastCalledWith('sorted by column Foo in descending order', 'assertive', 500); - }); - }); - - it('can announce deselect even when items are swapped out completely', async () => { - let tree = render(); - - let row = tree.getAllByRole('row')[2]; - await user.click(row); - expect(announce).toHaveBeenLastCalledWith('File B selected.'); - - let link = tree.getAllByRole('link')[1]; - await user.click(link); - - expect(announce).toHaveBeenLastCalledWith('No items selected.'); - expect(announce).toHaveBeenCalledTimes(2); - }); - - it('will not announce deselect caused by breadcrumb navigation', async () => { - let tree = render(); - - let link = tree.getAllByRole('link')[1]; - await user.click(link); - - // TableWithBreadcrumbs has a setTimeout to load the results of the link navigation on Folder A - act(() => jest.runAllTimers()); - // Animation. - act(() => jest.runAllTimers()); - let row = tree.getAllByRole('row')[1]; - await user.click(row); - expect(announce).toHaveBeenLastCalledWith('File C selected.'); - expect(announce).toHaveBeenCalledTimes(2); - - // breadcrumb root - link = tree.getAllByRole('link')[0]; - await user.click(link); - - // focus isn't on the table, so we don't announce that it has been deselected - expect(announce).toHaveBeenCalledTimes(2); - }); - - it('updates even if not focused', async () => { - let tree = render(); - - let link = tree.getAllByRole('link')[1]; - await user.click(link); - - // TableWithBreadcrumbs has a setTimeout to load the results of the link navigation on Folder A - act(() => jest.runAllTimers()); - // Animation. - act(() => jest.runAllTimers()); - let row = tree.getAllByRole('row')[1]; - await user.click(row); - expect(announce).toHaveBeenLastCalledWith('File C selected.'); - expect(announce).toHaveBeenCalledTimes(2); - let button = tree.getAllByRole('button')[0]; - await user.click(button); - expect(announce).toHaveBeenCalledTimes(2); - - // breadcrumb root - link = tree.getAllByRole('menuitemradio')[0]; - await user.click(link); - - act(() => { - // TableWithBreadcrumbs has a setTimeout to load the results of the link navigation on Folder A - jest.runAllTimers(); - }); - - // focus isn't on the table, so we don't announce that it has been deselected - expect(announce).toHaveBeenCalledTimes(2); - - link = tree.getAllByRole('link')[1]; - await user.click(link); - - act(() => { - // TableWithBreadcrumbs has a setTimeout to load the results of the link navigation on Folder A - jest.runAllTimers(); - }); - - expect(announce).toHaveBeenCalledTimes(3); - expect(announce).toHaveBeenLastCalledWith('No items selected.'); - }); - - describe('onAction', function () { - it('should trigger onAction when clicking rows with the mouse', async function () { - let onSelectionChange = jest.fn(); - let onAction = jest.fn(); - let tree = renderTable({onSelectionChange, onAction}); - - let rows = tree.getAllByRole('row'); - await user.click(getCell(tree, 'Baz 10'), {pointerType: 'mouse'}); - expect(onSelectionChange).not.toHaveBeenCalled(); - expect(onAction).toHaveBeenCalledTimes(1); - expect(onAction).toHaveBeenLastCalledWith('Foo 10'); - checkRowSelection(rows.slice(1), false); - - let checkbox = within(rows[1]).getByRole('checkbox'); - await user.click(checkbox); - expect(onSelectionChange).toHaveBeenCalledTimes(1); - checkRowSelection([rows[1]], true); - - await user.click(getCell(tree, 'Baz 10'), {pointerType: 'mouse'}); - expect(onSelectionChange).toHaveBeenCalledTimes(2); - checkRowSelection([rows[1], rows[10]], true); - }); - - it('should trigger onAction when clicking rows with touch', async function () { - let onSelectionChange = jest.fn(); - let onAction = jest.fn(); - let tree = renderTable({onSelectionChange, onAction}); - - let rows = tree.getAllByRole('row'); - await user.click(getCell(tree, 'Baz 10'), {pointerType: 'touch'}); - expect(onSelectionChange).not.toHaveBeenCalled(); - expect(onAction).toHaveBeenCalledTimes(1); - expect(onAction).toHaveBeenLastCalledWith('Foo 10'); - checkRowSelection(rows.slice(1), false); - - let checkbox = within(rows[1]).getByRole('checkbox'); - await user.click(checkbox, {pointerType: 'touch'}); - expect(onSelectionChange).toHaveBeenCalledTimes(1); - checkRowSelection([rows[1]], true); - - await user.click(getCell(tree, 'Baz 10'), {pointerType: 'touch'}); - expect(onSelectionChange).toHaveBeenCalledTimes(2); - checkRowSelection([rows[1], rows[10]], true); - }); - - describe('needs PointerEvent defined', () => { - installPointerEvent(); - it('should support long press to enter selection mode on touch', async function () { - let onSelectionChange = jest.fn(); - let onAction = jest.fn(); - let tree = renderTable({onSelectionChange, onAction, selectionStyle: 'highlight'}); - let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')}); - tableTester.setInteractionType('touch'); - - act(() => jest.runAllTimers()); - await user.pointer({target: document.body, keys: '[TouchA]'}); - - await tableTester.toggleRowSelection({row: 'Foo 5', needsLongPress: true}); - checkSelection(onSelectionChange, ['Foo 5']); - expect(onAction).not.toHaveBeenCalled(); - onSelectionChange.mockReset(); - - await tableTester.toggleRowSelection({row: 'Foo 10', needsLongPress: false}); - checkSelection(onSelectionChange, ['Foo 5', 'Foo 10']); - - // Deselect all to exit selection mode - onSelectionChange.mockReset(); - await tableTester.toggleRowSelection({row: 'Foo 10', needsLongPress: false}); - checkSelection(onSelectionChange, ['Foo 5']); - onSelectionChange.mockReset(); - await tableTester.toggleRowSelection({row: 'Foo 5', needsLongPress: false}); - act(() => jest.runAllTimers()); - checkSelection(onSelectionChange, []); - expect(onAction).not.toHaveBeenCalled(); - }); - }); - - it('should trigger onAction when pressing Enter', async function () { - let onSelectionChange = jest.fn(); - let onAction = jest.fn(); - let tree = renderTable({onSelectionChange, onAction}); - let rows = tree.getAllByRole('row'); - - await user.tab(); - await user.keyboard('{ArrowDown}'.repeat(9)); - await user.keyboard('{Enter}'); - expect(onSelectionChange).not.toHaveBeenCalled(); - expect(onAction).toHaveBeenCalledTimes(1); - expect(onAction).toHaveBeenLastCalledWith('Foo 10'); - checkRowSelection(rows.slice(1), false); - - onAction.mockReset(); - await user.keyboard(' '); - expect(onSelectionChange).toHaveBeenCalledTimes(1); - expect(onAction).not.toHaveBeenCalled(); - checkRowSelection([rows[10]], true); - }); - }); - - describe('selectionStyle highlight', function () { - it('will replace the current selection with the new selection', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange, selectionStyle: 'highlight'}); - - expect(tree.queryByLabelText('Select All')).toBeNull(); - - let rows = tree.getAllByRole('row'); - checkRowSelection(rows.slice(1), false); - await user.click(getCell(tree, 'Baz 10')); - expect(announce).toHaveBeenLastCalledWith('Foo 10 selected.'); - expect(announce).toHaveBeenCalledTimes(1); - - onSelectionChange.mockReset(); - await user.keyboard('[ShiftLeft>]'); - await user.click(getCell(tree, 'Baz 20')); - await user.keyboard('[/ShiftLeft]'); - // await user.click(getCell(tree, 'Baz 20'), {pointerType: 'mouse', shiftKey: true}); - expect(announce).toHaveBeenLastCalledWith('11 items selected.'); - expect(announce).toHaveBeenCalledTimes(2); - - onSelectionChange.mockReset(); - await user.click(getCell(tree, 'Foo 5')); - expect(announce).toHaveBeenLastCalledWith('Foo 5 selected. 1 item selected.'); - expect(announce).toHaveBeenCalledTimes(3); - - checkSelection(onSelectionChange, [ - 'Foo 5' - ]); - - checkRowSelection(rows.slice(1, 5), false); - checkRowSelection(rows.slice(5, 6), true); - checkRowSelection(rows.slice(6), false); - - onSelectionChange.mockReset(); - await user.keyboard('[ShiftLeft>]'); - await user.click(getCell(tree, 'Foo 10')); - await user.keyboard('[/ShiftLeft]'); - expect(announce).toHaveBeenLastCalledWith('6 items selected.'); - expect(announce).toHaveBeenCalledTimes(4); - - checkSelection(onSelectionChange, [ - 'Foo 5', 'Foo 6', 'Foo 7', 'Foo 8', 'Foo 9', 'Foo 10' - ]); - - checkRowSelection(rows.slice(1, 5), false); - checkRowSelection(rows.slice(5, 11), true); - checkRowSelection(rows.slice(11), false); - }); - - it('will add to the current selection if the command key is pressed', async function () { - let uaMock = jest.spyOn(navigator, 'platform', 'get').mockImplementation(() => 'Mac'); - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange, selectionStyle: 'highlight'}); - - expect(tree.queryByLabelText('Select All')).toBeNull(); - - let rows = tree.getAllByRole('row'); - checkRowSelection(rows.slice(1), false); - await user.click(getCell(tree, 'Baz 10'), {pointerType: 'mouse'}); - - onSelectionChange.mockReset(); - await user.keyboard('[ShiftLeft>]'); - await user.click(getCell(tree, 'Baz 20')); - await user.keyboard('[/ShiftLeft]'); - - onSelectionChange.mockReset(); - await user.keyboard('[MetaLeft>]'); - await user.click(getCell(tree, 'Foo 5')); - await user.keyboard('[/MetaLeft]'); - - checkSelection(onSelectionChange, [ - 'Foo 5', 'Foo 10', 'Foo 11', 'Foo 12', 'Foo 13', 'Foo 14', 'Foo 15', - 'Foo 16', 'Foo 17', 'Foo 18', 'Foo 19', 'Foo 20' - ]); - - checkRowSelection(rows.slice(1, 5), false); - checkRowSelection(rows.slice(5, 6), true); - checkRowSelection(rows.slice(6, 10), false); - checkRowSelection(rows.slice(10, 21), true); - checkRowSelection(rows.slice(21), false); - - uaMock.mockRestore(); - }); - - describe('needs pointerEvents', function () { - installPointerEvent(); - it('should toggle selection with touch', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange, selectionStyle: 'highlight'}); - let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')}); - tableTester.setInteractionType('touch'); - expect(tree.queryByLabelText('Select All')).toBeNull(); - - await tableTester.toggleRowSelection({row: 'Baz 5'}); - expect(announce).toHaveBeenLastCalledWith('Foo 5 selected.'); - expect(announce).toHaveBeenCalledTimes(1); - onSelectionChange.mockReset(); - await tableTester.toggleRowSelection({row: 'Foo 10'}); - expect(announce).toHaveBeenLastCalledWith('Foo 10 selected. 2 items selected.'); - expect(announce).toHaveBeenCalledTimes(2); - - checkSelection(onSelectionChange, ['Foo 5', 'Foo 10']); - }); - - it('should support single tap to perform onAction with touch', async function () { - let onSelectionChange = jest.fn(); - let onAction = jest.fn(); - let tree = renderTable({onSelectionChange, selectionStyle: 'highlight', onAction}); - - await user.pointer({target: getCell(tree, 'Baz 5'), keys: '[TouchA]'}); - expect(announce).not.toHaveBeenCalled(); - expect(onSelectionChange).not.toHaveBeenCalled(); - expect(onAction).toHaveBeenCalledTimes(1); - expect(onAction).toHaveBeenCalledWith('Foo 5'); - }); - }); - - it('should support double click to perform onAction with mouse', async function () { - let onSelectionChange = jest.fn(); - let onAction = jest.fn(); - let tree = renderTable({onSelectionChange, selectionStyle: 'highlight', onAction}); - let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')}); - - await tableTester.toggleRowSelection({row: 'Foo 5'}); - expect(announce).toHaveBeenLastCalledWith('Foo 5 selected.'); - expect(announce).toHaveBeenCalledTimes(1); - checkSelection(onSelectionChange, ['Foo 5']); - expect(onAction).not.toHaveBeenCalled(); - - announce.mockReset(); - onSelectionChange.mockReset(); - await tableTester.triggerRowAction({row: 'Foo 5', needsDoubleClick: true}); - expect(announce).not.toHaveBeenCalled(); - expect(onSelectionChange).not.toHaveBeenCalled(); - expect(onAction).toHaveBeenCalledTimes(1); - expect(onAction).toHaveBeenCalledWith('Foo 5'); - }); - - describe('needs pointerEvents', function () { - installPointerEvent(); - it('should support single tap to perform row selection with screen reader if onAction isn\'t provided', function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange, selectionStyle: 'highlight'}); - - let cell = getCell(tree, 'Baz 5'); - fireEvent(cell, pointerEvent('pointerdown', {width: 0, height: 0, pointerType: 'touch'})); - fireEvent(cell, pointerEvent('mousedown', {})); - fireEvent(cell, pointerEvent('pointerup', {width: 0, height: 0, pointerType: 'touch'})); - fireEvent(cell, pointerEvent('mouseup', {})); - fireEvent(cell, pointerEvent('click', {})); - checkSelection(onSelectionChange, [ - 'Foo 5' - ]); - onSelectionChange.mockReset(); - - cell = getCell(tree, 'Foo 8'); - fireEvent(cell, pointerEvent('pointerdown', { - pointerId: 1, - width: 1, - height: 1, - pressure: 0, - detail: 0, - pointerType: 'mouse' - })); - fireEvent(cell, pointerEvent('pointerup', { - pointerId: 1, - width: 1, - height: 1, - pressure: 0, - detail: 0, - pointerType: 'mouse' - })); - fireEvent.click(cell, {pointerType: 'mouse', width: 1, height: 1, detail: 1}); - checkSelection(onSelectionChange, [ - 'Foo 5', 'Foo 8' - ]); - onSelectionChange.mockReset(); - - // Android TalkBack double tap test, virtual pointer event sets pointerType and onClick handles the rest - cell = getCell(tree, 'Foo 10'); - fireEvent(cell, pointerEvent('pointerdown', { - pointerId: 1, - width: 1, - height: 1, - pressure: 0, - detail: 0, - pointerType: 'mouse' - })); - fireEvent(cell, pointerEvent('pointerup', { - pointerId: 1, - width: 1, - height: 1, - pressure: 0, - detail: 0, - pointerType: 'mouse' - })); - fireEvent.click(cell, {pointerType: 'mouse', width: 1, height: 1, detail: 1}); - checkSelection(onSelectionChange, [ - 'Foo 5', 'Foo 8', 'Foo 10' - ]); - }); - - it('should support single tap to perform onAction with screen reader', function () { - let onSelectionChange = jest.fn(); - let onAction = jest.fn(); - let tree = renderTable({onSelectionChange, selectionStyle: 'highlight', onAction}); - - fireEvent.click(getCell(tree, 'Baz 5'), {detail: 0}); - expect(announce).not.toHaveBeenCalled(); - expect(onSelectionChange).not.toHaveBeenCalled(); - expect(onAction).toHaveBeenCalledTimes(1); - expect(onAction).toHaveBeenCalledWith('Foo 5'); - - // Android TalkBack double tap test, virtual pointer event sets pointerType and onClick handles the rest - let cell = getCell(tree, 'Foo 10'); - fireEvent(cell, pointerEvent('pointerdown', {pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0, pointerType: 'mouse'})); - fireEvent(cell, pointerEvent('pointerup', {pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0, pointerType: 'mouse'})); - fireEvent.click(cell, {pointerType: 'mouse', width: 1, height: 1, detail: 1}); - expect(onSelectionChange).not.toHaveBeenCalled(); - expect(onAction).toHaveBeenCalledTimes(2); - expect(onAction).toHaveBeenCalledWith('Foo 10'); - }); - }); - - describe('with pointer events', () => { - beforeEach(() => { - window.ontouchstart = jest.fn(); - }); - afterEach(() => { - delete window.ontouchstart; - }); - - describe('still needs pointer events install', function () { - installPointerEvent(); - it('should support long press to enter selection mode on touch', async function () { - let onSelectionChange = jest.fn(); - let onAction = jest.fn(); - let tree = renderTable({onSelectionChange, selectionStyle: 'highlight', onAction}); - act(() => { - jest.runAllTimers(); - }); - - let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')}); - tableTester.setInteractionType('touch'); - - await user.click(document.body); - - // TODO: Not replacing this with util for long press since it tests various things in the middle of the press - fireEvent.pointerDown(tableTester.findCell({text: 'Baz 5'}), {pointerType: 'touch'}); - let description = tree.getByText('Long press to enter selection mode.'); - expect(tree.getByRole('grid')).toHaveAttribute('aria-describedby', expect.stringContaining(description.id)); - expect(announce).not.toHaveBeenCalled(); - expect(onSelectionChange).not.toHaveBeenCalled(); - expect(onAction).not.toHaveBeenCalled(); - expect(tree.queryByLabelText('Select All')).toBeNull(); - - act(() => { - jest.advanceTimersByTime(800); - }); - - expect(announce).toHaveBeenLastCalledWith('Foo 5 selected.'); - expect(announce).toHaveBeenCalledTimes(1); - checkSelection(onSelectionChange, ['Foo 5']); - expect(onAction).not.toHaveBeenCalled(); - expect(tree.queryByLabelText('Select All')).not.toBeNull(); - - let cell = getCell(tree, 'Baz 5'); - fireEvent.pointerUp(cell, {pointerType: 'touch'}); - fireEvent.click(cell, {detail: 1}); - onSelectionChange.mockReset(); - act(() => { - jest.runAllTimers(); - }); - - await user.click(getCell(tree, 'Foo 10'), {pointerType: 'touch'}); - act(() => { - jest.runAllTimers(); - }); - expect(announce).toHaveBeenLastCalledWith('Foo 10 selected. 2 items selected.'); - expect(announce).toHaveBeenCalledTimes(2); - checkSelection(onSelectionChange, ['Foo 5', 'Foo 10']); - - // Deselect all to exit selection mode - await tableTester.toggleRowSelection({row: 'Foo 10'}); - expect(announce).toHaveBeenLastCalledWith('Foo 10 not selected. 1 item selected.'); - expect(announce).toHaveBeenCalledTimes(3); - onSelectionChange.mockReset(); - - await tableTester.toggleRowSelection({row: 'Foo 5'}); - expect(announce).toHaveBeenLastCalledWith('Foo 5 not selected.'); - expect(announce).toHaveBeenCalledTimes(4); - - checkSelection(onSelectionChange, []); - expect(onAction).not.toHaveBeenCalled(); - expect(tree.queryByLabelText('Select All')).toBeNull(); - }); - }); - }); - - it('should support Enter to perform onAction with keyboard', async function () { - let onSelectionChange = jest.fn(); - let onAction = jest.fn(); - renderTable({onSelectionChange, selectionStyle: 'highlight', onAction}); - - await user.tab(); - await user.keyboard('{ArrowDown}'.repeat(8)); - await user.keyboard('{ArrowRight}{ArrowRight}'); - onSelectionChange.mockReset(); - await user.keyboard('{ArrowDown}'); - checkSelection(onSelectionChange, ['Foo 10']); - expect(onAction).not.toHaveBeenCalled(); - - onSelectionChange.mockReset(); - await user.keyboard('{ArrowUp}'.repeat(5)); - onSelectionChange.mockReset(); - announce.mockReset(); - onSelectionChange.mockReset(); - await user.keyboard('{Enter}'); - expect(onSelectionChange).not.toHaveBeenCalled(); - expect(announce).not.toHaveBeenCalled(); - expect(onAction).toHaveBeenCalledTimes(1); - expect(onAction).toHaveBeenCalledWith('Foo 5'); - }); - - it('should perform onAction on single click with selectionMode: none', async function () { - let onSelectionChange = jest.fn(); - let onAction = jest.fn(); - let tree = renderTable({onSelectionChange, selectionMode: 'none', onAction}); - - await user.click(getCell(tree, 'Baz 10'), {pointerType: 'mouse'}); - expect(announce).not.toHaveBeenCalled(); - expect(onSelectionChange).not.toHaveBeenCalled(); - expect(onAction).toHaveBeenCalledTimes(1); - expect(onAction).toHaveBeenCalledWith('Foo 10'); - }); - - it('should move selection when using the arrow keys', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange, selectionStyle: 'highlight'}); - - await user.click(getCell(tree, 'Baz 5'), {pointerType: 'mouse'}); - expect(announce).toHaveBeenLastCalledWith('Foo 5 selected.'); - expect(announce).toHaveBeenCalledTimes(1); - checkSelection(onSelectionChange, ['Foo 5']); - - announce.mockReset(); - onSelectionChange.mockReset(); - await user.keyboard('{ArrowDown}'); - expect(announce).toHaveBeenCalledWith('Foo 6 selected.'); - checkSelection(onSelectionChange, ['Foo 6']); - - onSelectionChange.mockReset(); - await user.keyboard('{ArrowUp}'); - expect(announce).toHaveBeenCalledWith('Foo 5 selected.'); - checkSelection(onSelectionChange, ['Foo 5']); - - onSelectionChange.mockReset(); - await user.keyboard('{Shift>}{ArrowDown}{/Shift}'); - expect(announce).toHaveBeenCalledWith('Foo 6 selected. 2 items selected.'); - checkSelection(onSelectionChange, ['Foo 5', 'Foo 6']); - }); - - it('should announce the new row when moving with the keyboard after multi select', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange, selectionStyle: 'highlight'}); - - await user.click(getCell(tree, 'Baz 5'), {pointerType: 'mouse'}); - expect(announce).toHaveBeenLastCalledWith('Foo 5 selected.'); - expect(announce).toHaveBeenCalledTimes(1); - checkSelection(onSelectionChange, ['Foo 5']); - - announce.mockReset(); - onSelectionChange.mockReset(); - await user.keyboard('{Shift>}{ArrowDown}{/Shift}'); - expect(announce).toHaveBeenCalledWith('Foo 6 selected. 2 items selected.'); - checkSelection(onSelectionChange, ['Foo 5', 'Foo 6']); - - announce.mockReset(); - onSelectionChange.mockReset(); - await user.keyboard('{ArrowDown}'); - expect(announce).toHaveBeenCalledWith('Foo 7 selected. 1 item selected.'); - checkSelection(onSelectionChange, ['Foo 7']); - }); - - it('should support non-contiguous selection with the keyboard', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange, selectionStyle: 'highlight'}); - - await user.click(getCell(tree, 'Baz 5'), {pointerType: 'mouse'}); - expect(announce).toHaveBeenLastCalledWith('Foo 5 selected.'); - expect(announce).toHaveBeenCalledTimes(1); - checkSelection(onSelectionChange, ['Foo 5']); - - announce.mockReset(); - onSelectionChange.mockReset(); - await user.keyboard('{Control>}{ArrowDown}{/Control}'); - expect(announce).not.toHaveBeenCalled(); - expect(onSelectionChange).not.toHaveBeenCalled(); - expect(document.activeElement).toBe(getCell(tree, 'Baz 6')); - - await user.keyboard('{Control>}{ArrowDown}{/Control}'); - expect(announce).not.toHaveBeenCalled(); - expect(onSelectionChange).not.toHaveBeenCalled(); - expect(document.activeElement).toBe(getCell(tree, 'Baz 7')); - - await user.keyboard('{Control>} {/Control}'); - expect(announce).toHaveBeenCalledWith('Foo 7 selected. 2 items selected.'); - expect(announce).toHaveBeenCalledTimes(1); - checkSelection(onSelectionChange, ['Foo 5', 'Foo 7']); - - announce.mockReset(); - onSelectionChange.mockReset(); - await user.keyboard(' '); - expect(announce).toHaveBeenCalledWith('Foo 7 selected. 1 item selected.'); - expect(announce).toHaveBeenCalledTimes(1); - checkSelection(onSelectionChange, ['Foo 7']); - }); - - it('should not call onSelectionChange when hitting Space/Enter on the currently selected row', async function () { - let onSelectionChange = jest.fn(); - let onAction = jest.fn(); - renderTable({onSelectionChange, selectionStyle: 'highlight', onAction}); - - await user.tab(); - await user.keyboard('{ArrowDown}'.repeat(8)); - await user.keyboard('{ArrowRight}{ArrowRight}'); - onSelectionChange.mockReset(); - await user.keyboard('{ArrowDown}'); - checkSelection(onSelectionChange, ['Foo 10']); - expect(onAction).not.toHaveBeenCalled(); - - await user.keyboard(' '); - expect(onSelectionChange).toHaveBeenCalledTimes(1); - - await user.keyboard('{Enter}'); - expect(onAction).toHaveBeenCalledTimes(1); - expect(onAction).toHaveBeenCalledWith('Foo 10'); - expect(onSelectionChange).toHaveBeenCalledTimes(1); - }); - - it('should announce the current selection when moving from all to one item', async function () { - let onSelectionChange = jest.fn(); - let onAction = jest.fn(); - let tree = renderTable({onSelectionChange, selectionStyle: 'highlight', onAction}); - await user.click(getCell(tree, 'Baz 5'), {pointerType: 'mouse'}); - expect(announce).toHaveBeenLastCalledWith('Foo 5 selected.'); - expect(announce).toHaveBeenCalledTimes(1); - checkSelection(onSelectionChange, ['Foo 5']); - - announce.mockReset(); - onSelectionChange.mockReset(); - await user.keyboard('{Control>}a{/Control}'); - expect(onSelectionChange.mock.calls[0][0]).toEqual('all'); - expect(announce).toHaveBeenCalledWith('All items selected.'); - - announce.mockReset(); - onSelectionChange.mockReset(); - await user.keyboard('{ArrowDown}'); - expect(announce).toHaveBeenCalledWith('Foo 6 selected. 1 item selected.'); - checkSelection(onSelectionChange, ['Foo 6']); - }); - }); - }); - - describe('single selection', function () { - let renderJSX = (props, items = manyItems) => ( - - - {column => {column.name}} - - - {item => - ( - {key => {item[key]}} - ) - } - - - ); - - let renderTable = (props, items = manyItems) => render(renderJSX(props, items)); - - let checkSelection = (onSelectionChange, selectedKeys) => { - expect(onSelectionChange).toHaveBeenCalledTimes(1); - expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(selectedKeys)); - }; - - let checkRowSelection = (rows, selected) => { - for (let row of rows) { - expect(row).toHaveAttribute('aria-selected', '' + selected); - } - }; - - describe('row selection', function () { - it('should select a row from checkbox', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - let row = tree.getAllByRole('row')[1]; - expect(row).toHaveAttribute('aria-selected', 'false'); - await user.click(within(row).getByRole('checkbox')); - - checkSelection(onSelectionChange, ['Foo 1']); - expect(row).toHaveAttribute('aria-selected', 'true'); - }); - - it('should select a row by pressing on a cell', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - let row = tree.getAllByRole('row')[1]; - expect(row).toHaveAttribute('aria-selected', 'false'); - await user.click(getCell(tree, 'Baz 1')); - - checkSelection(onSelectionChange, ['Foo 1']); - expect(row).toHaveAttribute('aria-selected', 'true'); - }); - - it('should select a row by pressing the Space key on a row', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - let row = tree.getAllByRole('row')[1]; - expect(row).toHaveAttribute('aria-selected', 'false'); - await user.tab(); - await user.keyboard('{ArrowRight}'); - await user.keyboard(' '); - - checkSelection(onSelectionChange, ['Foo 1']); - expect(row).toHaveAttribute('aria-selected', 'true'); - }); - - it('should select a row by pressing the Enter key on a row', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - let row = tree.getAllByRole('row')[1]; - expect(row).toHaveAttribute('aria-selected', 'false'); - await user.tab(); - await user.keyboard('{ArrowRight}'); - await user.keyboard('{Enter}'); - - checkSelection(onSelectionChange, ['Foo 1']); - expect(row).toHaveAttribute('aria-selected', 'true'); - }); - - it('should select a row by pressing the Space key on a cell', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - let row = tree.getAllByRole('row')[1]; - expect(row).toHaveAttribute('aria-selected', 'false'); - await user.tab(); - await user.keyboard('{ArrowRight}'); - await user.keyboard('{Enter}'); - - checkSelection(onSelectionChange, ['Foo 1']); - expect(row).toHaveAttribute('aria-selected', 'true'); - }); - - it('should select a row by pressing the Enter key on a cell', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - let row = tree.getAllByRole('row')[1]; - expect(row).toHaveAttribute('aria-selected', 'false'); - await user.tab(); - await user.keyboard('{ArrowRight}'); - await user.keyboard('{Enter}'); - - checkSelection(onSelectionChange, ['Foo 1']); - expect(row).toHaveAttribute('aria-selected', 'true'); - }); - - it('will only select one if pointer is used to click on multiple rows', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - let rows = tree.getAllByRole('row'); - checkRowSelection(rows.slice(1), false); - await user.click(getCell(tree, 'Baz 1')); - - checkSelection(onSelectionChange, ['Foo 1']); - expect(rows[1]).toHaveAttribute('aria-selected', 'true'); - expect(rows[2]).toHaveAttribute('aria-selected', 'false'); - checkRowSelection(rows.slice(2), false); - - onSelectionChange.mockReset(); - await user.click(getCell(tree, 'Baz 2')); - - checkSelection(onSelectionChange, ['Foo 2']); - expect(rows[1]).toHaveAttribute('aria-selected', 'false'); - expect(rows[2]).toHaveAttribute('aria-selected', 'true'); - checkRowSelection(rows.slice(3), false); - - // Deselect - onSelectionChange.mockReset(); - await user.click(getCell(tree, 'Baz 2')); - - checkSelection(onSelectionChange, []); - expect(rows[1]).toHaveAttribute('aria-selected', 'false'); - expect(rows[2]).toHaveAttribute('aria-selected', 'false'); - checkRowSelection(rows.slice(2), false); - }); - - it('will only select one if pointer is used to click on multiple checkboxes', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - let rows = tree.getAllByRole('row'); - checkRowSelection(rows.slice(1), false); - await user.click(within(rows[1]).getByRole('checkbox')); - - checkSelection(onSelectionChange, ['Foo 1']); - expect(rows[1]).toHaveAttribute('aria-selected', 'true'); - expect(rows[2]).toHaveAttribute('aria-selected', 'false'); - checkRowSelection(rows.slice(2), false); - - onSelectionChange.mockReset(); - await user.click(within(rows[2]).getByRole('checkbox')); - - checkSelection(onSelectionChange, ['Foo 2']); - expect(rows[1]).toHaveAttribute('aria-selected', 'false'); - expect(rows[2]).toHaveAttribute('aria-selected', 'true'); - checkRowSelection(rows.slice(3), false); - - // Deselect - onSelectionChange.mockReset(); - await user.click(within(rows[2]).getByRole('checkbox')); - - checkSelection(onSelectionChange, []); - expect(rows[1]).toHaveAttribute('aria-selected', 'false'); - expect(rows[2]).toHaveAttribute('aria-selected', 'false'); - checkRowSelection(rows.slice(2), false); - }); - - it('should support selecting single row only with the Space key', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - - let rows = tree.getAllByRole('row'); - checkRowSelection(rows.slice(1), false); - await user.tab(); - await user.keyboard('{ArrowRight}{ArrowRight}'); - await user.keyboard(' '); - - checkSelection(onSelectionChange, ['Foo 1']); - expect(rows[1]).toHaveAttribute('aria-selected', 'true'); - expect(rows[2]).toHaveAttribute('aria-selected', 'false'); - checkRowSelection(rows.slice(2), false); - - onSelectionChange.mockReset(); - await user.keyboard('{ArrowDown}'); - await user.keyboard(' '); - - checkSelection(onSelectionChange, ['Foo 2']); - expect(rows[1]).toHaveAttribute('aria-selected', 'false'); - expect(rows[2]).toHaveAttribute('aria-selected', 'true'); - checkRowSelection(rows.slice(3), false); - - // Deselect - onSelectionChange.mockReset(); - await user.keyboard(' '); - - checkSelection(onSelectionChange, []); - expect(rows[1]).toHaveAttribute('aria-selected', 'false'); - expect(rows[2]).toHaveAttribute('aria-selected', 'false'); - checkRowSelection(rows.slice(2), false); - }); - - it('should not select a disabled row from checkbox or keyboard interaction', async function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange, disabledKeys: ['Foo 1']}); - - let row = tree.getAllByRole('row')[1]; - expect(row).toHaveAttribute('aria-selected', 'false'); - await user.click(within(row).getByRole('checkbox')); - await user.click(getCell(tree, 'Baz 1')); - await user.keyboard('{ArrowLeft}{ArrowLeft}{ArrowLeft}'); - await user.keyboard(' '); - await user.keyboard('{ArrowRight}{ArrowRight}'); - await user.keyboard(' '); - await user.keyboard('{Enter}'); - - expect(row).toHaveAttribute('aria-selected', 'false'); - expect(onSelectionChange).not.toHaveBeenCalled(); - }); - }); - - describe('row selection column header', function () { - it('should contain a hidden checkbox and VisuallyHidden accessible text', function () { - let onSelectionChange = jest.fn(); - let tree = renderTable({onSelectionChange}); - let columnheader = tree.getAllByRole('columnheader')[0]; - let checkboxInput = columnheader.querySelector('input[type="checkbox"]'); - expect(columnheader).not.toHaveAttribute('aria-disabled', 'true'); - expect(columnheader.firstElementChild).toBeVisible(); - expect(checkboxInput).not.toBeVisible(); - expect(checkboxInput.getAttribute('aria-label')).toEqual('Select'); - expect(columnheader.firstElementChild.textContent).toEqual(checkboxInput.getAttribute('aria-label')); - }); - }); - }); - - describe('press/hover interactions and selection mode', function () { - let TableWithBreadcrumbs = (props) => ( - - - {column => {column.name}} - - - {item => - ( - {key => {item[key]}} - ) - } - - - ); - - it('displays pressed/hover styles when row is pressed/hovered and selection mode is not "none"', async function () { - let tree = render(); - - let row = tree.getAllByRole('row')[1]; - await user.hover(row); - expect(row.className.includes('is-hovered')).toBeTruthy(); - await user.pointer({target: row, keys: '[MouseLeft>]'}); - expect(row.className.includes('is-active')).toBeTruthy(); - await user.pointer({target: row, keys: '[/MouseLeft]'}); - - rerender(tree, ); - row = tree.getAllByRole('row')[1]; - await user.hover(row); - expect(row.className.includes('is-hovered')).toBeTruthy(); - await user.pointer({target: row, keys: '[MouseLeft>]'}); - expect(row.className.includes('is-active')).toBeTruthy(); - await user.pointer({target: row, keys: '[/MouseLeft]'}); - }); - - it('doesn\'t show pressed/hover styles when row is pressed/hovered and selection mode is "none" and disabledBehavior="all"', async function () { - let tree = render(); - - let row = tree.getAllByRole('row')[1]; - await user.hover(row); - expect(row.className.includes('is-hovered')).toBeFalsy(); - await user.pointer({target: row, keys: '[MouseLeft>]'}); - expect(row.className.includes('is-active')).toBeFalsy(); - await user.pointer({target: row, keys: '[/MouseLeft]'}); - }); - - it('shows pressed/hover styles when row is pressed/hovered and selection mode is "none", disabledBehavior="selection" and has a action', async function () { - let tree = render(); - - let row = tree.getAllByRole('row')[1]; - await user.hover(row); - expect(row.className.includes('is-hovered')).toBeTruthy(); - await user.pointer({target: row, keys: '[MouseLeft>]'}); - expect(row.className.includes('is-active')).toBeTruthy(); - await user.pointer({target: row, keys: '[/MouseLeft]'}); - }); - - it('shows pressed/hover styles when row is pressed/hovered, disabledBehavior="selection", row is disabled and has a action', async function () { - let tree = render(); - - let row = tree.getAllByRole('row')[1]; - await user.hover(row); - expect(row.className.includes('is-hovered')).toBeTruthy(); - await user.pointer({target: row, keys: '[MouseLeft>]'}); - expect(row.className.includes('is-active')).toBeTruthy(); - await user.pointer({target: row, keys: '[/MouseLeft]'}); - }); - - it('doesn\'t show pressed/hover styles when row is pressed/hovered, has a action, but is disabled and disabledBehavior="all"', async function () { - let tree = render(); - - let row = tree.getAllByRole('row')[1]; - await user.hover(row); - expect(row.className.includes('is-hovered')).toBeFalsy(); - await user.pointer({target: row, keys: '[MouseLeft>]'}); - expect(row.className.includes('is-active')).toBeFalsy(); - await user.pointer({target: row, keys: '[/MouseLeft]'}); - }); - }); - - describe('CRUD', function () { - it('can add items', async function () { - let tree = render(); - - let table = tree.getByRole('grid'); - let rows = within(table).getAllByRole('row'); - expect(rows).toHaveLength(3); - expect(rows[1]).toHaveAttribute('aria-rowindex', '2'); - expect(rows[2]).toHaveAttribute('aria-rowindex', '3'); - - let button = tree.getByLabelText('Add item'); - await user.click(button); - act(() => {jest.runAllTimers();}); - - let dialog = tree.getByRole('dialog'); - expect(dialog).toBeVisible(); - - await user.keyboard('Devon'); - await user.tab(); - - await user.keyboard('Govett'); - await user.tab(); - - await user.keyboard('Feb 3'); - await user.tab(); - - let createButton = tree.getByText('Create'); - await user.click(createButton); - act(() => {jest.runAllTimers();}); - - expect(dialog).not.toBeInTheDocument(); - - rows = within(table).getAllByRole('row'); - expect(rows).toHaveLength(4); - expect(rows[1]).toHaveAttribute('aria-rowindex', '2'); - expect(rows[2]).toHaveAttribute('aria-rowindex', '3'); - expect(rows[3]).toHaveAttribute('aria-rowindex', '4'); - - let rowHeaders = within(rows[1]).getAllByRole('rowheader'); - expect(rowHeaders[0]).toHaveTextContent('Devon'); - expect(rowHeaders[1]).toHaveTextContent('Govett'); - - let cells = within(rows[1]).getAllByRole('gridcell'); - expect(cells[1]).toHaveTextContent('Feb 3'); - }); - - it('can remove items', async function () { - let tree = render(); - - let table = tree.getByRole('grid'); - let rows = within(table).getAllByRole('row'); - expect(rows).toHaveLength(3); - - await user.tab(); - await user.tab(); - expect(document.activeElement).toBe(rows[1]); - - fireEvent.keyDown(document.activeElement, {key: 'ArrowLeft'}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowLeft'}); - expect(document.activeElement).toBe(within(rows[1]).getByRole('button')); - - fireEvent.keyDown(document.activeElement, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement, {key: 'Enter'}); - - let menu = tree.getByRole('menu'); - let menuItems = within(menu).getAllByRole('menuitem'); - expect(menuItems.length).toBe(2); - expect(document.activeElement).toBe(menuItems[0]); - - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); - expect(document.activeElement).toBe(menuItems[1]); - - fireEvent.keyDown(document.activeElement, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement, {key: 'Enter'}); - expect(menu).not.toBeInTheDocument(); - - let dialog = tree.getByRole('alertdialog', {hidden: true}); - let deleteButton = within(dialog).getByRole('button', {hidden: true}); - - await user.click(deleteButton); - act(() => jest.runAllTimers()); - expect(dialog).not.toBeInTheDocument(); - - act(() => jest.runAllTimers()); - expect(rows[1]).not.toBeInTheDocument(); - - rows = within(table).getAllByRole('row'); - expect(rows).toHaveLength(2); - - expect(within(rows[1]).getAllByRole('rowheader')[0]).toHaveTextContent('Julia'); - - act(() => jest.runAllTimers()); - expect(document.activeElement).toBe(within(rows[1]).getByRole('button')); - }); - - it('resets row indexes after deleting a row', async function () { - let tree = render(); - - let table = tree.getByRole('grid'); - let rows = within(table).getAllByRole('row'); - expect(rows).toHaveLength(3); - expect(rows[1]).toHaveAttribute('aria-rowindex', '2'); - expect(rows[2]).toHaveAttribute('aria-rowindex', '3'); - - let button = within(rows[1]).getByRole('button'); - await user.click(button); - - let menu = tree.getByRole('menu'); - expect(document.activeElement).toBe(menu); - - let menuItems = within(menu).getAllByRole('menuitem'); - expect(menuItems.length).toBe(2); - - await user.click(menuItems[1]); - act(() => jest.runAllTimers()); - expect(menu).not.toBeInTheDocument(); - - let dialog = tree.getByRole('alertdialog', {hidden: true}); - let deleteButton = within(dialog).getByRole('button', {hidden: true}); - - await user.click(deleteButton); - act(() => jest.runAllTimers()); - expect(dialog).not.toBeInTheDocument(); - - act(() => jest.runAllTimers()); - act(() => jest.runAllTimers()); - expect(rows[1]).not.toBeInTheDocument(); - - rows = within(table).getAllByRole('row'); - expect(rows).toHaveLength(2); - - let rowHeaders = within(rows[1]).getAllByRole('rowheader'); - expect(rowHeaders[0]).toHaveTextContent('Julia'); - - expect(rows[1]).toHaveAttribute('aria-rowindex', '2'); - }); - - it('can bulk remove items', async function () { - let tree = render(); - - let addButton = tree.getAllByRole('button')[0]; - let table = tree.getByRole('grid'); - let rows = within(table).getAllByRole('row'); - expect(rows).toHaveLength(3); - - let checkbox = within(rows[0]).getByRole('checkbox'); - await user.click(checkbox); - expect(checkbox.checked).toBe(true); - - let deleteButton = tree.getByLabelText('Delete selected items'); - await user.click(deleteButton); - - let dialog = tree.getByRole('alertdialog'); - let confirmButton = within(dialog).getByRole('button'); - expect(document.activeElement).toBe(dialog); - - await user.click(confirmButton); - act(() => jest.runAllTimers()); - expect(dialog).not.toBeInTheDocument(); - - act(() => jest.runAllTimers()); - - rows = within(table).getAllByRole('row'); - - // account for renderEmptyState - await act(() => Promise.resolve()); - expect(rows).toHaveLength(2); - expect(rows[1].firstChild.getAttribute('aria-colspan')).toBe('5'); - expect(rows[1].textContent).toBe('No results'); - - expect(checkbox.checked).toBe(false); - - expect(document.activeElement).toBe(addButton); - }); - - it('can edit items', async function () { - let tree = render(); - - let table = tree.getByRole('grid'); - let rows = within(table).getAllByRole('row'); - expect(rows).toHaveLength(3); - - let button = within(rows[2]).getByRole('button'); - await user.click(button); - act(() => {jest.runAllTimers();}); - - let menu = tree.getByRole('menu'); - expect(document.activeElement).toBe(menu); - - let menuItems = within(menu).getAllByRole('menuitem'); - expect(menuItems.length).toBe(2); - - await user.click(menuItems[0]); - act(() => {jest.runAllTimers();}); - expect(menu).not.toBeInTheDocument(); - - let dialog = tree.getByRole('dialog'); - expect(dialog).toBeVisible(); - - let firstName = tree.getByLabelText('First Name'); - expect(document.activeElement).toBe(firstName); - await user.keyboard('Jessica'); - - let saveButton = tree.getByText('Save'); - await user.click(saveButton); - - act(() => {jest.runAllTimers();}); - act(() => {jest.runAllTimers();}); - - expect(dialog).not.toBeInTheDocument(); - - let rowHeaders = within(rows[2]).getAllByRole('rowheader'); - expect(rowHeaders[0]).toHaveTextContent('Jessica'); - expect(rowHeaders[1]).toHaveTextContent('Jones'); - expect(document.activeElement).toBe(button); - }); - - it('keyboard navigation works as expected with menu buttons', async function () { - let tree = render(); - - let table = tree.getByRole('grid'); - let rows = within(table).getAllByRole('row'); - expect(rows).toHaveLength(3); - - act(() => within(rows[1]).getAllByRole('gridcell').pop().focus()); - act(() => {jest.runAllTimers();}); - let button = within(rows[1]).getByRole('button'); - expect(document.activeElement).toBe(button); - - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); - - expect(tree.queryByRole('menu')).toBeNull(); - - expect(document.activeElement).toBe(within(rows[2]).getByRole('button')); - - fireEvent.keyDown(document.activeElement, {key: 'ArrowUp'}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowUp'}); - - expect(tree.queryByRole('menu')).toBeNull(); - - expect(document.activeElement).toBe(button); - - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown', altKey: true}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown', altKey: true}); - act(() => {jest.runAllTimers();}); - - let menu = tree.getByRole('menu'); - expect(document.activeElement).toBe(within(menu).getAllByRole('menuitem')[0]); - - fireEvent.keyDown(document.activeElement, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement, {key: 'Enter'}); - act(() => {jest.runAllTimers();}); - await user.tab(); - act(() => {jest.runAllTimers();}); - await user.tab(); - act(() => {jest.runAllTimers();}); - await user.tab(); - act(() => {jest.runAllTimers();}); - await user.tab(); - act(() => {jest.runAllTimers();}); - expect(document.activeElement).toBe(tree.getAllByRole('button')[1]); - - fireEvent.keyDown(document.activeElement, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement, {key: 'Enter'}); - act(() => {jest.runAllTimers();}); - act(() => {jest.runAllTimers();}); - - expect(document.activeElement).toBe(button); - }); - - it('menu buttons can be opened with Alt + ArrowDown', function () { - let tree = render(); - - let table = tree.getByRole('grid'); - let rows = within(table).getAllByRole('row'); - expect(rows).toHaveLength(3); - - act(() => within(rows[1]).getAllByRole('gridcell').pop().focus()); - act(() => {jest.runAllTimers();}); - expect(document.activeElement).toBe(within(rows[1]).getByRole('button')); - - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown', altKey: true}); - - let menu = tree.getByRole('menu'); - expect(menu).toBeInTheDocument(); - expect(document.activeElement).toBe(within(menu).getAllByRole('menuitem')[0]); - }); - - it('menu buttons can be opened with Alt + ArrowUp', function () { - let tree = render(); - - let table = tree.getByRole('grid'); - let rows = within(table).getAllByRole('row'); - expect(rows).toHaveLength(3); - - act(() => within(rows[1]).getAllByRole('gridcell').pop().focus()); - act(() => {jest.runAllTimers();}); - expect(document.activeElement).toBe(within(rows[1]).getByRole('button')); - - fireEvent.keyDown(document.activeElement, {key: 'ArrowUp', altKey: true}); - - let menu = tree.getByRole('menu'); - expect(menu).toBeInTheDocument(); - expect(document.activeElement).toBe(within(menu).getAllByRole('menuitem').pop()); - }); - - it('menu keyboard navigation does not affect table', function () { - let tree = render(); - - let table = tree.getByRole('grid'); - let rows = within(table).getAllByRole('row'); - expect(rows).toHaveLength(3); - - act(() => within(rows[1]).getAllByRole('gridcell').pop().focus()); - act(() => {jest.runAllTimers();}); - expect(document.activeElement).toBe(within(rows[1]).getByRole('button')); - - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown', altKey: true}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown', altKey: true}); - - let menu = tree.getByRole('menu'); - expect(menu).toBeInTheDocument(); - expect(document.activeElement).toBe(within(menu).getAllByRole('menuitem')[0]); - - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); - expect(document.activeElement).toBe(within(menu).getAllByRole('menuitem')[1]); - - fireEvent.keyDown(document.activeElement, {key: 'Escape'}); - fireEvent.keyUp(document.activeElement, {key: 'Escape'}); - - act(() => jest.runAllTimers()); - - expect(menu).not.toBeInTheDocument(); - expect(document.activeElement).toBe(within(rows[1]).getByRole('button')); - }); - }); - - describe('with dialog trigger', function () { - let TableWithBreadcrumbs = (props) => ( - - - Foo - Bar - Baz - - - - One - Two - - - - {close => ( - - The Heading - - - - - - - - - - )} - - - - - - ); - - it('arrow keys interactions don\'t move the focus away from the textfield in the dialog', async function () { - let tree = render(); - let table = tree.getByRole('grid'); - let rows = within(table).getAllByRole('row'); - expect(rows).toHaveLength(2); - - let button = within(rows[1]).getByRole('button'); - await user.click(button); - - let dialog = tree.getByRole('dialog'); - let input = within(dialog).getByTestId('input'); - - expect(input).toBeTruthy(); - await user.type(input, 'blah'); - expect(document.activeElement).toEqual(input); - expect(input.value).toBe('blah'); - - fireEvent.keyDown(input, {key: 'ArrowLeft', code: 37, charCode: 37}); - fireEvent.keyUp(input, {key: 'ArrowLeft', code: 37, charCode: 37}); - act(() => { - jest.runAllTimers(); - }); - - expect(document.activeElement).toEqual(input); - - fireEvent.keyDown(input, {key: 'ArrowRight', code: 39, charCode: 39}); - fireEvent.keyUp(input, {key: 'ArrowRight', code: 39, charCode: 39}); - act(() => { - jest.runAllTimers(); - }); - - expect(document.activeElement).toEqual(input); - - fireEvent.keyDown(input, {key: 'Escape', code: 27, charCode: 27}); - fireEvent.keyUp(input, {key: 'Escape', code: 27, charCode: 27}); - act(() => { - jest.runAllTimers(); - }); - - expect(dialog).not.toBeInTheDocument(); - }); - }); - - describe('async loading', function () { - it('should display a spinner when loading', function () { - let tree = render( - - - Foo - Bar - - - {[]} - - - ); - - let table = tree.getByRole('grid'); - let rows = within(table).getAllByRole('row'); - expect(rows).toHaveLength(2); - expect(rows[1]).toHaveAttribute('aria-rowindex', '2'); - - let cell = within(rows[1]).getByRole('rowheader'); - expect(cell).toHaveAttribute('aria-colspan', '3'); - - let spinner = within(cell).getByRole('progressbar'); - expect(spinner).toBeVisible(); - expect(spinner).toHaveAttribute('aria-label', 'Loading…'); - expect(spinner).not.toHaveAttribute('aria-valuenow'); - - rerender(tree, defaultTable); - act(() => jest.runAllTimers()); - - rows = within(table).getAllByRole('row'); - expect(rows).toHaveLength(3); - expect(spinner).not.toBeInTheDocument(); - }); - - it('should display a spinner at the bottom when loading more', function () { - let tree = render( - - - Foo - Bar - - - - Foo 1 - Bar 1 - - - Foo 2 - Bar 2 - - - - ); - - let table = tree.getByRole('grid'); - let rows = within(table).getAllByRole('row'); - expect(rows).toHaveLength(4); - expect(rows[3]).toHaveAttribute('aria-rowindex', '4'); - - let cell = within(rows[3]).getByRole('rowheader'); - expect(cell).toHaveAttribute('aria-colspan', '2'); - - let spinner = within(cell).getByRole('progressbar'); - expect(spinner).toBeVisible(); - expect(spinner).toHaveAttribute('aria-label', 'Loading more…'); - expect(spinner).not.toHaveAttribute('aria-valuenow'); - - rerender(tree, defaultTable); - act(() => jest.runAllTimers()); - - rows = within(table).getAllByRole('row'); - expect(rows).toHaveLength(3); - expect(spinner).not.toBeInTheDocument(); - }); - - it('should not display a spinner when filtering', function () { - let tree = render( - - - Foo - Bar - - - - Foo 1 - Bar 1 - - - Foo 2 - Bar 2 - - - - ); - - let table = tree.getByRole('grid'); - expect(within(table).queryByRole('progressbar')).toBeNull(); - }); - - it('should fire onLoadMore when scrolling near the bottom', function () { - let scrollHeightMock = jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 4100); - let items = []; - for (let i = 1; i <= 100; i++) { - items.push({id: i, foo: 'Foo ' + i, bar: 'Bar ' + i}); - } - - let onLoadMore = jest.fn(); - let tree = render( - - - Foo - Bar - - - {row => ( - - {key => {row[key]}} - - )} - - - ); - - let body = tree.getAllByRole('rowgroup')[1]; - let scrollView = body; - - let rows = within(body).getAllByRole('row'); - expect(rows).toHaveLength(34); // each row is 41px tall. table is 1000px tall. 25 rows fit. + 1/3 overscan - - scrollView.scrollTop = 250; - fireEvent.scroll(scrollView); - act(() => {jest.runAllTimers();}); - - scrollView.scrollTop = 1500; - fireEvent.scroll(scrollView); - act(() => {jest.runAllTimers();}); - - scrollView.scrollTop = 2800; - fireEvent.scroll(scrollView); - act(() => {jest.runAllTimers();}); - - expect(onLoadMore).toHaveBeenCalledTimes(1); - scrollHeightMock.mockReset(); - }); - - it('should automatically fire onLoadMore if there aren\'t enough items to fill the Table', function () { - let scrollHeightMock = jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 1000); - let items = [{id: 1, foo: 'Foo 1', bar: 'Bar 1'}]; - let onLoadMore = jest.fn(() => { - scrollHeightMock = jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 2000); - }); - - let TableMock = (props) => ( - - - Foo - Bar - - - {row => ( - - {key => {row[key]}} - - )} - - - ); - - render(); - act(() => jest.runAllTimers()); - expect(onLoadMore).toHaveBeenCalledTimes(1); - scrollHeightMock.mockReset(); - }); - }); - - describe('sorting', function () { - it('should set the proper aria-describedby and aria-sort on sortable column headers', function () { - let tree = render( - - - Foo - Bar - Baz - - - - Foo 1 - Bar 1 - Baz 1 - - - - ); - - let table = tree.getByRole('grid'); - let columnheaders = within(table).getAllByRole('columnheader'); - expect(columnheaders).toHaveLength(3); - expect(columnheaders[0]).toHaveAttribute('aria-sort', 'none'); - expect(columnheaders[1]).toHaveAttribute('aria-sort', 'none'); - expect(columnheaders[2]).not.toHaveAttribute('aria-sort'); - expect(columnheaders[0]).toHaveAttribute('aria-describedby'); - expect(document.getElementById(columnheaders[0].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); - expect(columnheaders[1]).toHaveAttribute('aria-describedby'); - expect(document.getElementById(columnheaders[1].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); - expect(columnheaders[2]).not.toHaveAttribute('aria-describedby'); - }); - - it('should set the proper aria-describedby and aria-sort on an ascending sorted column header', function () { - let tree = render( - - - Foo - Bar - Baz - - - - Foo 1 - Bar 1 - Baz 1 - - - - ); - - let table = tree.getByRole('grid'); - let columnheaders = within(table).getAllByRole('columnheader'); - expect(columnheaders).toHaveLength(3); - expect(columnheaders[0]).toHaveAttribute('aria-sort', 'none'); - expect(columnheaders[1]).toHaveAttribute('aria-sort', 'ascending'); - expect(columnheaders[2]).not.toHaveAttribute('aria-sort'); - expect(columnheaders[0]).toHaveAttribute('aria-describedby'); - expect(document.getElementById(columnheaders[0].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); - expect(columnheaders[1]).toHaveAttribute('aria-describedby'); - expect(document.getElementById(columnheaders[1].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); - expect(columnheaders[2]).not.toHaveAttribute('aria-describedby'); - }); - - it('should set the proper aria-describedby and aria-sort on an descending sorted column header', function () { - let tree = render( - - - Foo - Bar - Baz - - - - Foo 1 - Bar 1 - Baz 1 - - - - ); - - let table = tree.getByRole('grid'); - let columnheaders = within(table).getAllByRole('columnheader'); - expect(columnheaders).toHaveLength(3); - expect(columnheaders[0]).toHaveAttribute('aria-sort', 'none'); - expect(columnheaders[1]).toHaveAttribute('aria-sort', 'descending'); - expect(columnheaders[2]).not.toHaveAttribute('aria-sort'); - expect(columnheaders[0]).toHaveAttribute('aria-describedby'); - expect(document.getElementById(columnheaders[0].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); - expect(columnheaders[1]).toHaveAttribute('aria-describedby'); - expect(document.getElementById(columnheaders[1].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); - expect(columnheaders[2]).not.toHaveAttribute('aria-describedby'); - }); - - it('should add sort direction info to the column header\'s aria-describedby for Android', async function () { - let uaMock = jest.spyOn(navigator, 'userAgent', 'get').mockImplementation(() => 'Android'); - let tree = render(); - let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')}); - tableTester.setInteractionType('keyboard'); - let columnheaders = tableTester.columns; - expect(columnheaders).toHaveLength(3); - expect(columnheaders[0]).not.toHaveAttribute('aria-sort'); - expect(columnheaders[1]).not.toHaveAttribute('aria-sort'); - expect(columnheaders[2]).not.toHaveAttribute('aria-sort'); - expect(columnheaders[0]).toHaveAttribute('aria-describedby'); - expect(document.getElementById(columnheaders[0].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); - expect(columnheaders[1]).toHaveAttribute('aria-describedby'); - expect(document.getElementById(columnheaders[1].getAttribute('aria-describedby'))).toHaveTextContent('sortable column, ascending'); - expect(columnheaders[2]).not.toHaveAttribute('aria-describedby'); - - await tableTester.toggleSort({column: 1}); - expect(document.getElementById(columnheaders[1].getAttribute('aria-describedby'))).toHaveTextContent('sortable column, descending'); - - uaMock.mockRestore(); - }); - - it('should fire onSortChange when there is no existing sortDescriptor', async function () { - let onSortChange = jest.fn(); - let tree = render( - - - Foo - Bar - Baz - - - - Foo 1 - Bar 1 - Baz 1 - - - - ); - - let table = tree.getByRole('grid'); - let columnheaders = within(table).getAllByRole('columnheader'); - expect(columnheaders).toHaveLength(3); - expect(columnheaders[0]).toHaveAttribute('aria-sort', 'none'); - expect(columnheaders[1]).toHaveAttribute('aria-sort', 'none'); - expect(columnheaders[2]).not.toHaveAttribute('aria-sort'); - expect(columnheaders[0]).toHaveAttribute('aria-describedby'); - expect(document.getElementById(columnheaders[0].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); - expect(columnheaders[1]).toHaveAttribute('aria-describedby'); - expect(document.getElementById(columnheaders[1].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); - expect(columnheaders[2]).not.toHaveAttribute('aria-describedby'); - - await user.click(columnheaders[0]); - - expect(onSortChange).toHaveBeenCalledTimes(1); - expect(onSortChange).toHaveBeenCalledWith({column: 'foo', direction: 'ascending'}); - }); - - it('should toggle the sort direction from ascending to descending', async function () { - let onSortChange = jest.fn(); - let tree = render( - - - Foo - Bar - Baz - - - - Foo 1 - Bar 1 - Baz 1 - - - - ); - - let table = tree.getByRole('grid'); - let columnheaders = within(table).getAllByRole('columnheader'); - expect(columnheaders).toHaveLength(3); - expect(columnheaders[0]).toHaveAttribute('aria-sort', 'ascending'); - expect(columnheaders[1]).toHaveAttribute('aria-sort', 'none'); - expect(columnheaders[2]).not.toHaveAttribute('aria-sort'); - expect(columnheaders[0]).toHaveAttribute('aria-describedby'); - expect(document.getElementById(columnheaders[0].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); - expect(columnheaders[1]).toHaveAttribute('aria-describedby'); - expect(document.getElementById(columnheaders[1].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); - expect(columnheaders[2]).not.toHaveAttribute('aria-describedby'); - - await user.click(columnheaders[0]); - - expect(onSortChange).toHaveBeenCalledTimes(1); - expect(onSortChange).toHaveBeenCalledWith({column: 'foo', direction: 'descending'}); - }); - - it('should toggle the sort direction from descending to ascending', async function () { - let onSortChange = jest.fn(); - let tree = render( - - - Foo - Bar - Baz - - - - Foo 1 - Bar 1 - Baz 1 - - - - ); - - let table = tree.getByRole('grid'); - let columnheaders = within(table).getAllByRole('columnheader'); - expect(columnheaders).toHaveLength(3); - expect(columnheaders[0]).toHaveAttribute('aria-describedby'); - expect(columnheaders[0]).toHaveAttribute('aria-sort', 'descending'); - expect(columnheaders[1]).toHaveAttribute('aria-sort', 'none'); - expect(columnheaders[2]).not.toHaveAttribute('aria-sort'); - expect(document.getElementById(columnheaders[0].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); - expect(columnheaders[1]).toHaveAttribute('aria-describedby'); - expect(document.getElementById(columnheaders[1].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); - expect(columnheaders[2]).not.toHaveAttribute('aria-describedby'); - - await user.click(columnheaders[0]); - - expect(onSortChange).toHaveBeenCalledTimes(1); - expect(onSortChange).toHaveBeenCalledWith({column: 'foo', direction: 'ascending'}); - }); - - it('should trigger sorting on a different column', async function () { - let onSortChange = jest.fn(); - let tree = render( - - - Foo - Bar - Baz - - - - Foo 1 - Bar 1 - Baz 1 - - - - ); - - let table = tree.getByRole('grid'); - let columnheaders = within(table).getAllByRole('columnheader'); - expect(columnheaders).toHaveLength(3); - expect(columnheaders[0]).toHaveAttribute('aria-sort', 'ascending'); - expect(columnheaders[1]).toHaveAttribute('aria-sort', 'none'); - expect(columnheaders[2]).not.toHaveAttribute('aria-sort'); - expect(columnheaders[0]).toHaveAttribute('aria-describedby'); - expect(document.getElementById(columnheaders[0].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); - expect(columnheaders[1]).toHaveAttribute('aria-describedby'); - expect(document.getElementById(columnheaders[1].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); - expect(columnheaders[2]).not.toHaveAttribute('aria-describedby'); - - await user.click(columnheaders[1]); - - expect(onSortChange).toHaveBeenCalledTimes(1); - expect(onSortChange).toHaveBeenCalledWith({column: 'bar', direction: 'ascending'}); - }); - }); - - describe('empty state', function () { - it('should display an empty state when there are no items', async function () { - let tree = render( -

No results

}> - - Foo - Bar - - - {[]} - -
- ); - - await act(() => Promise.resolve()); // wait for MutationObserver in useHasTabbableChild or we get act warnings - - let table = tree.getByRole('grid'); - let rows = within(table).getAllByRole('row'); - expect(rows).toHaveLength(2); - expect(rows[1]).toHaveAttribute('aria-rowindex', '2'); - - let cell = within(rows[1]).getByRole('rowheader'); - expect(cell).toHaveAttribute('aria-colspan', '2'); - - let heading = within(cell).getByRole('heading'); - expect(heading).toBeVisible(); - expect(heading).toHaveTextContent('No results'); - - rerender(tree, defaultTable); - act(() => jest.runAllTimers()); - - rows = within(table).getAllByRole('row'); - expect(rows).toHaveLength(3); - expect(heading).not.toBeInTheDocument(); - }); - - it('empty table select all should be disabled', async function () { - let onSelectionChange = jest.fn(); - let tree = render( -
-

No results

}> - - Foo - Bar - - - {[]} - -
- -
- ); - - await act(() => Promise.resolve()); - - let table = tree.getByRole('grid'); - let selectAll = tree.getByRole('checkbox'); - - await user.tab(); - expect(document.activeElement).toBe(table); - await user.tab(); - expect(document.activeElement).not.toBe(selectAll); - expect(selectAll).toHaveAttribute('disabled'); - }); - - it('should allow the user to tab into the table body', async function () { - let tree = render(); - await act(() => Promise.resolve()); - let toggleButton = tree.getAllByRole('button')[0]; - let link = tree.getByRole('link'); - - await user.tab(); - expect(document.activeElement).toBe(toggleButton); - await user.tab(); - expect(document.activeElement).toBe(link); - }); - - it('should disable keyboard navigation within the table', async function () { - let tree = render(); - await act(() => Promise.resolve()); - let table = tree.getByRole('grid'); - let header = within(table).getAllByRole('columnheader')[2]; - expect(header).toHaveAttribute('tabindex', '-1'); - let headerButton = within(header).getByRole('button'); - expect(headerButton).toHaveAttribute('aria-disabled', 'true'); - }); - - it('should shift focus to the table if table becomes empty via column sort', function () { - let tree = render(); - let rows = tree.getAllByRole('row'); - expect(rows).toHaveLength(3); - focusCell(tree, 'Height'); - expect(document.activeElement).toHaveTextContent('Height'); - fireEvent.keyDown(document.activeElement, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement, {key: 'Enter'}); - act(() => jest.advanceTimersByTime(500)); - let table = tree.getByRole('grid'); - expect(document.activeElement).toBe(table); - // Run the rest of the timeout and run the transitions - act(() => {jest.runAllTimers();}); - act(() => {jest.runAllTimers();}); - rows = tree.getAllByRole('row'); - expect(rows).toHaveLength(2); - }); - - it('should disable press interactions with the column headers', async function () { - let tree = render(); - await act(() => Promise.resolve()); - let table = tree.getByRole('grid'); - let headers = within(table).getAllByRole('columnheader'); - let toggleButton = tree.getAllByRole('button')[0]; - - await user.tab(); - expect(document.activeElement).toBe(toggleButton); - - let columnButton = within(headers[2]).getByRole('button'); - await user.click(columnButton); - expect(document.activeElement).toBe(toggleButton); - expect(tree.queryByRole('menuitem')).toBeFalsy(); - fireEvent.mouseEnter(headers[2]); - act(() => {jest.runAllTimers();}); - expect(tree.queryByRole('slider')).toBeFalsy(); - }); - - it.skip('should re-enable functionality when the table recieves items', async function () { - let tree = render(); - let table = tree.getByRole('grid'); - let headers = within(table).getAllByRole('columnheader'); - let toggleButton = tree.getAllByRole('button')[0]; - let selectAll = tree.getByRole('checkbox'); - - await user.tab(); - expect(document.activeElement).toBe(toggleButton); - await user.click(toggleButton); - act(() => {jest.runAllTimers();}); - - expect(selectAll).not.toHaveAttribute('disabled'); - await user.click(selectAll); - act(() => {jest.runAllTimers();}); - expect(selectAll.checked).toBeTruthy(); - expect(document.activeElement).toBe(selectAll); - - fireEvent.mouseEnter(headers[2]); - act(() => {jest.runAllTimers();}); - expect(tree.queryAllByRole('slider')).toBeTruthy(); - - let column1Button = within(headers[1]).getByRole('button'); - let column2Button = within(headers[2]).getByRole('button'); - await user.click(column2Button); - act(() => {jest.runAllTimers();}); - expect(tree.queryAllByRole('menuitem')).toBeTruthy(); - await user.keyboard('{Escape}'); - act(() => {jest.runAllTimers();}); - act(() => {jest.runAllTimers();}); - expect(document.activeElement).toBe(column2Button); - await user.keyboard('{ArrowLeft}'); - expect(document.activeElement).toBe(column1Button); - - await user.click(toggleButton); - act(() => {jest.runAllTimers();}); - expect(selectAll).toHaveAttribute('disabled'); - await user.click(headers[2]); - expect(document.activeElement).toBe(toggleButton); - await user.tab(); - expect(document.activeElement).toBe(table); - expect(table).toHaveAttribute('tabIndex', '0'); - }); - }); - - describe('links', function () { - describe.each(['mouse', 'keyboard'])('%s', (type) => { - let trigger = async (item, key = 'Enter') => { - if (type === 'mouse') { - await user.click(item); - } else { - fireEvent.keyDown(item, {key}); - fireEvent.keyUp(item, {key}); - } - }; - - it('should support links with selectionMode="none"', async function () { - let {getAllByRole} = render( - - - - Foo - Bar - Baz - - - - Foo 1 - Bar 1 - Baz 1 - - - Foo 2 - Bar 2 - Baz 2 - - - - - ); - - let items = getAllByRole('row').slice(1); - for (let item of items) { - expect(item.tagName).not.toBe('A'); - expect(item).toHaveAttribute('data-href'); - } - - let onClick = mockClickDefault(); - await trigger(items[0]); - expect(onClick).toHaveBeenCalledTimes(1); - expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); - expect(onClick.mock.calls[0][0].target.href).toBe('https://google.com/'); - }); - - it.each(['single', 'multiple'])('should support links with selectionStyle="checkbox" selectionMode="%s"', async function (selectionMode) { - let {getAllByRole} = render( - - - - Foo - Bar - Baz - - - - Foo 1 - Bar 1 - Baz 1 - - - Foo 2 - Bar 2 - Baz 2 - - - - - ); - - let items = getAllByRole('row').slice(1); - for (let item of items) { - expect(item.tagName).not.toBe('A'); - expect(item).toHaveAttribute('data-href'); - } - - let onClick = mockClickDefault(); - await trigger(items[0]); - expect(onClick).toHaveBeenCalledTimes(1); - expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); - expect(onClick.mock.calls[0][0].target.href).toBe('https://google.com/'); - - await user.click(within(items[0]).getByRole('checkbox')); - expect(items[0]).toHaveAttribute('aria-selected', 'true'); - - await trigger(items[1], ' '); - expect(onClick).toHaveBeenCalledTimes(1); - expect(items[1]).toHaveAttribute('aria-selected', 'true'); - document.removeEventListener('click', onClick); - }); - - it.each(['single', 'multiple'])('should support links with selectionStyle="highlight" selectionMode="%s"', async function (selectionMode) { - let {getAllByRole} = render( - - - - Foo - Bar - Baz - - - - Foo 1 - Bar 1 - Baz 1 - - - Foo 2 - Bar 2 - Baz 2 - - - - - ); - - let items = getAllByRole('row').slice(1); - for (let item of items) { - expect(item.tagName).not.toBe('A'); - expect(item).toHaveAttribute('data-href'); - } - - let onClick = mockClickDefault(); - await trigger(items[0], ' '); - expect(onClick).not.toHaveBeenCalled(); - expect(items[0]).toHaveAttribute('aria-selected', 'true'); - document.removeEventListener('click', onClick); - - if (type === 'mouse') { - await user.dblClick(items[0], {pointerType: 'mouse'}); - } else { - fireEvent.keyDown(items[0], {key: 'Enter'}); - fireEvent.keyUp(items[0], {key: 'Enter'}); - } - expect(onClick).toHaveBeenCalledTimes(1); - expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); - expect(onClick.mock.calls[0][0].target.href).toBe('https://google.com/'); - }); - - it('should work with RouterProvider', async () => { - let navigate = jest.fn(); - let {getAllByRole} = render( - - - - Foo - Bar - Baz - - - - Foo 1 - Bar 1 - Baz 1 - - - Foo 2 - Bar 2 - Baz 2 - - - - - ); - - let items = getAllByRole('row').slice(1); - await trigger(items[0]); - expect(navigate).toHaveBeenCalledWith('/one', {foo: 'bar'}); - - navigate.mockReset(); - let onClick = mockClickDefault(); - - await trigger(items[1]); - expect(navigate).not.toHaveBeenCalled(); - expect(onClick).toHaveBeenCalledTimes(1); - }); - }); - }); -}; +import {tableTests} from './TableTests'; describe('TableView', () => { tableTests(); diff --git a/packages/@react-spectrum/table/test/TableDnd.test.js b/packages/@react-spectrum/table/test/TableDnd.test.js index d4be98204c6..a97231f92a7 100644 --- a/packages/@react-spectrum/table/test/TableDnd.test.js +++ b/packages/@react-spectrum/table/test/TableDnd.test.js @@ -35,7 +35,9 @@ import {useDragAndDrop} from '@react-spectrum/dnd'; import {useListData} from '@react-stately/data'; import userEvent from '@testing-library/user-event'; -let isReact18 = parseInt(React.version, 10) >= 18; +// getComputedStyle is very slow in our version of jsdom. +// These tests only care about direct inline styles. We can avoid parsing other stylesheets. +window.getComputedStyle = (el) => el.style; describe('TableView', function () { let offsetWidth, offsetHeight, scrollHeight; @@ -64,8 +66,8 @@ describe('TableView', function () { beforeAll(function () { user = userEvent.setup({delay: null, pointerMap}); - offsetWidth = jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 1000); - offsetHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 1000); + offsetWidth = jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 400); + offsetHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 300); scrollHeight = jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 40); jest.useFakeTimers(); }); diff --git a/packages/@react-spectrum/table/test/TableNestedRows.test.js b/packages/@react-spectrum/table/test/TableNestedRows.test.js index 415b76eb2f1..a839f58a003 100644 --- a/packages/@react-spectrum/table/test/TableNestedRows.test.js +++ b/packages/@react-spectrum/table/test/TableNestedRows.test.js @@ -17,7 +17,7 @@ import {Cell, Column, Row, TableBody, TableHeader, TableView} from '../'; import {enableTableNestedRows} from '@react-stately/flags'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; -import {tableTests} from './Table.test'; +import {tableTests} from './TableTests'; import {theme} from '@react-spectrum/theme-default'; describe('TableView with expandable rows flag on', function () { diff --git a/packages/@react-spectrum/table/test/TableTests.js b/packages/@react-spectrum/table/test/TableTests.js new file mode 100644 index 00000000000..d9f2065132d --- /dev/null +++ b/packages/@react-spectrum/table/test/TableTests.js @@ -0,0 +1,5070 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +jest.mock('@react-aria/live-announcer'); +jest.mock('@react-aria/utils/src/scrollIntoView'); +import {act, fireEvent, installPointerEvent, mockClickDefault, pointerMap, render as renderComponent, User, within} from '@react-spectrum/test-utils-internal'; +import {ActionButton, Button} from '@react-spectrum/button'; +import Add from '@spectrum-icons/workflow/Add'; +import {announce} from '@react-aria/live-announcer'; +import {ButtonGroup} from '@react-spectrum/buttongroup'; +import {Cell, Column, Row, TableBody, TableHeader, TableView} from '../'; +import {composeStories} from '@storybook/react'; +import {Content} from '@react-spectrum/view'; +import {CRUDExample} from '../stories/CRUDExample'; +import {Dialog, DialogTrigger} from '@react-spectrum/dialog'; +import {Divider} from '@react-spectrum/divider'; +import {getFocusableTreeWalker} from '@react-aria/focus'; +import {Heading} from '@react-spectrum/text'; +import {Item, Picker} from '@react-spectrum/picker'; +import {Link} from '@react-spectrum/link'; +import {Provider} from '@react-spectrum/provider'; +import React from 'react'; +import {scrollIntoView} from '@react-aria/utils'; +import * as stories from '../stories/Table.stories'; +import {Switch} from '@react-spectrum/switch'; +import {TextField} from '@react-spectrum/textfield'; +import {theme} from '@react-spectrum/theme-default'; +import userEvent from '@testing-library/user-event'; + +let { + InlineDeleteButtons: DeletableRowsTable, + EmptyStateStory: EmptyStateTable, + WithBreadcrumbNavigation: TableWithBreadcrumbs, + TypeaheadWithDialog: TypeaheadWithDialog, + ColumnHeaderFocusRingTable +} = composeStories(stories); + + +let columns = [ + {name: 'Foo', key: 'foo'}, + {name: 'Bar', key: 'bar'}, + {name: 'Baz', key: 'baz'} +]; + +let nestedColumns = [ + {name: 'Test', key: 'test'}, + {name: 'Tiered One Header', key: 'tier1', children: [ + {name: 'Tier Two Header A', key: 'tier2a', children: [ + {name: 'Foo', key: 'foo'}, + {name: 'Bar', key: 'bar'} + ]}, + {name: 'Yay', key: 'yay'}, + {name: 'Tier Two Header B', key: 'tier2b', children: [ + {name: 'Baz', key: 'baz'} + ]} + ]} +]; + +let items = [ + {test: 'Test 1', foo: 'Foo 1', bar: 'Bar 1', yay: 'Yay 1', baz: 'Baz 1'}, + {test: 'Test 2', foo: 'Foo 2', bar: 'Bar 2', yay: 'Yay 2', baz: 'Baz 2'} +]; + +let itemsWithFalsyId = [ + {test: 'Test 1', foo: 'Foo 1', bar: 'Bar 1', yay: 'Yay 1', baz: 'Baz 1', id: 0}, + {test: 'Test 2', foo: 'Foo 2', bar: 'Bar 2', yay: 'Yay 2', baz: 'Baz 2', id: 1} +]; + +let manyItems = []; +for (let i = 1; i <= 25; i++) { + manyItems.push({id: i, foo: 'Foo ' + i, bar: 'Bar ' + i, baz: 'Baz ' + i}); +} + +let manyColumns = []; +for (let i = 1; i <= 20; i++) { + manyColumns.push({id: i, name: 'Column ' + i}); +} + +// getComputedStyle is very slow in our version of jsdom. +// These tests only care about direct inline styles. We can avoid parsing other stylesheets. +window.getComputedStyle = (el) => el.style; + +function ExampleSortTable() { + let [sortDescriptor, setSortDescriptor] = React.useState({column: 'bar', direction: 'ascending'}); + + return ( + + + Foo + Bar + Baz + + + + Foo 1 + Bar 1 + Baz 1 + + + + ); +} + +let defaultTable = ( + + + Foo + Bar + + + + Foo 1 + Bar 1 + + + Foo 2 + Bar 2 + + + +); + +function pointerEvent(type, opts) { + let evt = new Event(type, {bubbles: true, cancelable: true}); + Object.assign(evt, { + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + button: opts.button || 0, + width: opts.width == null ? undefined : opts.width ?? 1, + height: opts.height == null ? undefined : opts.height ?? 1 + }, opts); + return evt; +} + +export let tableTests = () => { + // Temporarily disabling these tests in React 16 because they run into a memory limit and crash. + // TODO: investigate. + if (parseInt(React.version, 10) <= 16) { + return; + } + + let offsetWidth, offsetHeight; + let user; + let testUtilUser = new User({advanceTimer: (time) => jest.advanceTimersByTime(time)}); + + beforeAll(function () { + user = userEvent.setup({delay: null, pointerMap}); + offsetWidth = jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 400); + offsetHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 200); + jest.useFakeTimers(); + }); + + afterAll(function () { + offsetWidth.mockReset(); + offsetHeight.mockReset(); + }); + + afterEach(() => { + act(() => {jest.runAllTimers();}); + }); + + let render = (children, scale = 'medium') => { + let tree = renderComponent( + + {children} + + ); + // account for table column resizing to do initial pass due to relayout from useTableColumnResizeState render + act(() => {jest.runAllTimers();}); + return tree; + }; + + let rerender = (tree, children, scale = 'medium') => { + let newTree = tree.rerender( + + {children} + + ); + act(() => {jest.runAllTimers();}); + return newTree; + }; + // I'd use tree.getByRole(role, {name: text}) here, but it's unbearably slow. + let getCell = (tree, text) => { + // Find by text, then go up to the element with the cell role. + let el = tree.getByText(text); + while (el && !/gridcell|rowheader|columnheader/.test(el.getAttribute('role'))) { + el = el.parentElement; + } + + return el; + }; + + let focusCell = (tree, text) => act(() => getCell(tree, text).focus()); + let moveFocus = (key, opts = {}) => {fireEvent.keyDown(document.activeElement, {key, ...opts});}; + + it('renders a static table', function () { + let {getByRole} = render( + + + Foo + Bar + Baz + + + + Foo 1 + Bar 1 + Baz 1 + + + Foo 2 + Bar 2 + Baz 2 + + + + ); + + let grid = getByRole('grid'); + expect(grid).toBeVisible(); + expect(grid).toHaveAttribute('aria-label', 'Table'); + expect(grid).toHaveAttribute('data-testid', 'test'); + + expect(grid).toHaveAttribute('aria-rowcount', '3'); + expect(grid).toHaveAttribute('aria-colcount', '3'); + + let rowgroups = within(grid).getAllByRole('rowgroup'); + expect(rowgroups).toHaveLength(2); + + let headerRows = within(rowgroups[0]).getAllByRole('row'); + expect(headerRows).toHaveLength(1); + expect(headerRows[0]).toHaveAttribute('aria-rowindex', '1'); + + let headers = within(grid).getAllByRole('columnheader'); + expect(headers).toHaveLength(3); + expect(headers[0]).toHaveAttribute('aria-colindex', '1'); + expect(headers[1]).toHaveAttribute('aria-colindex', '2'); + expect(headers[2]).toHaveAttribute('aria-colindex', '3'); + + for (let header of headers) { + expect(header).not.toHaveAttribute('aria-sort'); + expect(header).not.toHaveAttribute('aria-describedby'); + } + + expect(headers[0]).toHaveTextContent('Foo'); + expect(headers[1]).toHaveTextContent('Bar'); + expect(headers[2]).toHaveTextContent('Baz'); + + let rows = within(rowgroups[1]).getAllByRole('row'); + expect(rows).toHaveLength(2); + expect(rows[0]).toHaveAttribute('aria-rowindex', '2'); + expect(rows[1]).toHaveAttribute('aria-rowindex', '3'); + + let rowheader = within(rows[0]).getByRole('rowheader'); + let cellSpan = within(rowheader).getByText('Foo 1'); + expect(rowheader).toHaveTextContent('Foo 1'); + expect(rowheader).toHaveAttribute('aria-colindex', '1'); + + expect(rows[0]).toHaveAttribute('aria-labelledby', cellSpan.id); + + rowheader = within(rows[1]).getByRole('rowheader'); + cellSpan = within(rowheader).getByText('Foo 2'); + expect(rowheader).toHaveTextContent('Foo 2'); + expect(rowheader).toHaveAttribute('aria-colindex', '1'); + + expect(rows[1]).not.toHaveAttribute('aria-selected'); + expect(rows[1]).toHaveAttribute('aria-labelledby', cellSpan.id); + + + let cells = within(rowgroups[1]).getAllByRole('gridcell'); + expect(cells).toHaveLength(4); + + expect(cells[0]).toHaveAttribute('aria-colindex', '2'); + expect(cells[1]).toHaveAttribute('aria-colindex', '3'); + expect(cells[2]).toHaveAttribute('aria-colindex', '2'); + expect(cells[3]).toHaveAttribute('aria-colindex', '3'); + }); + + it('renders a static table with selection', function () { + let {getByRole} = render( + + + Foo + Bar + Baz + + + + Foo 1 + Bar 1 + Baz 1 + + + Foo 2 + Bar 2 + Baz 2 + + + + ); + + let grid = getByRole('grid'); + expect(grid).toBeVisible(); + expect(grid).toHaveAttribute('aria-label', 'Table'); + expect(grid).toHaveAttribute('data-testid', 'test'); + expect(grid).toHaveAttribute('aria-multiselectable', 'true'); + expect(grid).toHaveAttribute('aria-rowcount', '3'); + expect(grid).toHaveAttribute('aria-colcount', '4'); + + let rowgroups = within(grid).getAllByRole('rowgroup'); + expect(rowgroups).toHaveLength(2); + + let headerRows = within(rowgroups[0]).getAllByRole('row'); + expect(headerRows).toHaveLength(1); + expect(headerRows[0]).toHaveAttribute('aria-rowindex', '1'); + + let headers = within(grid).getAllByRole('columnheader'); + expect(headers).toHaveLength(4); + expect(headers[0]).toHaveAttribute('aria-colindex', '1'); + expect(headers[1]).toHaveAttribute('aria-colindex', '2'); + expect(headers[2]).toHaveAttribute('aria-colindex', '3'); + expect(headers[3]).toHaveAttribute('aria-colindex', '4'); + + for (let header of headers) { + expect(header).not.toHaveAttribute('aria-sort'); + expect(header).not.toHaveAttribute('aria-describedby'); + } + + let checkbox = within(headers[0]).getByRole('checkbox'); + expect(checkbox).toHaveAttribute('aria-label', 'Select All'); + + expect(headers[1]).toHaveTextContent('Foo'); + expect(headers[2]).toHaveTextContent('Bar'); + expect(headers[3]).toHaveTextContent('Baz'); + + let rows = within(rowgroups[1]).getAllByRole('row'); + expect(rows).toHaveLength(2); + expect(rows[0]).toHaveAttribute('aria-rowindex', '2'); + expect(rows[1]).toHaveAttribute('aria-rowindex', '3'); + + let rowheader = within(rows[0]).getByRole('rowheader'); + let cellSpan = within(rowheader).getByText('Foo 1'); + expect(rowheader).toHaveTextContent('Foo 1'); + expect(rowheader).toHaveAttribute('aria-colindex', '2'); + + expect(rows[0]).toHaveAttribute('aria-selected', 'false'); + expect(rows[0]).toHaveAttribute('aria-labelledby', cellSpan.id); + + checkbox = within(rows[0]).getByRole('checkbox'); + expect(checkbox).toHaveAttribute('aria-label', 'Select'); + expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${cellSpan.id}`); + + rowheader = within(rows[1]).getByRole('rowheader'); + cellSpan = within(rowheader).getByText('Foo 2'); + expect(rowheader).toHaveTextContent('Foo 2'); + expect(rowheader).toHaveAttribute('aria-colindex', '2'); + + expect(rows[1]).toHaveAttribute('aria-selected', 'false'); + expect(rows[1]).toHaveAttribute('aria-labelledby', cellSpan.id); + + + checkbox = within(rows[1]).getByRole('checkbox'); + expect(checkbox).toHaveAttribute('aria-label', 'Select'); + expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${cellSpan.id}`); + + let cells = within(rowgroups[1]).getAllByRole('gridcell'); + expect(cells).toHaveLength(6); + + expect(cells[0]).toHaveAttribute('aria-colindex', '1'); + expect(cells[1]).toHaveAttribute('aria-colindex', '3'); + expect(cells[2]).toHaveAttribute('aria-colindex', '4'); + expect(cells[3]).toHaveAttribute('aria-colindex', '1'); + expect(cells[4]).toHaveAttribute('aria-colindex', '3'); + expect(cells[5]).toHaveAttribute('aria-colindex', '4'); + }); + + it('accepts a UNSAFE_className', function () { + let {getByRole} = render( + + + Foo + Bar + Baz + + + + Foo 1 + Bar 1 + Baz 1 + + + Foo 2 + Bar 2 + Baz 2 + + + + ); + + let grid = getByRole('grid'); + expect(grid).toHaveAttribute('class', expect.stringContaining('test-class')); + }); + + it('renders a dynamic table', function () { + let {getByRole} = render( + + + {column => {column.name}} + + + {item => + ( + {key => {item[key]}} + ) + } + + + ); + + let grid = getByRole('grid'); + expect(grid).toBeVisible(); + expect(grid).toHaveAttribute('aria-rowcount', '3'); + expect(grid).toHaveAttribute('aria-colcount', '3'); + + let rowgroups = within(grid).getAllByRole('rowgroup'); + expect(rowgroups).toHaveLength(2); + + let headerRows = within(rowgroups[0]).getAllByRole('row'); + expect(headerRows).toHaveLength(1); + expect(headerRows[0]).toHaveAttribute('aria-rowindex', '1'); + + let headers = within(grid).getAllByRole('columnheader'); + expect(headers).toHaveLength(3); + expect(headers[0]).toHaveAttribute('aria-colindex', '1'); + expect(headers[1]).toHaveAttribute('aria-colindex', '2'); + expect(headers[2]).toHaveAttribute('aria-colindex', '3'); + + expect(headers[0]).toHaveTextContent('Foo'); + expect(headers[1]).toHaveTextContent('Bar'); + expect(headers[2]).toHaveTextContent('Baz'); + + let rows = within(rowgroups[1]).getAllByRole('row'); + expect(rows).toHaveLength(2); + + let rowheader = within(rows[0]).getByRole('rowheader'); + let cellSpan = within(rowheader).getByText('Foo 1'); + expect(rowheader).toHaveTextContent('Foo 1'); + expect(rowheader).toHaveAttribute('aria-colindex', '1'); + + expect(rows[0]).toHaveAttribute('aria-labelledby', cellSpan.id); + + rowheader = within(rows[1]).getByRole('rowheader'); + cellSpan = within(rowheader).getByText('Foo 2'); + expect(rowheader).toHaveTextContent('Foo 2'); + expect(rowheader).toHaveAttribute('aria-colindex', '1'); + + expect(rows[1]).not.toHaveAttribute('aria-selected'); + expect(rows[1]).toHaveAttribute('aria-labelledby', cellSpan.id); + + let cells = within(rowgroups[1]).getAllByRole('gridcell'); + expect(cells).toHaveLength(4); + + expect(cells[0]).toHaveAttribute('aria-colindex', '2'); + expect(cells[1]).toHaveAttribute('aria-colindex', '3'); + expect(cells[2]).toHaveAttribute('aria-colindex', '2'); + expect(cells[3]).toHaveAttribute('aria-colindex', '3'); + }); + + it('renders a dynamic table with selection', function () { + let {getByRole} = render( + + + {column => {column.name}} + + + {item => + ( + {key => {item[key]}} + ) + } + + + ); + + let grid = getByRole('grid'); + expect(grid).toBeVisible(); + expect(grid).toHaveAttribute('aria-multiselectable', 'true'); + expect(grid).toHaveAttribute('aria-rowcount', '3'); + expect(grid).toHaveAttribute('aria-colcount', '4'); + + let rowgroups = within(grid).getAllByRole('rowgroup'); + expect(rowgroups).toHaveLength(2); + + let headerRows = within(rowgroups[0]).getAllByRole('row'); + expect(headerRows).toHaveLength(1); + expect(headerRows[0]).toHaveAttribute('aria-rowindex', '1'); + + let headers = within(grid).getAllByRole('columnheader'); + expect(headers).toHaveLength(4); + expect(headers[0]).toHaveAttribute('aria-colindex', '1'); + expect(headers[1]).toHaveAttribute('aria-colindex', '2'); + expect(headers[2]).toHaveAttribute('aria-colindex', '3'); + expect(headers[3]).toHaveAttribute('aria-colindex', '4'); + + let checkbox = within(headers[0]).getByRole('checkbox'); + expect(checkbox).toHaveAttribute('aria-label', 'Select All'); + + expect(headers[1]).toHaveTextContent('Foo'); + expect(headers[2]).toHaveTextContent('Bar'); + expect(headers[3]).toHaveTextContent('Baz'); + + let rows = within(rowgroups[1]).getAllByRole('row'); + expect(rows).toHaveLength(2); + + let rowheader = within(rows[0]).getByRole('rowheader'); + let cellSpan = within(rowheader).getByText('Foo 1'); + expect(rowheader).toHaveTextContent('Foo 1'); + expect(rowheader).toHaveAttribute('aria-colindex', '2'); + + expect(rows[0]).toHaveAttribute('aria-selected', 'false'); + expect(rows[0]).toHaveAttribute('aria-labelledby', cellSpan.id); + + checkbox = within(rows[0]).getByRole('checkbox'); + expect(checkbox).toHaveAttribute('aria-label', 'Select'); + expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${cellSpan.id}`); + + rowheader = within(rows[1]).getByRole('rowheader'); + cellSpan = within(rowheader).getByText('Foo 2'); + expect(rowheader).toHaveTextContent('Foo 2'); + expect(rowheader).toHaveAttribute('aria-colindex', '2'); + + expect(rows[1]).toHaveAttribute('aria-selected', 'false'); + expect(rows[1]).toHaveAttribute('aria-labelledby', cellSpan.id); + + + checkbox = within(rows[1]).getByRole('checkbox'); + expect(checkbox).toHaveAttribute('aria-label', 'Select'); + expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${cellSpan.id}`); + + let cells = within(rowgroups[1]).getAllByRole('gridcell'); + expect(cells).toHaveLength(6); + + expect(cells[0]).toHaveAttribute('aria-colindex', '1'); + expect(cells[1]).toHaveAttribute('aria-colindex', '3'); + expect(cells[2]).toHaveAttribute('aria-colindex', '4'); + expect(cells[3]).toHaveAttribute('aria-colindex', '1'); + expect(cells[4]).toHaveAttribute('aria-colindex', '3'); + expect(cells[5]).toHaveAttribute('aria-colindex', '4'); + }); + + it('renders contents even with falsy row ids', function () { + // TODO: doesn't support empty string ids, fix for that to come + let {getByRole} = render( + + + {column => {column.name}} + + + {item => + ( + {key => {item[key]}} + ) + } + + + ); + + let grid = getByRole('grid'); + let rows = within(grid).getAllByRole('row'); + expect(rows).toHaveLength(3); + + for (let [i, row] of rows.entries()) { + if (i === 0) { + let columnheaders = within(row).getAllByRole('columnheader'); + expect(columnheaders).toHaveLength(3); + for (let [j, columnheader] of columnheaders.entries()) { + expect(within(columnheader).getByText(columns[j].name)).toBeTruthy(); + } + } else { + let rowheader = within(row).getByRole('rowheader'); + expect(within(rowheader).getByText(itemsWithFalsyId[i - 1][columns[0].key])).toBeTruthy(); + let cells = within(row).getAllByRole('gridcell'); + expect(cells).toHaveLength(2); + expect(within(cells[0]).getByText(itemsWithFalsyId[i - 1][columns[1].key])).toBeTruthy(); + expect(within(cells[1]).getByText(itemsWithFalsyId[i - 1][columns[2].key])).toBeTruthy(); + } + } + }); + + it('renders a static table with colspans', function () { + let {getByRole} = render( + + + Col 1 + Col 2 + Col 3 + Col 4 + + + + Cell + Span 2 + Cell + + + Cell + Cell + Cell + Cell + + + Span 4 + + + Span 3 + Cell + + + Cell + Span 3 + + + + ); + + let grid = getByRole('grid'); + expect(grid).toBeVisible(); + expect(grid).toHaveAttribute('aria-rowcount', '6'); + expect(grid).toHaveAttribute('aria-colcount', '4'); + + let rows = within(grid).getAllByRole('row'); + expect(rows).toHaveLength(6); + + let columnheaders = within(rows[0]).getAllByRole('columnheader'); + expect(columnheaders).toHaveLength(4); + + let cells1 = [...within(rows[1]).getAllByRole('rowheader'), ...within(rows[1]).getAllByRole('gridcell')]; + expect(cells1).toHaveLength(3); + expect(cells1[0]).toHaveAttribute('aria-colindex', '1'); + expect(cells1[1]).toHaveAttribute('aria-colindex', '2'); + expect(cells1[1]).toHaveAttribute('aria-colspan', '2'); + expect(cells1[2]).toHaveAttribute('aria-colindex', '4'); + + + let cells2 = [...within(rows[2]).getAllByRole('rowheader'), ...within(rows[2]).getAllByRole('gridcell')]; + expect(cells2).toHaveLength(4); + expect(cells2[0]).toHaveAttribute('aria-colindex', '1'); + expect(cells2[1]).toHaveAttribute('aria-colindex', '2'); + expect(cells2[2]).toHaveAttribute('aria-colindex', '3'); + expect(cells2[3]).toHaveAttribute('aria-colindex', '4'); + + let cells3 = within(rows[3]).getAllByRole('rowheader'); + expect(cells3).toHaveLength(1); + expect(cells3[0]).toHaveAttribute('aria-colindex', '1'); + expect(cells3[0]).toHaveAttribute('aria-colspan', '4'); + + let cells4 = [...within(rows[4]).getAllByRole('rowheader'), ...within(rows[4]).getAllByRole('gridcell')]; + expect(cells4).toHaveLength(2); + expect(cells4[0]).toHaveAttribute('aria-colindex', '1'); + expect(cells4[0]).toHaveAttribute('aria-colspan', '3'); + expect(cells4[1]).toHaveAttribute('aria-colindex', '4'); + + let cells5 = [...within(rows[5]).getAllByRole('rowheader'), ...within(rows[5]).getAllByRole('gridcell')]; + expect(cells5).toHaveLength(2); + expect(cells5[0]).toHaveAttribute('aria-colindex', '1'); + expect(cells5[1]).toHaveAttribute('aria-colindex', '2'); + expect(cells5[1]).toHaveAttribute('aria-colspan', '3'); + }); + + it('should throw error if number of cells do not match column count', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + try { + render( + + + Col 1 + Col 2 + Col 3 + Col 4 + + + + Cell 11 + Cell 12 + Cell 14 + + + Cell 21 + Cell 22 + Cell 24 + Cell 25 + + + + ); + } catch (e) { + expect(e.message).toEqual('Cell count must match column count. Found 5 cells and 4 columns.'); + } + try { + render( + + + Col 1 + Col 2 + + + + Cell + + + + ); + } catch (e) { + expect(e.message).toEqual('Cell count must match column count. Found 1 cells and 2 columns.'); + } + }); + + it('renders a static table with nested columns', function () { + let {getByRole} = render( + + + Test + + Foo + Bar + + + Baz + + + + + Test 1 + Foo 1 + Bar 1 + Baz 1 + + + Test 2 + Foo 2 + Bar 2 + Baz 2 + + + + ); + + let grid = getByRole('grid'); + expect(grid).toBeVisible(); + expect(grid).toHaveAttribute('aria-multiselectable', 'true'); + expect(grid).toHaveAttribute('aria-rowcount', '4'); + expect(grid).toHaveAttribute('aria-colcount', '5'); + + let rowgroups = within(grid).getAllByRole('rowgroup'); + expect(rowgroups).toHaveLength(2); + + let headerRows = within(rowgroups[0]).getAllByRole('row'); + expect(headerRows).toHaveLength(2); + expect(headerRows[0]).toHaveAttribute('aria-rowindex', '1'); + expect(headerRows[1]).toHaveAttribute('aria-rowindex', '2'); + + let headers = within(headerRows[0]).getAllByRole('columnheader'); + let placeholderCells = within(headerRows[0]).getAllByRole('gridcell'); + expect(headers).toHaveLength(2); + expect(placeholderCells).toHaveLength(1); + + expect(placeholderCells[0]).toHaveTextContent(''); + expect(placeholderCells[0]).toHaveAttribute('aria-colspan', '2'); + expect(placeholderCells[0]).toHaveAttribute('aria-colindex', '1'); + + expect(headers[0]).toHaveTextContent('Group 1'); + expect(headers[0]).toHaveAttribute('aria-colspan', '2'); + expect(headers[0]).toHaveAttribute('aria-colindex', '3'); + expect(headers[1]).toHaveTextContent('Group 2'); + expect(headers[1]).toHaveAttribute('aria-colindex', '5'); + + headers = within(headerRows[1]).getAllByRole('columnheader'); + expect(headers).toHaveLength(5); + expect(headers[0]).toHaveAttribute('aria-colindex', '1'); + expect(headers[1]).toHaveAttribute('aria-colindex', '2'); + expect(headers[2]).toHaveAttribute('aria-colindex', '3'); + expect(headers[3]).toHaveAttribute('aria-colindex', '4'); + expect(headers[4]).toHaveAttribute('aria-colindex', '5'); + + let checkbox = within(headers[0]).getByRole('checkbox'); + expect(checkbox).toHaveAttribute('aria-label', 'Select All'); + + expect(headers[1]).toHaveTextContent('Test'); + expect(headers[2]).toHaveTextContent('Foo'); + expect(headers[3]).toHaveTextContent('Bar'); + expect(headers[4]).toHaveTextContent('Baz'); + + let rows = within(rowgroups[1]).getAllByRole('row'); + expect(rows).toHaveLength(2); + + let rowheader = within(rows[0]).getByRole('rowheader'); + let cellSpan = within(rowheader).getByText('Test 1'); + expect(rowheader).toHaveTextContent('Test 1'); + + expect(rows[0]).toHaveAttribute('aria-selected', 'false'); + expect(rows[0]).toHaveAttribute('aria-labelledby', cellSpan.id); + expect(rows[0]).toHaveAttribute('aria-rowindex', '3'); + + checkbox = within(rows[0]).getByRole('checkbox'); + expect(checkbox).toHaveAttribute('aria-label', 'Select'); + expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${cellSpan.id}`); + + rowheader = within(rows[1]).getByRole('rowheader'); + cellSpan = within(rowheader).getByText('Test 2'); + expect(rowheader).toHaveTextContent('Test 2'); + + expect(rows[1]).toHaveAttribute('aria-selected', 'false'); + expect(rows[1]).toHaveAttribute('aria-labelledby', cellSpan.id); + expect(rows[1]).toHaveAttribute('aria-rowindex', '4'); + + + checkbox = within(rows[1]).getByRole('checkbox'); + expect(checkbox).toHaveAttribute('aria-label', 'Select'); + expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${cellSpan.id}`); + + let cells = within(rowgroups[1]).getAllByRole('gridcell'); + expect(cells).toHaveLength(8); + }); + + it('renders a dynamic table with nested columns', function () { + let {getByRole} = render( + + + {column => + {column.name} + } + + + {item => + ( + {key => {item[key]}} + ) + } + + + ); + + let grid = getByRole('grid'); + expect(grid).toBeVisible(); + expect(grid).toHaveAttribute('aria-multiselectable', 'true'); + expect(grid).toHaveAttribute('aria-rowcount', '5'); + expect(grid).toHaveAttribute('aria-colcount', '6'); + + let rowgroups = within(grid).getAllByRole('rowgroup'); + expect(rowgroups).toHaveLength(2); + + let headerRows = within(rowgroups[0]).getAllByRole('row'); + expect(headerRows).toHaveLength(3); + expect(headerRows[0]).toHaveAttribute('aria-rowindex', '1'); + expect(headerRows[1]).toHaveAttribute('aria-rowindex', '2'); + expect(headerRows[2]).toHaveAttribute('aria-rowindex', '3'); + + let headers = within(headerRows[0]).getAllByRole('columnheader'); + let placeholderCells = within(headerRows[0]).getAllByRole('gridcell'); + expect(headers).toHaveLength(1); + expect(placeholderCells).toHaveLength(1); + + expect(placeholderCells[0]).toHaveTextContent(''); + expect(placeholderCells[0]).toHaveAttribute('aria-colspan', '2'); + expect(placeholderCells[0]).toHaveAttribute('aria-colindex', '1'); + expect(headers[0]).toHaveTextContent('Tiered One Header'); + expect(headers[0]).toHaveAttribute('aria-colspan', '4'); + expect(headers[0]).toHaveAttribute('aria-colindex', '3'); + + headers = within(headerRows[1]).getAllByRole('columnheader'); + placeholderCells = within(headerRows[1]).getAllByRole('gridcell'); + expect(headers).toHaveLength(2); + expect(placeholderCells).toHaveLength(2); + + expect(placeholderCells[0]).toHaveTextContent(''); + expect(placeholderCells[0]).toHaveAttribute('aria-colspan', '2'); + expect(placeholderCells[0]).toHaveAttribute('aria-colindex', '1'); + expect(headers[0]).toHaveTextContent('Tier Two Header A'); + expect(headers[0]).toHaveAttribute('aria-colspan', '2'); + expect(headers[0]).toHaveAttribute('aria-colindex', '3'); + expect(placeholderCells[1]).toHaveTextContent(''); + expect(placeholderCells[1]).toHaveAttribute('aria-colindex', '5'); + expect(headers[1]).toHaveTextContent('Tier Two Header B'); + expect(headers[1]).toHaveAttribute('aria-colindex', '6'); + + headers = within(headerRows[2]).getAllByRole('columnheader'); + expect(headers).toHaveLength(6); + + let checkbox = within(headers[0]).getByRole('checkbox'); + expect(checkbox).toHaveAttribute('aria-label', 'Select All'); + + expect(headers[1]).toHaveTextContent('Test'); + expect(headers[2]).toHaveTextContent('Foo'); + expect(headers[3]).toHaveTextContent('Bar'); + expect(headers[4]).toHaveTextContent('Yay'); + expect(headers[5]).toHaveTextContent('Baz'); + + let rows = within(rowgroups[1]).getAllByRole('row'); + expect(rows).toHaveLength(2); + + let rowheader = within(rows[0]).getByRole('rowheader'); + let cellSpan = within(rowheader).getByText('Test 1'); + expect(rowheader).toHaveTextContent('Test 1'); + + expect(rows[0]).toHaveAttribute('aria-selected', 'false'); + expect(rows[0]).toHaveAttribute('aria-labelledby', cellSpan.id); + expect(rows[0]).toHaveAttribute('aria-rowindex', '4'); + + checkbox = within(rows[0]).getByRole('checkbox'); + expect(checkbox).toHaveAttribute('aria-label', 'Select'); + expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${cellSpan.id}`); + + rowheader = within(rows[1]).getByRole('rowheader'); + cellSpan = within(rowheader).getByText('Test 2'); + expect(rowheader).toHaveTextContent('Test 2'); + + expect(rows[1]).toHaveAttribute('aria-selected', 'false'); + expect(rows[1]).toHaveAttribute('aria-labelledby', cellSpan.id); + expect(rows[1]).toHaveAttribute('aria-rowindex', '5'); + + + checkbox = within(rows[1]).getByRole('checkbox'); + expect(checkbox).toHaveAttribute('aria-label', 'Select'); + expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${cellSpan.id}`); + + let cells = within(rowgroups[1]).getAllByRole('gridcell'); + expect(cells).toHaveLength(10); + }); + + it('renders a table with multiple row headers', function () { + let {getByRole} = render( + + + First Name + Last Name + Birthday + + + + Sam + Smith + May 3 + + + Julia + Jones + February 10 + + + + ); + + let grid = getByRole('grid'); + let rowgroups = within(grid).getAllByRole('rowgroup'); + let rows = within(rowgroups[1]).getAllByRole('row'); + + let rowheaders = within(rows[0]).getAllByRole('rowheader'); + expect(rowheaders).toHaveLength(2); + expect(rowheaders[0]).toHaveTextContent('Sam'); + expect(rowheaders[1]).toHaveTextContent('Smith'); + let firstCellSpan = within(rowheaders[0]).getByText('Sam'); + let secondCellSpan = within(rowheaders[1]).getByText('Smith'); + + expect(rows[0]).toHaveAttribute('aria-labelledby', `${firstCellSpan.id} ${secondCellSpan.id}`); + + let checkbox = within(rows[0]).getByRole('checkbox'); + expect(checkbox).toHaveAttribute('aria-label', 'Select'); + expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${firstCellSpan.id} ${secondCellSpan.id}`); + + rowheaders = within(rows[1]).getAllByRole('rowheader'); + expect(rowheaders).toHaveLength(2); + expect(rowheaders[0]).toHaveTextContent('Julia'); + expect(rowheaders[1]).toHaveTextContent('Jones'); + firstCellSpan = within(rowheaders[0]).getByText('Julia'); + secondCellSpan = within(rowheaders[1]).getByText('Jones'); + + expect(rows[1]).toHaveAttribute('aria-labelledby', `${firstCellSpan.id} ${secondCellSpan.id}`); + + checkbox = within(rows[1]).getByRole('checkbox'); + expect(checkbox).toHaveAttribute('aria-label', 'Select'); + expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${firstCellSpan.id} ${secondCellSpan.id}`); + }); + + describe('keyboard focus', function () { + // locale is being set here, since we can't nest them, use original render function + let renderTable = (locale = 'en-US', props = {}) => { + let tree = renderComponent( + + + + {column => {column.name}} + + + {item => + ( + {key => {item[key]}} + ) + } + + + + ); + act(() => {jest.runAllTimers();}); + return tree; + }; + + // locale is being set here, since we can't nest them, use original render function + let renderNested = (locale = 'en-US') => { + let tree = renderComponent( + + + + {column => + {column.name} + } + + + {item => + ( + {key => {item[key]}} + ) + } + + + + ); + act(() => {jest.runAllTimers();}); + return tree; + }; + + let renderMany = () => render( + + + {column => + {column.name} + } + + + {item => + ( + {key => {item[key]}} + ) + } + + + ); + + let renderManyColumns = () => render( + + + {column => + {column.name} + } + + + {item => + ( + {key => {item.foo + ' ' + key}} + ) + } + + + ); + + describe('ArrowRight', function () { + it('should move focus to the next cell in a row with ArrowRight', async function () { + let tree = renderTable(); + focusCell(tree, 'Bar 1'); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(getCell(tree, 'Baz 1')); + }); + + it('should move focus to the previous cell in a row with ArrowRight in RTL', function () { + let tree = renderTable('ar-AE'); + focusCell(tree, 'Bar 1'); + moveFocus('ArrowRight'); + expect(document.activeElement).toBe(getCell(tree, 'Foo 1')); + }); + + it('should move focus to the row when on the last cell with ArrowRight', function () { + let tree = renderTable(); + focusCell(tree, 'Baz 1'); + moveFocus('ArrowRight'); + expect(document.activeElement).toBe(tree.getAllByRole('row')[1]); + }); + + it('should move focus to the row when on the first cell with ArrowRight in RTL', function () { + let tree = renderTable('ar-AE'); + focusCell(tree, 'Foo 1'); + moveFocus('ArrowRight'); + expect(document.activeElement).toBe(tree.getAllByRole('row')[1]); + }); + + it('should move focus from the row to the first cell with ArrowRight', function () { + let tree = renderTable(); + act(() => {tree.getAllByRole('row')[1].focus();}); + moveFocus('ArrowRight'); + expect(document.activeElement).toBe(getCell(tree, 'Foo 1')); + }); + + it('should move focus from the row to the last cell with ArrowRight in RTL', function () { + let tree = renderTable('ar-AE'); + act(() => {tree.getAllByRole('row')[1].focus();}); + moveFocus('ArrowRight'); + expect(document.activeElement).toBe(getCell(tree, 'Baz 1')); + }); + + it('should move to the next column header in a row with ArrowRight', function () { + let tree = renderTable(); + focusCell(tree, 'Bar'); + moveFocus('ArrowRight'); + expect(document.activeElement).toBe(getCell(tree, 'Baz')); + }); + + it('should move to the previous column header in a row with ArrowRight in RTL', function () { + let tree = renderTable('ar-AE'); + focusCell(tree, 'Bar'); + moveFocus('ArrowRight'); + expect(document.activeElement).toBe(getCell(tree, 'Foo')); + }); + + it('should move to the next nested column header in a row with ArrowRight', function () { + let tree = renderNested(); + focusCell(tree, 'Tier Two Header A'); + moveFocus('ArrowRight'); + expect(document.activeElement).toBe(getCell(tree, 'Tier Two Header B')); + }); + + it('should move to the previous nested column header in a row with ArrowRight in RTL', function () { + let tree = renderNested('ar-AE'); + focusCell(tree, 'Tier Two Header B'); + moveFocus('ArrowRight'); + expect(document.activeElement).toBe(getCell(tree, 'Tier Two Header A')); + }); + + it('should move to the first column header when focus is on the last column with ArrowRight', function () { + let tree = renderTable(); + focusCell(tree, 'Baz'); + moveFocus('ArrowRight'); + expect(document.activeElement).toBe(getCell(tree, 'Foo')); + }); + + it('should move to the last column header when focus is on the first column with ArrowRight in RTL', function () { + let tree = renderTable('ar-AE'); + focusCell(tree, 'Foo'); + moveFocus('ArrowRight'); + expect(document.activeElement).toBe(getCell(tree, 'Baz')); + }); + + it('should allow the user to focus disabled cells', function () { + let tree = renderTable('en-US', {disabledKeys: ['Foo 1']}); + focusCell(tree, 'Bar 1'); + moveFocus('ArrowRight'); + expect(document.activeElement).toBe(getCell(tree, 'Baz 1')); + }); + }); + + describe('ArrowLeft', function () { + it('should move focus to the previous cell in a row with ArrowLeft', function () { + let tree = renderTable(); + focusCell(tree, 'Bar 1'); + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(getCell(tree, 'Foo 1')); + }); + + it('should move focus to the next cell in a row with ArrowRight in RTL', function () { + let tree = renderTable('ar-AE'); + focusCell(tree, 'Bar 1'); + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(getCell(tree, 'Baz 1')); + }); + + it('should move focus to the row when on the first cell with ArrowLeft', function () { + let tree = renderTable(); + focusCell(tree, 'Foo 1'); + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(tree.getAllByRole('row')[1]); + }); + + it('should move focus to the row when on the last cell with ArrowLeft in RTL', function () { + let tree = renderTable('ar-AE'); + focusCell(tree, 'Baz 1'); + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(tree.getAllByRole('row')[1]); + }); + + it('should move focus from the row to the last cell with ArrowLeft', function () { + let tree = renderTable(); + act(() => {tree.getAllByRole('row')[1].focus();}); + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(getCell(tree, 'Baz 1')); + }); + + it('should move focus from the row to the first cell with ArrowLeft in RTL', function () { + let tree = renderTable('ar-AE'); + act(() => {tree.getAllByRole('row')[1].focus();}); + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(getCell(tree, 'Foo 1')); + }); + + it('should move to the previous column header in a row with ArrowLeft', function () { + let tree = renderTable(); + focusCell(tree, 'Bar'); + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(getCell(tree, 'Foo')); + }); + + it('should move to the next column header in a row with ArrowLeft in RTL', function () { + let tree = renderTable('ar-AE'); + focusCell(tree, 'Bar'); + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(getCell(tree, 'Baz')); + }); + + it('should move to the previous nested column header in a row with ArrowLeft', function () { + let tree = renderNested(); + focusCell(tree, 'Tier Two Header B'); + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(getCell(tree, 'Tier Two Header A')); + }); + + it('should move to the next nested column header in a row with ArrowLeft in RTL', function () { + let tree = renderNested('ar-AE'); + focusCell(tree, 'Tier Two Header A'); + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(getCell(tree, 'Tier Two Header B')); + }); + + it('should move to the last column header when focus is on the first column with ArrowLeft', function () { + let tree = renderTable(); + focusCell(tree, 'Foo'); + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(getCell(tree, 'Baz')); + }); + + it('should move to the first column header when focus is on the last column with ArrowLeft in RTL', function () { + let tree = renderTable('ar-AE'); + focusCell(tree, 'Baz'); + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(getCell(tree, 'Foo')); + }); + + it('should allow the user to focus disabled cells', function () { + let tree = renderTable('en-US', {disabledKeys: ['Foo 1']}); + focusCell(tree, 'Bar 1'); + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(getCell(tree, 'Foo 1')); + }); + }); + + describe('ArrowUp', function () { + it('should move focus to the cell above with ArrowUp', function () { + let tree = renderTable(); + focusCell(tree, 'Bar 2'); + moveFocus('ArrowUp'); + expect(document.activeElement).toBe(getCell(tree, 'Bar 1')); + }); + + it('should move focus to the row above with ArrowUp', function () { + let tree = renderTable(); + act(() => {tree.getAllByRole('row')[2].focus();}); + moveFocus('ArrowUp'); + expect(document.activeElement).toBe(tree.getAllByRole('row')[1]); + }); + + it('should move focus to the column header above a cell in the first row with ArrowUp', function () { + let tree = renderTable(); + focusCell(tree, 'Bar 1'); + moveFocus('ArrowUp'); + expect(document.activeElement).toBe(getCell(tree, 'Bar')); + }); + + it('should move focus to the column header above the first row with ArrowUp', function () { + let tree = renderTable(); + act(() => {tree.getAllByRole('row')[1].focus();}); + moveFocus('ArrowUp'); + expect(document.activeElement).toBe(getCell(tree, 'Foo')); + }); + + it('should move focus to the parent column header with ArrowUp', function () { + let tree = renderNested(); + focusCell(tree, 'Bar'); + moveFocus('ArrowUp'); + expect(document.activeElement).toBe(getCell(tree, 'Tier Two Header A')); + moveFocus('ArrowUp'); + expect(document.activeElement).toBe(getCell(tree, 'Tiered One Header')); + // do nothing when at the top + moveFocus('ArrowUp'); + expect(document.activeElement).toBe(getCell(tree, 'Tiered One Header')); + }); + + it('should allow the user to focus disabled rows', function () { + let tree = renderTable('en-US', {disabledKeys: ['Foo 1']}); + focusCell(tree, 'Bar 2'); + moveFocus('ArrowUp'); + expect(document.activeElement).toBe(getCell(tree, 'Bar 1')); + }); + }); + + describe('ArrowDown', function () { + it('should move focus to the cell below with ArrowDown', function () { + let tree = renderTable(); + focusCell(tree, 'Bar 1'); + moveFocus('ArrowDown'); + expect(document.activeElement).toBe(getCell(tree, 'Bar 2')); + }); + + it('should move focus to the row below with ArrowDown', function () { + let tree = renderTable(); + act(() => {tree.getAllByRole('row')[1].focus();}); + moveFocus('ArrowDown'); + expect(document.activeElement).toBe(tree.getAllByRole('row')[2]); + }); + + it('should move focus to the child column header with ArrowDown', function () { + let tree = renderNested(); + focusCell(tree, 'Tiered One Header'); + moveFocus('ArrowDown'); + expect(document.activeElement).toBe(getCell(tree, 'Tier Two Header A')); + moveFocus('ArrowDown'); + expect(document.activeElement).toBe(getCell(tree, 'Foo')); + }); + + it('should move focus to the cell below a column header with ArrowDown', function () { + let tree = renderTable(); + focusCell(tree, 'Bar'); + moveFocus('ArrowDown'); + expect(document.activeElement).toBe(getCell(tree, 'Bar 1')); + }); + + it('should allow the user to focus disabled cells', function () { + let tree = renderTable('en-US', {disabledKeys: ['Foo 2']}); + focusCell(tree, 'Bar 1'); + moveFocus('ArrowDown'); + expect(document.activeElement).toBe(getCell(tree, 'Bar 2')); + }); + }); + + describe('Home', function () { + it('should focus the first cell in a row with Home', function () { + let tree = renderTable(); + focusCell(tree, 'Bar 1'); + moveFocus('Home'); + expect(document.activeElement).toBe(getCell(tree, 'Foo 1')); + }); + + it('should focus the first cell in the first row with ctrl + Home', function () { + let tree = renderTable(); + focusCell(tree, 'Bar 2'); + moveFocus('Home', {ctrlKey: true}); + expect(document.activeElement).toBe(getCell(tree, 'Foo 1')); + }); + + it('should focus the first row with Home', function () { + let tree = renderTable(); + act(() => {tree.getAllByRole('row')[2].focus();}); + moveFocus('Home'); + expect(document.activeElement).toBe(tree.getAllByRole('row')[1]); + }); + }); + + describe('End', function () { + it('should focus the last cell in a row with End', function () { + let tree = renderTable(); + focusCell(tree, 'Foo 1'); + moveFocus('End'); + expect(document.activeElement).toBe(getCell(tree, 'Baz 1')); + }); + + it('should focus the last cell in the last row with ctrl + End', function () { + let tree = renderTable(); + focusCell(tree, 'Bar 1'); + moveFocus('End', {ctrlKey: true}); + expect(document.activeElement).toBe(getCell(tree, 'Baz 2')); + }); + + it('should focus the last row with End', function () { + let tree = renderTable(); + act(() => {tree.getAllByRole('row')[1].focus();}); + moveFocus('End'); + expect(document.activeElement).toBe(tree.getAllByRole('row')[2]); + }); + }); + + describe('PageDown', function () { + it('should focus the cell a page below', function () { + let tree = renderMany(); + focusCell(tree, 'Foo 1'); + moveFocus('PageDown'); + expect(document.activeElement).toBe(getCell(tree, 'Foo 5')); + moveFocus('PageDown'); + expect(document.activeElement).toBe(getCell(tree, 'Foo 9')); + moveFocus('PageDown'); + expect(document.activeElement).toBe(getCell(tree, 'Foo 13')); + moveFocus('PageDown'); + expect(document.activeElement).toBe(getCell(tree, 'Foo 17')); + moveFocus('PageDown'); + expect(document.activeElement).toBe(getCell(tree, 'Foo 21')); + }); + + it('should focus the row a page below', function () { + let tree = renderMany(); + act(() => {tree.getAllByRole('row')[1].focus();}); + moveFocus('PageDown'); + expect(document.activeElement).toBe(tree.getByRole('row', {name: 'Foo 5'})); + moveFocus('PageDown'); + expect(document.activeElement).toBe(tree.getByRole('row', {name: 'Foo 9'})); + moveFocus('PageDown'); + expect(document.activeElement).toBe(tree.getByRole('row', {name: 'Foo 13'})); + moveFocus('PageDown'); + expect(document.activeElement).toBe(tree.getByRole('row', {name: 'Foo 17'})); + moveFocus('PageDown'); + expect(document.activeElement).toBe(tree.getByRole('row', {name: 'Foo 21'})); + }); + }); + + describe('PageUp', function () { + it('should focus the cell a page above', function () { + let tree = renderMany(); + focusCell(tree, 'Foo 5'); + moveFocus('PageUp'); + expect(document.activeElement).toBe(getCell(tree, 'Foo 1')); + focusCell(tree, 'Foo 3'); + moveFocus('PageUp'); + expect(document.activeElement).toBe(getCell(tree, 'Foo 1')); + }); + + it('should focus the row a page above', function () { + let tree = renderMany(); + act(() => {tree.getAllByRole('row')[5].focus();}); + moveFocus('PageUp'); + expect(document.activeElement).toBe(tree.getAllByRole('row')[1]); + act(() => {tree.getAllByRole('row')[4].focus();}); + moveFocus('PageUp'); + expect(document.activeElement).toBe(tree.getAllByRole('row')[1]); + }); + }); + + describe('type to select', function () { + let renderTypeSelect = () => render( + + + First Name + Last Name + Birthday + + + + Sam + Smith + May 3 + + + Julia + Jones + February 10 + + + John + Doe + December 12 + + + + ); + + it('focuses cell by typing letters in rapid succession', function () { + let tree = renderTypeSelect(); + focusCell(tree, 'Sam'); + + moveFocus('J'); + expect(document.activeElement).toBe(getCell(tree, 'Julia')); + + moveFocus('o'); + expect(document.activeElement).toBe(getCell(tree, 'Jones')); + + moveFocus('h'); + expect(document.activeElement).toBe(getCell(tree, 'John')); + }); + + it('matches against all row header cells', function () { + let tree = renderTypeSelect(); + focusCell(tree, 'Sam'); + + moveFocus('D'); + expect(document.activeElement).toBe(getCell(tree, 'Doe')); + }); + + it('non row header columns don\'t match', function () { + let tree = renderTypeSelect(); + focusCell(tree, 'Sam'); + + moveFocus('F'); + expect(document.activeElement).toBe(getCell(tree, 'Sam')); + }); + + it('focuses row by typing letters in rapid succession', function () { + let tree = renderTypeSelect(); + act(() => {tree.getAllByRole('row')[1].focus();}); + + moveFocus('J'); + expect(document.activeElement).toBe(tree.getAllByRole('row')[2]); + + moveFocus('o'); + expect(document.activeElement).toBe(tree.getAllByRole('row')[2]); + + moveFocus('h'); + expect(document.activeElement).toBe(tree.getAllByRole('row')[3]); + }); + + it('matches row against all row header cells', function () { + let tree = renderTypeSelect(); + act(() => {tree.getAllByRole('row')[1].focus();}); + + moveFocus('D'); + expect(document.activeElement).toBe(tree.getAllByRole('row')[3]); + }); + + it('resets the search text after a timeout', function () { + let tree = renderTypeSelect(); + focusCell(tree, 'Sam'); + + moveFocus('J'); + expect(document.activeElement).toBe(getCell(tree, 'Julia')); + + act(() => {jest.runAllTimers();}); + + moveFocus('J'); + expect(document.activeElement).toBe(getCell(tree, 'Julia')); + }); + + it('wraps around when reaching the end of the collection', function () { + let tree = renderTypeSelect(); + focusCell(tree, 'Sam'); + + moveFocus('J'); + expect(document.activeElement).toBe(getCell(tree, 'Julia')); + + moveFocus('o'); + expect(document.activeElement).toBe(getCell(tree, 'Jones')); + + moveFocus('h'); + expect(document.activeElement).toBe(getCell(tree, 'John')); + + act(() => {jest.runAllTimers();}); + + moveFocus('J'); + expect(document.activeElement).toBe(getCell(tree, 'John')); + + moveFocus('u'); + expect(document.activeElement).toBe(getCell(tree, 'Julia')); + }); + + it('wraps around when no items past the current one match', function () { + let tree = renderTypeSelect(); + focusCell(tree, 'Sam'); + + moveFocus('J'); + expect(document.activeElement).toBe(getCell(tree, 'Julia')); + + act(() => {jest.runAllTimers();}); + + moveFocus('S'); + expect(document.activeElement).toBe(getCell(tree, 'Sam')); + }); + + describe('type ahead with dialog triggers', function () { + beforeEach(function () { + offsetHeight.mockRestore(); + offsetHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get') + .mockImplementationOnce(() => 20) + .mockImplementation(() => 100); + }); + afterEach(function () { + offsetHeight.mockRestore(); + offsetHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 1000); + }); + it('does not pick up typeahead from a dialog', async function () { + offsetHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get') + .mockImplementationOnce(() => 20) + .mockImplementation(() => 100); + let tree = render(); + let trigger = tree.getAllByRole('button')[0]; + await user.click(trigger); + act(() => { + jest.runAllTimers(); + }); + let textfield = tree.getByLabelText('Enter a J'); + act(() => {textfield.focus();}); + fireEvent.keyDown(textfield, {key: 'J'}); + fireEvent.keyUp(textfield, {key: 'J'}); + act(() => { + jest.runAllTimers(); + }); + expect(document.activeElement).toBe(textfield); + fireEvent.keyDown(document.activeElement, {key: 'Escape'}); + fireEvent.keyUp(document.activeElement, {key: 'Escape'}); + act(() => { + jest.runAllTimers(); + }); + }); + }); + }); + + describe('focus marshalling', function () { + let renderFocusable = () => render( + <> + + + + Foo + Bar + baz + + + + + Google + Baz 1 + + + + Yahoo + Baz 2 + + + + + + ); + + let renderWithPicker = () => render( + <> + + + Foo + Bar + baz + + + + + + + Yahoo + Google + DuckDuckGo + + + Baz 1 + + + + + ); + + it('should retain focus on the pressed child', async function () { + let tree = renderFocusable(); + let switchToPress = tree.getAllByRole('switch')[2]; + await user.click(switchToPress); + expect(document.activeElement).toBe(switchToPress); + }); + + it('should marshall focus to the focusable element inside a cell', function () { + let tree = renderFocusable(); + focusCell(tree, 'Baz 1'); + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(tree.getAllByRole('link')[0]); + + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(tree.getAllByRole('switch')[0]); + + moveFocus('ArrowDown'); + expect(document.activeElement).toBe(tree.getAllByRole('switch')[1]); + + moveFocus('ArrowRight'); + expect(document.activeElement).toBe(tree.getAllByRole('switch')[2]); + + moveFocus('ArrowRight'); + expect(document.activeElement).toBe(tree.getAllByRole('link')[1]); + + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(tree.getAllByRole('switch')[2]); + + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(tree.getAllByRole('switch')[1]); + + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(tree.getAllByRole('checkbox')[2]); + + moveFocus('ArrowUp'); + expect(document.activeElement).toBe(tree.getAllByRole('checkbox')[1]); + + moveFocus('ArrowUp'); + expect(document.activeElement).toBe(tree.getAllByRole('checkbox')[0]); + }); + + it('should support keyboard navigation after pressing focusable element inside a cell', async function () { + let tree = renderFocusable(); + await user.click(tree.getAllByRole('switch')[0]); + expect(document.activeElement).toBe(tree.getAllByRole('switch')[0]); + + moveFocus('ArrowDown'); + expect(document.activeElement).toBe(tree.getAllByRole('switch')[1]); + }); + + it('should move focus to the first row when tabbing into the table from the start', function () { + let tree = renderFocusable(); + + let table = tree.getByRole('grid'); + expect(table).toHaveAttribute('tabIndex', '0'); + + let before = tree.getByTestId('before'); + act(() => before.focus()); + + // Simulate tabbing to the first "tabbable" item inside the table + fireEvent.keyDown(before, {key: 'Tab'}); + act(() => {within(table).getAllByRole('switch')[0].focus();}); + fireEvent.keyUp(before, {key: 'Tab'}); + + expect(document.activeElement).toBe(within(table).getAllByRole('row')[1]); + }); + + it('should move focus to the last row when tabbing into the table from the end', function () { + let tree = renderFocusable(); + + let table = tree.getByRole('grid'); + expect(table).toHaveAttribute('tabIndex', '0'); + + let after = tree.getByTestId('after'); + act(() => after.focus()); + + // Simulate tabbing to the last "tabbable" item inside the table + act(() => { + fireEvent.keyDown(after, {key: 'Tab', shiftKey: true}); + within(table).getAllByRole('link')[1].focus(); + fireEvent.keyUp(after, {key: 'Tab', shiftKey: true}); + }); + + expect(document.activeElement).toBe(within(table).getAllByRole('row')[2]); + }); + + it('should move focus to the last focused cell when tabbing into the table from the start', function () { + let tree = renderFocusable(); + + let table = tree.getByRole('grid'); + expect(table).toHaveAttribute('tabIndex', '0'); + + let baz1 = getCell(tree, 'Baz 1'); + act(() => baz1.focus()); + + expect(table).toHaveAttribute('tabIndex', '-1'); + + let before = tree.getByTestId('before'); + act(() => before.focus()); + + // Simulate tabbing to the first "tabbable" item inside the table + fireEvent.keyDown(before, {key: 'Tab'}); + act(() => {within(table).getAllByRole('switch')[0].focus();}); + fireEvent.keyUp(before, {key: 'Tab'}); + + expect(document.activeElement).toBe(baz1); + }); + + it('should move focus to the last focused cell when tabbing into the table from the end', function () { + let tree = renderFocusable(); + + let table = tree.getByRole('grid'); + expect(table).toHaveAttribute('tabIndex', '0'); + + let baz1 = getCell(tree, 'Baz 1'); + act(() => baz1.focus()); + + expect(table).toHaveAttribute('tabIndex', '-1'); + + let after = tree.getByTestId('after'); + act(() => after.focus()); + + // Simulate tabbing to the last "tabbable" item inside the table + fireEvent.keyDown(after, {key: 'Tab'}); + act(() => {within(table).getAllByRole('link')[1].focus();}); + fireEvent.keyUp(after, {key: 'Tab'}); + + expect(document.activeElement).toBe(baz1); + }); + + it('should not trap focus when navigating through a cell with a picker using the arrow keys', function () { + let tree = renderWithPicker(); + focusCell(tree, 'Baz 1'); + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(tree.getByRole('button')); + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(tree.getByRole('switch')); + moveFocus('ArrowRight'); + expect(document.activeElement).toBe(tree.getByRole('button')); + moveFocus('ArrowRight'); + expect(document.activeElement).toBe(tree.getAllByRole('gridcell')[1]); + }); + + it('should move focus after the table when tabbing', async function () { + let tree = renderFocusable(); + + await user.click(tree.getAllByRole('switch')[1]); + expect(document.activeElement).toBe(tree.getAllByRole('switch')[1]); + + // Simulate tabbing within the table + fireEvent.keyDown(document.activeElement, {key: 'Tab'}); + let walker = getFocusableTreeWalker(document.body, {tabbable: true}); + walker.currentNode = document.activeElement; + act(() => {walker.nextNode().focus();}); + fireEvent.keyUp(document.activeElement, {key: 'Tab'}); + + let after = tree.getByTestId('after'); + expect(document.activeElement).toBe(after); + }); + + it('should move focus after the table when tabbing from the last row', function () { + let tree = renderFocusable(); + + act(() => tree.getAllByRole('row')[2].focus()); + expect(document.activeElement).toBe(tree.getAllByRole('row')[2]); + + // Simulate tabbing within the table + act(() => { + fireEvent.keyDown(document.activeElement, {key: 'Tab'}); + let walker = getFocusableTreeWalker(document.body, {tabbable: true}); + walker.currentNode = document.activeElement; + walker.nextNode().focus(); + fireEvent.keyUp(document.activeElement, {key: 'Tab'}); + }); + + let after = tree.getByTestId('after'); + expect(document.activeElement).toBe(after); + }); + + it('should move focus before the table when shift tabbing', async function () { + let tree = renderFocusable(); + + await user.click(tree.getAllByRole('switch')[1]); + expect(document.activeElement).toBe(tree.getAllByRole('switch')[1]); + + // Simulate shift tabbing within the table + fireEvent.keyDown(document.activeElement, {key: 'Tab', shiftKey: true}); + let walker = getFocusableTreeWalker(document.body, {tabbable: true}); + walker.currentNode = document.activeElement; + act(() => {walker.previousNode().focus();}); + fireEvent.keyUp(document.activeElement, {key: 'Tab', shiftKey: true}); + + let before = tree.getByTestId('before'); + expect(document.activeElement).toBe(before); + }); + + it('should send focus to the appropriate key below if the focused row is removed', async function () { + let tree = render(); + + let rows = tree.getAllByRole('row'); + await user.tab(); + expect(document.activeElement).toBe(rows[1]); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowLeft'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowLeft'}); + expect(document.activeElement).toBe(within(rows[1]).getByRole('button')); + + fireEvent.keyDown(document.activeElement, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + act(() => {jest.runAllTimers();}); + + rows = tree.getAllByRole('row'); + expect(document.activeElement).toBe(within(rows[1]).getByRole('button')); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowUp'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowUp'}); + + rows = tree.getAllByRole('row'); + expect(document.activeElement).toBe(within(rows[0]).getAllByRole('columnheader')[4]); + }); + + it('should send focus to the appropriate key above if the focused last row is removed', async function () { + let tree = render(); + + let rows = tree.getAllByRole('row'); + await user.tab(); + expect(document.activeElement).toBe(rows[1]); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowLeft'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowLeft'}); + expect(document.activeElement).toBe(within(rows[1]).getByRole('button')); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + expect(document.activeElement).toBe(within(rows[2]).getByRole('button')); + + fireEvent.keyDown(document.activeElement, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + act(() => {jest.runAllTimers();}); + + rows = tree.getAllByRole('row'); + expect(document.activeElement).toBe(within(rows[1]).getByRole('button')); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowUp'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowUp'}); + + rows = tree.getAllByRole('row'); + expect(document.activeElement).toBe(within(rows[0]).getAllByRole('columnheader')[4]); + }); + + it('should send focus to the appropriate column and row if both the current row and column are removed', function () { + let itemsLocal = items; + let columnsLocal = columns; + let renderJSX = (props, items = itemsLocal, columns = columnsLocal) => ( + + + {column => {column.name}} + + + {item => + ( + {key => {item[key]}} + ) + } + + + ); + + let renderTable = (props, items = itemsLocal, columns = columnsLocal) => render(renderJSX(props, items, columns)); + + let tree = renderTable(); + focusCell(tree, 'Baz 1'); + + rerender(tree, renderJSX({}, [itemsLocal[1], itemsLocal[0]], columnsLocal.slice(0, 2))); + + expect(document.activeElement).toBe(tree.getAllByRole('row')[1], 'If column index with last focus is greater than the new number of columns, focus the row'); + + focusCell(tree, 'Bar 1'); + + rerender(tree, renderJSX({}, [itemsLocal[1]], columnsLocal.slice(0, 1))); + + expect(document.activeElement).toBe(tree.getAllByRole('row')[1], 'If column index with last focus is greater than the new number of columns, focus the row'); + + focusCell(tree, 'Foo 2'); + + rerender(tree, renderJSX({}, [itemsLocal[0], itemsLocal[0]], columnsLocal)); + + expect(document.activeElement).toBe(getCell(tree, 'Foo 1')); + }); + }); + + describe('scrolling', function () { + it('should scroll to a cell when it is focused', function () { + let tree = renderMany(); + let body = tree.getByRole('grid').childNodes[1]; + + focusCell(tree, 'Baz 25'); + expect(scrollIntoView).toHaveBeenLastCalledWith(body, document.activeElement); + }); + + it('should scroll to a cell when it is focused off screen', function () { + let tree = renderManyColumns(); + let body = tree.getByRole('grid').childNodes[1]; + + let cell = getCell(tree, 'Foo 5 5'); + act(() => cell.focus()); + expect(document.activeElement).toBe(cell); + expect(body.scrollTop).toBe(0); + + // When scrolling the focused item out of view, focus should remain on the item, + // virtualizer keeps focused items from being reused + body.scrollTop = 1000; + body.scrollLeft = 1000; + fireEvent.scroll(body); + act(() => jest.runAllTimers()); + + expect(body.scrollTop).toBe(1000); + expect(document.activeElement).toBe(cell); + expect(tree.queryByText('Foo 5 5')).toBe(cell.firstElementChild); + + // Ensure we have the correct sticky cells in the right order. + let row = cell.closest('[role=row]'); + let cells = within(row).getAllByRole('gridcell'); + let rowHeaders = within(row).getAllByRole('rowheader'); + expect(cells).toHaveLength(9); + expect(rowHeaders).toHaveLength(1); + expect(cells[0]).toHaveAttribute('aria-colindex', '1'); // checkbox + expect(rowHeaders[0]).toHaveAttribute('aria-colindex', '2'); // rowheader + expect(cells[1]).toHaveAttribute('aria-colindex', '6'); // persisted + expect(cells[1]).toBe(cell); + expect(cells[2]).toHaveAttribute('aria-colindex', '14'); // first visible + + // Moving focus should scroll the new focused item into view + moveFocus('ArrowLeft'); + expect(document.activeElement).toBe(getCell(tree, 'Foo 5 4')); + expect(scrollIntoView).toHaveBeenLastCalledWith(body, document.activeElement); + }); + + it('should not scroll when a column header receives focus', function () { + let tree = renderMany(); + let body = tree.getByRole('grid').childNodes[1]; + let cell = getCell(tree, 'Baz 5'); + + focusCell(tree, 'Baz 5'); + + body.scrollTop = 1000; + fireEvent.scroll(body); + + expect(body.scrollTop).toBe(1000); + expect(document.activeElement).toBe(cell); + + focusCell(tree, 'Bar'); + expect(document.activeElement).toHaveAttribute('role', 'columnheader'); + expect(document.activeElement).toHaveTextContent('Bar'); + expect(scrollIntoView).toHaveBeenLastCalledWith(body, document.activeElement); + }); + }); + }); + + describe('selection', function () { + afterEach(() => { + act(() => jest.runAllTimers()); + }); + + let renderJSX = (props, items = manyItems) => ( + + + {column => {column.name}} + + + {item => + ( + {key => {item[key]}} + ) + } + + + ); + + let renderTable = (props, items = manyItems) => render(renderJSX(props, items)); + + let checkSelection = (onSelectionChange, selectedKeys) => { + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(selectedKeys)); + }; + + let checkSelectAll = (tree, state = 'indeterminate') => { + let checkbox = tree.getByTestId('selectAll'); + if (state === 'indeterminate') { + expect(checkbox.indeterminate).toBe(true); + } else { + expect(checkbox.checked).toBe(state === 'checked'); + } + }; + + let checkRowSelection = (rows, selected) => { + for (let row of rows) { + expect(row).toHaveAttribute('aria-selected', '' + selected); + } + }; + + describe('row selection', function () { + it('should select a row from checkbox', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + let row = tree.getAllByRole('row')[1]; + expect(row).toHaveAttribute('aria-selected', 'false'); + await user.click(within(row).getByRole('checkbox')); + + checkSelection(onSelectionChange, ['Foo 1']); + expect(row).toHaveAttribute('aria-selected', 'true'); + checkSelectAll(tree); + }); + + it('should select a row by pressing on a cell', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + let row = tree.getAllByRole('row')[1]; + expect(row).toHaveAttribute('aria-selected', 'false'); + await user.click(getCell(tree, 'Baz 1')); + + checkSelection(onSelectionChange, ['Foo 1']); + expect(row).toHaveAttribute('aria-selected', 'true'); + checkSelectAll(tree); + }); + + it('should select a row by pressing the Space key on a row', function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + let row = tree.getAllByRole('row')[1]; + expect(row).toHaveAttribute('aria-selected', 'false'); + fireEvent.keyDown(row, {key: ' '}); + + checkSelection(onSelectionChange, ['Foo 1']); + expect(row).toHaveAttribute('aria-selected', 'true'); + checkSelectAll(tree); + }); + + it('should select a row by pressing the Enter key on a row', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + let row = tree.getAllByRole('row')[1]; + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + + checkSelection(onSelectionChange, ['Foo 1']); + expect(row).toHaveAttribute('aria-selected', 'true'); + checkSelectAll(tree); + }); + + it('should select a row by pressing the Space key on a cell', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + let row = tree.getAllByRole('row')[1]; + expect(row).toHaveAttribute('aria-selected', 'false'); + await user.tab(); + await user.keyboard('{ArrowRight}{ArrowRight}'); + await user.keyboard(' '); + + checkSelection(onSelectionChange, ['Foo 1']); + expect(row).toHaveAttribute('aria-selected', 'true'); + checkSelectAll(tree); + }); + + it('should select a row by pressing the Enter key on a cell', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + let row = tree.getAllByRole('row')[1]; + expect(row).toHaveAttribute('aria-selected', 'false'); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + + checkSelection(onSelectionChange, ['Foo 1']); + expect(row).toHaveAttribute('aria-selected', 'true'); + checkSelectAll(tree); + }); + + it('should support selecting multiple with a pointer', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + checkSelectAll(tree, 'unchecked'); + + let rows = tree.getAllByRole('row'); + checkRowSelection(rows.slice(1), false); + await user.click(getCell(tree, 'Baz 1')); + + checkSelection(onSelectionChange, ['Foo 1']); + expect(rows[1]).toHaveAttribute('aria-selected', 'true'); + checkRowSelection(rows.slice(2), false); + checkSelectAll(tree, 'indeterminate'); + + onSelectionChange.mockReset(); + await user.click(getCell(tree, 'Baz 2')); + + checkSelection(onSelectionChange, ['Foo 1', 'Foo 2']); + expect(rows[1]).toHaveAttribute('aria-selected', 'true'); + expect(rows[2]).toHaveAttribute('aria-selected', 'true'); + checkRowSelection(rows.slice(3), false); + checkSelectAll(tree, 'indeterminate'); + + // Deselect + onSelectionChange.mockReset(); + await user.click(getCell(tree, 'Baz 2')); + + checkSelection(onSelectionChange, ['Foo 1']); + expect(rows[1]).toHaveAttribute('aria-selected', 'true'); + checkRowSelection(rows.slice(2), false); + checkSelectAll(tree, 'indeterminate'); + }); + + it('should support selecting multiple with the Space key', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + checkSelectAll(tree, 'unchecked'); + + let rows = tree.getAllByRole('row'); + checkRowSelection(rows.slice(1), false); + await user.tab(); + await user.keyboard('{ArrowRight}{ArrowRight}'); + await user.keyboard(' '); + + checkSelection(onSelectionChange, ['Foo 1']); + expect(rows[1]).toHaveAttribute('aria-selected', 'true'); + checkRowSelection(rows.slice(2), false); + checkSelectAll(tree, 'indeterminate'); + + onSelectionChange.mockReset(); + await user.keyboard('{ArrowDown}'); + await user.keyboard(' '); + + checkSelection(onSelectionChange, ['Foo 1', 'Foo 2']); + expect(rows[1]).toHaveAttribute('aria-selected', 'true'); + expect(rows[2]).toHaveAttribute('aria-selected', 'true'); + checkRowSelection(rows.slice(3), false); + checkSelectAll(tree, 'indeterminate'); + + // Deselect + onSelectionChange.mockReset(); + await user.keyboard(' '); + + checkSelection(onSelectionChange, ['Foo 1']); + expect(rows[1]).toHaveAttribute('aria-selected', 'true'); + checkRowSelection(rows.slice(2), false); + checkSelectAll(tree, 'indeterminate'); + }); + + it('should prevent Esc from clearing selection if escapeKeyBehavior is "none"', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange, selectionMode: 'multiple', escapeKeyBehavior: 'none'}); + let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')}); + tableTester.setInteractionType('keyboard'); + + + await tableTester.toggleRowSelection({row: 0}); + expect(tableTester.selectedRows).toHaveLength(1); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + + await tableTester.toggleRowSelection({row: 1}); + expect(tableTester.selectedRows).toHaveLength(2); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + + await user.keyboard('{Escape}'); + expect(tableTester.selectedRows).toHaveLength(2); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + }); + + it('should not allow selection of a disabled row via checkbox click', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange, disabledKeys: ['Foo 1']}); + + let row = tree.getAllByRole('row')[1]; + expect(row).toHaveAttribute('aria-selected', 'false'); + await user.click(within(row).getByRole('checkbox')); + + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(row).toHaveAttribute('aria-selected', 'false'); + + let checkbox = tree.getByTestId('selectAll'); + expect(checkbox.checked).toBeFalsy(); + }); + + it('should not allow selection of a disabled row by pressing on a cell', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange, disabledKeys: ['Foo 1']}); + + let row = tree.getAllByRole('row')[1]; + expect(row).toHaveAttribute('aria-selected', 'false'); + await user.click(getCell(tree, 'Baz 1')); + + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(row).toHaveAttribute('aria-selected', 'false'); + + let checkbox = tree.getByTestId('selectAll'); + expect(checkbox.checked).toBeFalsy(); + }); + + it('should not allow the user to select a disabled row via keyboard', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange, disabledKeys: ['Foo 1']}); + + let row = tree.getAllByRole('row')[1]; + expect(row).toHaveAttribute('aria-selected', 'false'); + + await user.tab(); + await user.keyboard(' '); + await user.keyboard('{Enter}'); + + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(row).toHaveAttribute('aria-selected', 'false'); + + let checkbox = tree.getByTestId('selectAll'); + expect(checkbox.checked).toBeFalsy(); + }); + + describe('Space key with focus on a link within a cell', () => { + it('should toggle selection and prevent scrolling of the table', async () => { + let tree = render( + + + {column => {column.name}} + + + {item => + ( + {key => {item[key]}} + ) + } + + + ); + + let row = tree.getAllByRole('row')[1]; + expect(row).toHaveAttribute('aria-selected', 'false'); + + let link = within(row).getAllByRole('link')[0]; + expect(link.textContent).toBe('Foo 1'); + + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(link); + await user.keyboard(' '); + act(() => {jest.runAllTimers();}); + + row = tree.getAllByRole('row')[1]; + expect(row).toHaveAttribute('aria-selected', 'true'); + + await user.keyboard(' '); + act(() => {jest.runAllTimers();}); + + row = tree.getAllByRole('row')[1]; + link = within(row).getAllByRole('link')[0]; + + expect(row).toHaveAttribute('aria-selected', 'false'); + expect(link.textContent).toBe('Foo 1'); + }); + }); + }); + + describe('range selection', function () { + it('should support selecting a range with a pointer', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + checkSelectAll(tree, 'unchecked'); + + let rows = tree.getAllByRole('row'); + checkRowSelection(rows.slice(1), false); + await user.click(getCell(tree, 'Baz 1')); + + onSelectionChange.mockReset(); + await user.keyboard('[ShiftLeft>]'); + await user.click(getCell(tree, 'Baz 20')); + await user.keyboard('[/ShiftLeft]'); + + checkSelection(onSelectionChange, [ + 'Foo 1', 'Foo 2', 'Foo 3', 'Foo 4', 'Foo 5', 'Foo 6', 'Foo 7', 'Foo 8', 'Foo 9', 'Foo 10', + 'Foo 11', 'Foo 12', 'Foo 13', 'Foo 14', 'Foo 15', 'Foo 16', 'Foo 17', 'Foo 18', 'Foo 19', 'Foo 20' + ]); + + checkRowSelection(rows.slice(1, 21), true); + checkRowSelection(rows.slice(21), false); + }); + + it('should anchor range selections with a pointer', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + checkSelectAll(tree, 'unchecked'); + + let rows = tree.getAllByRole('row'); + checkRowSelection(rows.slice(1), false); + await user.click(getCell(tree, 'Baz 10')); + + onSelectionChange.mockReset(); + await user.keyboard('[ShiftLeft>]'); + await user.click(getCell(tree, 'Baz 20')); + await user.keyboard('[/ShiftLeft]'); + + checkSelection(onSelectionChange, [ + 'Foo 10', 'Foo 11', 'Foo 12', 'Foo 13', 'Foo 14', 'Foo 15', + 'Foo 16', 'Foo 17', 'Foo 18', 'Foo 19', 'Foo 20' + ]); + + checkRowSelection(rows.slice(11, 21), true); + checkRowSelection(rows.slice(21), false); + + onSelectionChange.mockReset(); + await user.keyboard('[ShiftLeft>]'); + await user.click(getCell(tree, 'Baz 1')); + await user.keyboard('[/ShiftLeft]'); + + checkSelection(onSelectionChange, [ + 'Foo 1', 'Foo 2', 'Foo 3', 'Foo 4', 'Foo 5', + 'Foo 6', 'Foo 7', 'Foo 8', 'Foo 9', 'Foo 10' + ]); + + checkRowSelection(rows.slice(1, 11), true); + checkRowSelection(rows.slice(11), false); + }); + + it('should extend a selection with Shift + ArrowDown', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + checkSelectAll(tree, 'unchecked'); + + let rows = tree.getAllByRole('row'); + checkRowSelection(rows.slice(1), false); + await user.tab(); + await user.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}'); + await user.keyboard('{ArrowDown}'.repeat(9)); + await user.keyboard(' '); + + onSelectionChange.mockReset(); + await user.keyboard('{Shift>}{ArrowDown}{/Shift}'); + + checkSelection(onSelectionChange, ['Foo 10', 'Foo 11']); + checkRowSelection(rows.slice(1, 10), false); + checkRowSelection(rows.slice(11, 12), true); + checkRowSelection(rows.slice(12), false); + }); + + it('should extend a selection with Shift + ArrowUp', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + checkSelectAll(tree, 'unchecked'); + + let rows = tree.getAllByRole('row'); + checkRowSelection(rows.slice(1), false); + await user.tab(); + await user.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}'); + await user.keyboard('{ArrowDown}'.repeat(9)); + await user.keyboard(' '); + + onSelectionChange.mockReset(); + await user.keyboard('{Shift>}{ArrowUp}{/Shift}'); + + checkSelection(onSelectionChange, ['Foo 9', 'Foo 10']); + checkRowSelection(rows.slice(1, 9), false); + checkRowSelection(rows.slice(9, 10), true); + checkRowSelection(rows.slice(11), false); + }); + + it('should extend a selection with Ctrl + Shift + Home', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + checkSelectAll(tree, 'unchecked'); + + let rows = tree.getAllByRole('row'); + checkRowSelection(rows.slice(1), false); + await user.tab(); + await user.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}'); + await user.keyboard('{ArrowDown}'.repeat(9)); + await user.keyboard(' '); + + onSelectionChange.mockReset(); + await user.keyboard('{Shift>}{Control>}{Home}{/Control}{/Shift}'); + + checkSelection(onSelectionChange, [ + 'Foo 1', 'Foo 2', 'Foo 3', 'Foo 4', 'Foo 5', + 'Foo 6', 'Foo 7', 'Foo 8', 'Foo 9', 'Foo 10' + ]); + + checkRowSelection(rows.slice(1, 11), true); + checkRowSelection(rows.slice(11), false); + }); + + it('should extend a selection with Ctrl + Shift + End', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + checkSelectAll(tree, 'unchecked'); + + let rows = tree.getAllByRole('row'); + checkRowSelection(rows.slice(1), false); + await user.tab(); + await user.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}'); + await user.keyboard('{ArrowDown}'.repeat(9)); + await user.keyboard(' '); + + onSelectionChange.mockReset(); + await user.keyboard('{Shift>}{Control>}{End}{/Control}{/Shift}'); + + let expected = []; + for (let i = 10; i <= 25; i++) { + expected.push('Foo ' + i); + } + + checkSelection(onSelectionChange, expected); + }); + + it('should extend a selection with Shift + PageDown', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + checkSelectAll(tree, 'unchecked'); + + let rows = tree.getAllByRole('row'); + checkRowSelection(rows.slice(1), false); + await user.tab(); + await user.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}'); + await user.keyboard('{ArrowDown}'.repeat(9)); + await user.keyboard(' '); + + onSelectionChange.mockReset(); + await user.keyboard('{Shift>}{PageDown}{/Shift}'); + + let expected = []; + for (let i = 10; i <= 25; i++) { + expected.push('Foo ' + i); + } + + checkSelection(onSelectionChange, expected); + }); + + it('should extend a selection with Shift + PageUp', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + checkSelectAll(tree, 'unchecked'); + + let rows = tree.getAllByRole('row'); + checkRowSelection(rows.slice(1), false); + await user.tab(); + await user.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}'); + await user.keyboard('{ArrowDown}'.repeat(9)); + await user.keyboard(' '); + + onSelectionChange.mockReset(); + await user.keyboard('{Shift>}{PageUp}{/Shift}'); + + checkSelection(onSelectionChange, [ + 'Foo 1', 'Foo 2', 'Foo 3', 'Foo 4', 'Foo 5', + 'Foo 6', 'Foo 7', 'Foo 8', 'Foo 9', 'Foo 10' + ]); + + checkRowSelection(rows.slice(1, 11), true); + checkRowSelection(rows.slice(11), false); + }); + + it('should not include disabled rows within a range selection', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange, disabledKeys: ['Foo 3', 'Foo 16']}); + checkSelectAll(tree, 'unchecked'); + + let rows = tree.getAllByRole('row'); + checkRowSelection(rows.slice(1), false); + await user.click(getCell(tree, 'Baz 1')); + + onSelectionChange.mockReset(); + await user.keyboard('[ShiftLeft>]'); + await user.click(getCell(tree, 'Baz 20')); + await user.keyboard('[/ShiftLeft]'); + + checkSelection(onSelectionChange, [ + 'Foo 1', 'Foo 2', 'Foo 4', 'Foo 5', 'Foo 6', 'Foo 7', 'Foo 8', 'Foo 9', 'Foo 10', + 'Foo 11', 'Foo 12', 'Foo 13', 'Foo 14', 'Foo 15', 'Foo 17', 'Foo 18', 'Foo 19', 'Foo 20' + ]); + + checkRowSelection(rows.slice(1, 3), true); + checkRowSelection(rows.slice(3, 4), false); + checkRowSelection(rows.slice(4, 16), true); + checkRowSelection(rows.slice(16, 17), false); + checkRowSelection(rows.slice(17, 21), true); + }); + }); + + describe('select all', function () { + it('should support selecting all via the checkbox', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')}); + tableTester.setInteractionType('keyboard'); + + checkSelectAll(tree, 'unchecked'); + + let rows = tableTester.rows; + checkRowSelection(rows.slice(1), false); + expect(tableTester.selectedRows).toHaveLength(0); + + await tableTester.toggleSelectAll(); + expect(tableTester.selectedRows).toHaveLength(tableTester.rows.length); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(onSelectionChange.mock.calls[0][0]).toEqual('all'); + checkRowSelection(rows.slice(1), true); + checkSelectAll(tree, 'checked'); + }); + + it('should support selecting all via ctrl + A', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + checkSelectAll(tree, 'unchecked'); + + let rows = tree.getAllByRole('row'); + checkRowSelection(rows.slice(1), false); + + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Control>}a{/Control}'); + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(onSelectionChange.mock.calls[0][0]).toEqual('all'); + checkRowSelection(rows.slice(1), true); + checkSelectAll(tree, 'checked'); + + await user.keyboard('{Control>}a{/Control}'); + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(onSelectionChange.mock.calls[0][0]).toEqual('all'); + checkRowSelection(rows.slice(1), true); + checkSelectAll(tree, 'checked'); + }); + + it('should deselect an item after selecting all', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + checkSelectAll(tree, 'unchecked'); + + let rows = tree.getAllByRole('row'); + checkRowSelection(rows.slice(1), false); + + await user.click(tree.getByTestId('selectAll')); + + onSelectionChange.mockReset(); + await user.click(rows[4]); + + let expected = []; + for (let i = 1; i <= 25; i++) { + if (i !== 4) { + expected.push('Foo ' + i); + } + } + + checkSelection(onSelectionChange, expected); + expect(rows[4]).toHaveAttribute('aria-selected', 'false'); + }); + + it('should shift click on an item after selecting all', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + checkSelectAll(tree, 'unchecked'); + + let rows = tree.getAllByRole('row'); + checkRowSelection(rows.slice(1), false); + + await user.click(tree.getByTestId('selectAll')); + + onSelectionChange.mockReset(); + await user.keyboard('[ShiftLeft>]'); + await user.click(rows[4]); + await user.keyboard('[/ShiftLeft]'); + + checkSelection(onSelectionChange, ['Foo 4']); + checkRowSelection(rows.slice(1, 4), false); + expect(rows[4]).toHaveAttribute('aria-selected', 'true'); + checkRowSelection(rows.slice(5), false); + }); + + it('should support clearing selection via checkbox', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + checkSelectAll(tree, 'unchecked'); + + let rows = tree.getAllByRole('row'); + checkRowSelection(rows.slice(1), false); + + await user.click(tree.getByTestId('selectAll')); + checkSelectAll(tree, 'checked'); + + onSelectionChange.mockReset(); + await user.click(tree.getByTestId('selectAll')); + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set()); + checkRowSelection(rows.slice(1), false); + checkSelectAll(tree, 'unchecked'); + }); + + it('should support clearing selection via Escape', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + checkSelectAll(tree, 'unchecked'); + + let rows = tree.getAllByRole('row'); + checkRowSelection(rows.slice(1), false); + await user.click(getCell(tree, 'Baz 1')); + checkSelectAll(tree, 'indeterminate'); + + onSelectionChange.mockReset(); + await user.keyboard('{ArrowLeft}'); + await user.keyboard('{Escape}'); + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set()); + checkRowSelection(rows.slice(1), false); + checkSelectAll(tree, 'unchecked'); + }); + + it('should only call onSelectionChange if there are selections to clear', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + checkSelectAll(tree, 'unchecked'); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Escape}'); + expect(onSelectionChange).not.toHaveBeenCalled(); + + await user.click(tree.getByTestId('selectAll')); + checkSelectAll(tree, 'checked'); + expect(onSelectionChange).toHaveBeenLastCalledWith('all'); + + onSelectionChange.mockReset(); + await user.keyboard('{ArrowDown}{ArrowRight}{ArrowRight}'); + await user.keyboard('{Escape}'); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set()); + }); + + it('should automatically select new items when select all is active', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + checkSelectAll(tree, 'unchecked'); + + let rows = tree.getAllByRole('row'); + checkRowSelection(rows.slice(1), false); + + await user.click(tree.getByTestId('selectAll')); + checkSelectAll(tree, 'checked'); + checkRowSelection(rows.slice(1), true); + + rerender(tree, renderJSX({onSelectionChange}, [ + {foo: 'Foo 0', bar: 'Bar 0', baz: 'Baz 0'}, + ...manyItems + ])); + + act(() => jest.runAllTimers()); + + expect(getCell(tree, 'Foo 0')).toBeVisible(); + checkRowSelection(rows.slice(1), true); + }); + + it('manually selecting all should not auto select new items', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}, items); + + checkSelectAll(tree, 'unchecked'); + + let rows = tree.getAllByRole('row'); + checkRowSelection(rows.slice(1), false); + + await user.click(rows[1]); + checkSelectAll(tree, 'indeterminate'); + + await user.click(rows[2]); + checkSelectAll(tree, 'checked'); + + rerender(tree, renderJSX({onSelectionChange}, [ + {foo: 'Foo 0', bar: 'Bar 0', baz: 'Baz 0'}, + ...items + ])); + + act(() => jest.runAllTimers()); + + rows = tree.getAllByRole('row'); + expect(getCell(tree, 'Foo 0')).toBeVisible(); + expect(rows[1]).toHaveAttribute('aria-selected', 'false'); + checkRowSelection(rows.slice(2), true); + checkSelectAll(tree, 'indeterminate'); + }); + + it('should not included disabled rows when selecting all', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange, disabledKeys: ['Foo 3']}); + + checkSelectAll(tree, 'unchecked'); + + let rows = tree.getAllByRole('row'); + checkRowSelection(rows.slice(1), false); + + await user.click(tree.getByTestId('selectAll')); + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(onSelectionChange.mock.calls[0][0]).toEqual('all'); + checkRowSelection(rows.slice(1, 3), true); + checkRowSelection(rows.slice(3, 4), false); + checkRowSelection(rows.slice(4, 20), true); + }); + }); + + describe('annoucements', function () { + it('should announce the selected or deselected row', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + let row = tree.getAllByRole('row')[1]; + await user.click(row); + expect(announce).toHaveBeenLastCalledWith('Foo 1 selected.'); + + await user.click(row); + expect(announce).toHaveBeenLastCalledWith('Foo 1 not selected.'); + }); + + it('should announce the row and number of selected items when there are more than one', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + let rows = tree.getAllByRole('row'); + await user.click(rows[1]); + await user.click(rows[2]); + + expect(announce).toHaveBeenLastCalledWith('Foo 2 selected. 2 items selected.'); + + await user.click(rows[2]); + expect(announce).toHaveBeenLastCalledWith('Foo 2 not selected. 1 item selected.'); + }); + + it('should announce only the number of selected items when multiple are selected at once', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + let rows = tree.getAllByRole('row'); + await user.click(rows[1]); + await user.keyboard('[ShiftLeft>]'); + await user.click(rows[3]); + + expect(announce).toHaveBeenLastCalledWith('3 items selected.'); + + await user.click(rows[1]); + await user.keyboard('[/ShiftLeft]'); + expect(announce).toHaveBeenLastCalledWith('1 item selected.'); + }); + + it('should announce select all', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + await user.click(tree.getByTestId('selectAll')); + expect(announce).toHaveBeenLastCalledWith('All items selected.'); + + await user.click(tree.getByTestId('selectAll')); + expect(announce).toHaveBeenLastCalledWith('No items selected.'); + }); + + it('should announce all row header columns', async function () { + let tree = render( + + + First Name + Last Name + Birthday + + + + Sam + Smith + May 3 + + + Julia + Jones + February 10 + + + + ); + + let row = tree.getAllByRole('row')[1]; + await user.click(row); + expect(announce).toHaveBeenLastCalledWith('Sam Smith selected.'); + + await user.click(row); + expect(announce).toHaveBeenLastCalledWith('Sam Smith not selected.'); + }); + + it('should announce changes in sort order', async function () { + let tree = render(); + let table = tree.getByRole('grid'); + let columnheaders = within(table).getAllByRole('columnheader'); + expect(columnheaders).toHaveLength(3); + + await user.click(columnheaders[1]); + expect(announce).toHaveBeenLastCalledWith('sorted by column Bar in descending order', 'assertive', 500); + await user.click(columnheaders[1]); + expect(announce).toHaveBeenLastCalledWith('sorted by column Bar in ascending order', 'assertive', 500); + await user.click(columnheaders[0]); + expect(announce).toHaveBeenLastCalledWith('sorted by column Foo in ascending order', 'assertive', 500); + await user.click(columnheaders[0]); + expect(announce).toHaveBeenLastCalledWith('sorted by column Foo in descending order', 'assertive', 500); + }); + }); + + it('can announce deselect even when items are swapped out completely', async () => { + let tree = render(); + + let row = tree.getAllByRole('row')[2]; + await user.click(row); + expect(announce).toHaveBeenLastCalledWith('File B selected.'); + + let link = tree.getAllByRole('link')[1]; + await user.click(link); + + expect(announce).toHaveBeenLastCalledWith('No items selected.'); + expect(announce).toHaveBeenCalledTimes(2); + }); + + it('will not announce deselect caused by breadcrumb navigation', async () => { + let tree = render(); + + let link = tree.getAllByRole('link')[1]; + await user.click(link); + + // TableWithBreadcrumbs has a setTimeout to load the results of the link navigation on Folder A + act(() => jest.runAllTimers()); + // Animation. + act(() => jest.runAllTimers()); + let row = tree.getAllByRole('row')[1]; + await user.click(row); + expect(announce).toHaveBeenLastCalledWith('File C selected.'); + expect(announce).toHaveBeenCalledTimes(2); + + // breadcrumb root + link = tree.getAllByRole('link')[0]; + await user.click(link); + + // focus isn't on the table, so we don't announce that it has been deselected + expect(announce).toHaveBeenCalledTimes(2); + }); + + it('updates even if not focused', async () => { + let tree = render(); + + let link = tree.getAllByRole('link')[1]; + await user.click(link); + + // TableWithBreadcrumbs has a setTimeout to load the results of the link navigation on Folder A + act(() => jest.runAllTimers()); + // Animation. + act(() => jest.runAllTimers()); + let row = tree.getAllByRole('row')[1]; + await user.click(row); + expect(announce).toHaveBeenLastCalledWith('File C selected.'); + expect(announce).toHaveBeenCalledTimes(2); + let button = tree.getAllByRole('button')[0]; + await user.click(button); + expect(announce).toHaveBeenCalledTimes(2); + + // breadcrumb root + link = tree.getAllByRole('menuitemradio')[0]; + await user.click(link); + + act(() => { + // TableWithBreadcrumbs has a setTimeout to load the results of the link navigation on Folder A + jest.runAllTimers(); + }); + + // focus isn't on the table, so we don't announce that it has been deselected + expect(announce).toHaveBeenCalledTimes(2); + + link = tree.getAllByRole('link')[1]; + await user.click(link); + + act(() => { + // TableWithBreadcrumbs has a setTimeout to load the results of the link navigation on Folder A + jest.runAllTimers(); + }); + + expect(announce).toHaveBeenCalledTimes(3); + expect(announce).toHaveBeenLastCalledWith('No items selected.'); + }); + + describe('onAction', function () { + it('should trigger onAction when clicking rows with the mouse', async function () { + let onSelectionChange = jest.fn(); + let onAction = jest.fn(); + let tree = renderTable({onSelectionChange, onAction}); + + let rows = tree.getAllByRole('row'); + await user.click(getCell(tree, 'Baz 10'), {pointerType: 'mouse'}); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(onAction).toHaveBeenCalledTimes(1); + expect(onAction).toHaveBeenLastCalledWith('Foo 10'); + checkRowSelection(rows.slice(1), false); + + let checkbox = within(rows[1]).getByRole('checkbox'); + await user.click(checkbox); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + checkRowSelection([rows[1]], true); + + await user.click(getCell(tree, 'Baz 10'), {pointerType: 'mouse'}); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + checkRowSelection([rows[1], rows[10]], true); + }); + + it('should trigger onAction when clicking rows with touch', async function () { + let onSelectionChange = jest.fn(); + let onAction = jest.fn(); + let tree = renderTable({onSelectionChange, onAction}); + + let rows = tree.getAllByRole('row'); + await user.click(getCell(tree, 'Baz 10'), {pointerType: 'touch'}); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(onAction).toHaveBeenCalledTimes(1); + expect(onAction).toHaveBeenLastCalledWith('Foo 10'); + checkRowSelection(rows.slice(1), false); + + let checkbox = within(rows[1]).getByRole('checkbox'); + await user.click(checkbox, {pointerType: 'touch'}); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + checkRowSelection([rows[1]], true); + + await user.click(getCell(tree, 'Baz 10'), {pointerType: 'touch'}); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + checkRowSelection([rows[1], rows[10]], true); + }); + + describe('needs PointerEvent defined', () => { + installPointerEvent(); + it('should support long press to enter selection mode on touch', async function () { + let onSelectionChange = jest.fn(); + let onAction = jest.fn(); + let tree = renderTable({onSelectionChange, onAction, selectionStyle: 'highlight'}); + let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')}); + tableTester.setInteractionType('touch'); + + act(() => jest.runAllTimers()); + await user.pointer({target: document.body, keys: '[TouchA]'}); + + await tableTester.toggleRowSelection({row: 'Foo 5', needsLongPress: true}); + checkSelection(onSelectionChange, ['Foo 5']); + expect(onAction).not.toHaveBeenCalled(); + onSelectionChange.mockReset(); + + await tableTester.toggleRowSelection({row: 'Foo 10', needsLongPress: false}); + checkSelection(onSelectionChange, ['Foo 5', 'Foo 10']); + + // Deselect all to exit selection mode + onSelectionChange.mockReset(); + await tableTester.toggleRowSelection({row: 'Foo 10', needsLongPress: false}); + checkSelection(onSelectionChange, ['Foo 5']); + onSelectionChange.mockReset(); + await tableTester.toggleRowSelection({row: 'Foo 5', needsLongPress: false}); + act(() => jest.runAllTimers()); + checkSelection(onSelectionChange, []); + expect(onAction).not.toHaveBeenCalled(); + }); + }); + + it('should trigger onAction when pressing Enter', async function () { + let onSelectionChange = jest.fn(); + let onAction = jest.fn(); + let tree = renderTable({onSelectionChange, onAction}); + let rows = tree.getAllByRole('row'); + + await user.tab(); + await user.keyboard('{ArrowDown}'.repeat(9)); + await user.keyboard('{Enter}'); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(onAction).toHaveBeenCalledTimes(1); + expect(onAction).toHaveBeenLastCalledWith('Foo 10'); + checkRowSelection(rows.slice(1), false); + + onAction.mockReset(); + await user.keyboard(' '); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(onAction).not.toHaveBeenCalled(); + checkRowSelection([rows[10]], true); + }); + }); + + describe('selectionStyle highlight', function () { + it('will replace the current selection with the new selection', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange, selectionStyle: 'highlight'}); + + expect(tree.queryByLabelText('Select All')).toBeNull(); + + let rows = tree.getAllByRole('row'); + checkRowSelection(rows.slice(1), false); + await user.click(getCell(tree, 'Baz 10')); + expect(announce).toHaveBeenLastCalledWith('Foo 10 selected.'); + expect(announce).toHaveBeenCalledTimes(1); + + onSelectionChange.mockReset(); + await user.keyboard('[ShiftLeft>]'); + await user.click(getCell(tree, 'Baz 20')); + await user.keyboard('[/ShiftLeft]'); + // await user.click(getCell(tree, 'Baz 20'), {pointerType: 'mouse', shiftKey: true}); + expect(announce).toHaveBeenLastCalledWith('11 items selected.'); + expect(announce).toHaveBeenCalledTimes(2); + + onSelectionChange.mockReset(); + await user.click(getCell(tree, 'Foo 5')); + expect(announce).toHaveBeenLastCalledWith('Foo 5 selected. 1 item selected.'); + expect(announce).toHaveBeenCalledTimes(3); + + checkSelection(onSelectionChange, [ + 'Foo 5' + ]); + + checkRowSelection(rows.slice(1, 5), false); + checkRowSelection(rows.slice(5, 6), true); + checkRowSelection(rows.slice(6), false); + + onSelectionChange.mockReset(); + await user.keyboard('[ShiftLeft>]'); + await user.click(getCell(tree, 'Foo 10')); + await user.keyboard('[/ShiftLeft]'); + expect(announce).toHaveBeenLastCalledWith('6 items selected.'); + expect(announce).toHaveBeenCalledTimes(4); + + checkSelection(onSelectionChange, [ + 'Foo 5', 'Foo 6', 'Foo 7', 'Foo 8', 'Foo 9', 'Foo 10' + ]); + + checkRowSelection(rows.slice(1, 5), false); + checkRowSelection(rows.slice(5, 11), true); + checkRowSelection(rows.slice(11), false); + }); + + it('will add to the current selection if the command key is pressed', async function () { + let uaMock = jest.spyOn(navigator, 'platform', 'get').mockImplementation(() => 'Mac'); + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange, selectionStyle: 'highlight'}); + + expect(tree.queryByLabelText('Select All')).toBeNull(); + + let rows = tree.getAllByRole('row'); + checkRowSelection(rows.slice(1), false); + await user.click(getCell(tree, 'Baz 10'), {pointerType: 'mouse'}); + + onSelectionChange.mockReset(); + await user.keyboard('[ShiftLeft>]'); + await user.click(getCell(tree, 'Baz 20')); + await user.keyboard('[/ShiftLeft]'); + + onSelectionChange.mockReset(); + await user.keyboard('[MetaLeft>]'); + await user.click(getCell(tree, 'Foo 5')); + await user.keyboard('[/MetaLeft]'); + + checkSelection(onSelectionChange, [ + 'Foo 5', 'Foo 10', 'Foo 11', 'Foo 12', 'Foo 13', 'Foo 14', 'Foo 15', + 'Foo 16', 'Foo 17', 'Foo 18', 'Foo 19', 'Foo 20' + ]); + + checkRowSelection(rows.slice(1, 5), false); + checkRowSelection(rows.slice(5, 6), true); + checkRowSelection(rows.slice(6, 10), false); + checkRowSelection(rows.slice(10, 21), true); + checkRowSelection(rows.slice(21), false); + + uaMock.mockRestore(); + }); + + describe('needs pointerEvents', function () { + installPointerEvent(); + it('should toggle selection with touch', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange, selectionStyle: 'highlight'}); + let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')}); + tableTester.setInteractionType('touch'); + expect(tree.queryByLabelText('Select All')).toBeNull(); + + await tableTester.toggleRowSelection({row: 'Baz 5'}); + expect(announce).toHaveBeenLastCalledWith('Foo 5 selected.'); + expect(announce).toHaveBeenCalledTimes(1); + onSelectionChange.mockReset(); + await tableTester.toggleRowSelection({row: 'Foo 10'}); + expect(announce).toHaveBeenLastCalledWith('Foo 10 selected. 2 items selected.'); + expect(announce).toHaveBeenCalledTimes(2); + + checkSelection(onSelectionChange, ['Foo 5', 'Foo 10']); + }); + + it('should support single tap to perform onAction with touch', async function () { + let onSelectionChange = jest.fn(); + let onAction = jest.fn(); + let tree = renderTable({onSelectionChange, selectionStyle: 'highlight', onAction}); + + await user.pointer({target: getCell(tree, 'Baz 5'), keys: '[TouchA]'}); + expect(announce).not.toHaveBeenCalled(); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(onAction).toHaveBeenCalledTimes(1); + expect(onAction).toHaveBeenCalledWith('Foo 5'); + }); + }); + + it('should support double click to perform onAction with mouse', async function () { + let onSelectionChange = jest.fn(); + let onAction = jest.fn(); + let tree = renderTable({onSelectionChange, selectionStyle: 'highlight', onAction}); + let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')}); + + await tableTester.toggleRowSelection({row: 'Foo 5'}); + expect(announce).toHaveBeenLastCalledWith('Foo 5 selected.'); + expect(announce).toHaveBeenCalledTimes(1); + checkSelection(onSelectionChange, ['Foo 5']); + expect(onAction).not.toHaveBeenCalled(); + + announce.mockReset(); + onSelectionChange.mockReset(); + await tableTester.triggerRowAction({row: 'Foo 5', needsDoubleClick: true}); + expect(announce).not.toHaveBeenCalled(); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(onAction).toHaveBeenCalledTimes(1); + expect(onAction).toHaveBeenCalledWith('Foo 5'); + }); + + describe('needs pointerEvents', function () { + installPointerEvent(); + it('should support single tap to perform row selection with screen reader if onAction isn\'t provided', function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange, selectionStyle: 'highlight'}); + + let cell = getCell(tree, 'Baz 5'); + fireEvent(cell, pointerEvent('pointerdown', {width: 0, height: 0, pointerType: 'touch'})); + fireEvent(cell, pointerEvent('mousedown', {})); + fireEvent(cell, pointerEvent('pointerup', {width: 0, height: 0, pointerType: 'touch'})); + fireEvent(cell, pointerEvent('mouseup', {})); + fireEvent(cell, pointerEvent('click', {})); + checkSelection(onSelectionChange, [ + 'Foo 5' + ]); + onSelectionChange.mockReset(); + + cell = getCell(tree, 'Foo 8'); + fireEvent(cell, pointerEvent('pointerdown', { + pointerId: 1, + width: 1, + height: 1, + pressure: 0, + detail: 0, + pointerType: 'mouse' + })); + fireEvent(cell, pointerEvent('pointerup', { + pointerId: 1, + width: 1, + height: 1, + pressure: 0, + detail: 0, + pointerType: 'mouse' + })); + fireEvent.click(cell, {pointerType: 'mouse', width: 1, height: 1, detail: 1}); + checkSelection(onSelectionChange, [ + 'Foo 5', 'Foo 8' + ]); + onSelectionChange.mockReset(); + + // Android TalkBack double tap test, virtual pointer event sets pointerType and onClick handles the rest + cell = getCell(tree, 'Foo 10'); + fireEvent(cell, pointerEvent('pointerdown', { + pointerId: 1, + width: 1, + height: 1, + pressure: 0, + detail: 0, + pointerType: 'mouse' + })); + fireEvent(cell, pointerEvent('pointerup', { + pointerId: 1, + width: 1, + height: 1, + pressure: 0, + detail: 0, + pointerType: 'mouse' + })); + fireEvent.click(cell, {pointerType: 'mouse', width: 1, height: 1, detail: 1}); + checkSelection(onSelectionChange, [ + 'Foo 5', 'Foo 8', 'Foo 10' + ]); + }); + + it('should support single tap to perform onAction with screen reader', function () { + let onSelectionChange = jest.fn(); + let onAction = jest.fn(); + let tree = renderTable({onSelectionChange, selectionStyle: 'highlight', onAction}); + + fireEvent.click(getCell(tree, 'Baz 5'), {detail: 0}); + expect(announce).not.toHaveBeenCalled(); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(onAction).toHaveBeenCalledTimes(1); + expect(onAction).toHaveBeenCalledWith('Foo 5'); + + // Android TalkBack double tap test, virtual pointer event sets pointerType and onClick handles the rest + let cell = getCell(tree, 'Foo 10'); + fireEvent(cell, pointerEvent('pointerdown', {pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0, pointerType: 'mouse'})); + fireEvent(cell, pointerEvent('pointerup', {pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0, pointerType: 'mouse'})); + fireEvent.click(cell, {pointerType: 'mouse', width: 1, height: 1, detail: 1}); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(onAction).toHaveBeenCalledTimes(2); + expect(onAction).toHaveBeenCalledWith('Foo 10'); + }); + }); + + describe('with pointer events', () => { + beforeEach(() => { + window.ontouchstart = jest.fn(); + }); + afterEach(() => { + delete window.ontouchstart; + }); + + describe('still needs pointer events install', function () { + installPointerEvent(); + it('should support long press to enter selection mode on touch', async function () { + let onSelectionChange = jest.fn(); + let onAction = jest.fn(); + let tree = renderTable({onSelectionChange, selectionStyle: 'highlight', onAction}); + act(() => { + jest.runAllTimers(); + }); + + let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')}); + tableTester.setInteractionType('touch'); + + await user.click(document.body); + + // TODO: Not replacing this with util for long press since it tests various things in the middle of the press + fireEvent.pointerDown(tableTester.findCell({text: 'Baz 5'}), {pointerType: 'touch'}); + let description = tree.getByText('Long press to enter selection mode.'); + expect(tree.getByRole('grid')).toHaveAttribute('aria-describedby', expect.stringContaining(description.id)); + expect(announce).not.toHaveBeenCalled(); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(onAction).not.toHaveBeenCalled(); + expect(tree.queryByLabelText('Select All')).toBeNull(); + + act(() => { + jest.advanceTimersByTime(800); + }); + + expect(announce).toHaveBeenLastCalledWith('Foo 5 selected.'); + expect(announce).toHaveBeenCalledTimes(1); + checkSelection(onSelectionChange, ['Foo 5']); + expect(onAction).not.toHaveBeenCalled(); + expect(tree.queryByLabelText('Select All')).not.toBeNull(); + + let cell = getCell(tree, 'Baz 5'); + fireEvent.pointerUp(cell, {pointerType: 'touch'}); + fireEvent.click(cell, {detail: 1}); + onSelectionChange.mockReset(); + act(() => { + jest.runAllTimers(); + }); + + await user.click(getCell(tree, 'Foo 10'), {pointerType: 'touch'}); + act(() => { + jest.runAllTimers(); + }); + expect(announce).toHaveBeenLastCalledWith('Foo 10 selected. 2 items selected.'); + expect(announce).toHaveBeenCalledTimes(2); + checkSelection(onSelectionChange, ['Foo 5', 'Foo 10']); + + // Deselect all to exit selection mode + await tableTester.toggleRowSelection({row: 'Foo 10'}); + expect(announce).toHaveBeenLastCalledWith('Foo 10 not selected. 1 item selected.'); + expect(announce).toHaveBeenCalledTimes(3); + onSelectionChange.mockReset(); + + await tableTester.toggleRowSelection({row: 'Foo 5'}); + expect(announce).toHaveBeenLastCalledWith('Foo 5 not selected.'); + expect(announce).toHaveBeenCalledTimes(4); + + checkSelection(onSelectionChange, []); + expect(onAction).not.toHaveBeenCalled(); + expect(tree.queryByLabelText('Select All')).toBeNull(); + }); + }); + }); + + it('should support Enter to perform onAction with keyboard', async function () { + let onSelectionChange = jest.fn(); + let onAction = jest.fn(); + renderTable({onSelectionChange, selectionStyle: 'highlight', onAction}); + + await user.tab(); + await user.keyboard('{ArrowDown}'.repeat(8)); + await user.keyboard('{ArrowRight}{ArrowRight}'); + onSelectionChange.mockReset(); + await user.keyboard('{ArrowDown}'); + checkSelection(onSelectionChange, ['Foo 10']); + expect(onAction).not.toHaveBeenCalled(); + + onSelectionChange.mockReset(); + await user.keyboard('{ArrowUp}'.repeat(5)); + onSelectionChange.mockReset(); + announce.mockReset(); + onSelectionChange.mockReset(); + await user.keyboard('{Enter}'); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(announce).not.toHaveBeenCalled(); + expect(onAction).toHaveBeenCalledTimes(1); + expect(onAction).toHaveBeenCalledWith('Foo 5'); + }); + + it('should perform onAction on single click with selectionMode: none', async function () { + let onSelectionChange = jest.fn(); + let onAction = jest.fn(); + let tree = renderTable({onSelectionChange, selectionMode: 'none', onAction}); + + await user.click(getCell(tree, 'Baz 10'), {pointerType: 'mouse'}); + expect(announce).not.toHaveBeenCalled(); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(onAction).toHaveBeenCalledTimes(1); + expect(onAction).toHaveBeenCalledWith('Foo 10'); + }); + + it('should move selection when using the arrow keys', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange, selectionStyle: 'highlight'}); + + await user.click(getCell(tree, 'Baz 5'), {pointerType: 'mouse'}); + expect(announce).toHaveBeenLastCalledWith('Foo 5 selected.'); + expect(announce).toHaveBeenCalledTimes(1); + checkSelection(onSelectionChange, ['Foo 5']); + + announce.mockReset(); + onSelectionChange.mockReset(); + await user.keyboard('{ArrowDown}'); + expect(announce).toHaveBeenCalledWith('Foo 6 selected.'); + checkSelection(onSelectionChange, ['Foo 6']); + + onSelectionChange.mockReset(); + await user.keyboard('{ArrowUp}'); + expect(announce).toHaveBeenCalledWith('Foo 5 selected.'); + checkSelection(onSelectionChange, ['Foo 5']); + + onSelectionChange.mockReset(); + await user.keyboard('{Shift>}{ArrowDown}{/Shift}'); + expect(announce).toHaveBeenCalledWith('Foo 6 selected. 2 items selected.'); + checkSelection(onSelectionChange, ['Foo 5', 'Foo 6']); + }); + + it('should announce the new row when moving with the keyboard after multi select', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange, selectionStyle: 'highlight'}); + + await user.click(getCell(tree, 'Baz 5'), {pointerType: 'mouse'}); + expect(announce).toHaveBeenLastCalledWith('Foo 5 selected.'); + expect(announce).toHaveBeenCalledTimes(1); + checkSelection(onSelectionChange, ['Foo 5']); + + announce.mockReset(); + onSelectionChange.mockReset(); + await user.keyboard('{Shift>}{ArrowDown}{/Shift}'); + expect(announce).toHaveBeenCalledWith('Foo 6 selected. 2 items selected.'); + checkSelection(onSelectionChange, ['Foo 5', 'Foo 6']); + + announce.mockReset(); + onSelectionChange.mockReset(); + await user.keyboard('{ArrowDown}'); + expect(announce).toHaveBeenCalledWith('Foo 7 selected. 1 item selected.'); + checkSelection(onSelectionChange, ['Foo 7']); + }); + + it('should support non-contiguous selection with the keyboard', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange, selectionStyle: 'highlight'}); + + await user.click(getCell(tree, 'Baz 5'), {pointerType: 'mouse'}); + expect(announce).toHaveBeenLastCalledWith('Foo 5 selected.'); + expect(announce).toHaveBeenCalledTimes(1); + checkSelection(onSelectionChange, ['Foo 5']); + + announce.mockReset(); + onSelectionChange.mockReset(); + await user.keyboard('{Control>}{ArrowDown}{/Control}'); + expect(announce).not.toHaveBeenCalled(); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(getCell(tree, 'Baz 6')); + + await user.keyboard('{Control>}{ArrowDown}{/Control}'); + expect(announce).not.toHaveBeenCalled(); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(getCell(tree, 'Baz 7')); + + await user.keyboard('{Control>} {/Control}'); + expect(announce).toHaveBeenCalledWith('Foo 7 selected. 2 items selected.'); + expect(announce).toHaveBeenCalledTimes(1); + checkSelection(onSelectionChange, ['Foo 5', 'Foo 7']); + + announce.mockReset(); + onSelectionChange.mockReset(); + await user.keyboard(' '); + expect(announce).toHaveBeenCalledWith('Foo 7 selected. 1 item selected.'); + expect(announce).toHaveBeenCalledTimes(1); + checkSelection(onSelectionChange, ['Foo 7']); + }); + + it('should not call onSelectionChange when hitting Space/Enter on the currently selected row', async function () { + let onSelectionChange = jest.fn(); + let onAction = jest.fn(); + renderTable({onSelectionChange, selectionStyle: 'highlight', onAction}); + + await user.tab(); + await user.keyboard('{ArrowDown}'.repeat(8)); + await user.keyboard('{ArrowRight}{ArrowRight}'); + onSelectionChange.mockReset(); + await user.keyboard('{ArrowDown}'); + checkSelection(onSelectionChange, ['Foo 10']); + expect(onAction).not.toHaveBeenCalled(); + + await user.keyboard(' '); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + + await user.keyboard('{Enter}'); + expect(onAction).toHaveBeenCalledTimes(1); + expect(onAction).toHaveBeenCalledWith('Foo 10'); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + }); + + it('should announce the current selection when moving from all to one item', async function () { + let onSelectionChange = jest.fn(); + let onAction = jest.fn(); + let tree = renderTable({onSelectionChange, selectionStyle: 'highlight', onAction}); + await user.click(getCell(tree, 'Baz 5'), {pointerType: 'mouse'}); + expect(announce).toHaveBeenLastCalledWith('Foo 5 selected.'); + expect(announce).toHaveBeenCalledTimes(1); + checkSelection(onSelectionChange, ['Foo 5']); + + announce.mockReset(); + onSelectionChange.mockReset(); + await user.keyboard('{Control>}a{/Control}'); + expect(onSelectionChange.mock.calls[0][0]).toEqual('all'); + expect(announce).toHaveBeenCalledWith('All items selected.'); + + announce.mockReset(); + onSelectionChange.mockReset(); + await user.keyboard('{ArrowDown}'); + expect(announce).toHaveBeenCalledWith('Foo 6 selected. 1 item selected.'); + checkSelection(onSelectionChange, ['Foo 6']); + }); + }); + }); + + describe('single selection', function () { + let renderJSX = (props, items = manyItems) => ( + + + {column => {column.name}} + + + {item => + ( + {key => {item[key]}} + ) + } + + + ); + + let renderTable = (props, items = manyItems) => render(renderJSX(props, items)); + + let checkSelection = (onSelectionChange, selectedKeys) => { + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(selectedKeys)); + }; + + let checkRowSelection = (rows, selected) => { + for (let row of rows) { + expect(row).toHaveAttribute('aria-selected', '' + selected); + } + }; + + describe('row selection', function () { + it('should select a row from checkbox', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + let row = tree.getAllByRole('row')[1]; + expect(row).toHaveAttribute('aria-selected', 'false'); + await user.click(within(row).getByRole('checkbox')); + + checkSelection(onSelectionChange, ['Foo 1']); + expect(row).toHaveAttribute('aria-selected', 'true'); + }); + + it('should select a row by pressing on a cell', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + let row = tree.getAllByRole('row')[1]; + expect(row).toHaveAttribute('aria-selected', 'false'); + await user.click(getCell(tree, 'Baz 1')); + + checkSelection(onSelectionChange, ['Foo 1']); + expect(row).toHaveAttribute('aria-selected', 'true'); + }); + + it('should select a row by pressing the Space key on a row', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + let row = tree.getAllByRole('row')[1]; + expect(row).toHaveAttribute('aria-selected', 'false'); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard(' '); + + checkSelection(onSelectionChange, ['Foo 1']); + expect(row).toHaveAttribute('aria-selected', 'true'); + }); + + it('should select a row by pressing the Enter key on a row', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + let row = tree.getAllByRole('row')[1]; + expect(row).toHaveAttribute('aria-selected', 'false'); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + + checkSelection(onSelectionChange, ['Foo 1']); + expect(row).toHaveAttribute('aria-selected', 'true'); + }); + + it('should select a row by pressing the Space key on a cell', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + let row = tree.getAllByRole('row')[1]; + expect(row).toHaveAttribute('aria-selected', 'false'); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + + checkSelection(onSelectionChange, ['Foo 1']); + expect(row).toHaveAttribute('aria-selected', 'true'); + }); + + it('should select a row by pressing the Enter key on a cell', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + let row = tree.getAllByRole('row')[1]; + expect(row).toHaveAttribute('aria-selected', 'false'); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + + checkSelection(onSelectionChange, ['Foo 1']); + expect(row).toHaveAttribute('aria-selected', 'true'); + }); + + it('will only select one if pointer is used to click on multiple rows', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + let rows = tree.getAllByRole('row'); + checkRowSelection(rows.slice(1), false); + await user.click(getCell(tree, 'Baz 1')); + + checkSelection(onSelectionChange, ['Foo 1']); + expect(rows[1]).toHaveAttribute('aria-selected', 'true'); + expect(rows[2]).toHaveAttribute('aria-selected', 'false'); + checkRowSelection(rows.slice(2), false); + + onSelectionChange.mockReset(); + await user.click(getCell(tree, 'Baz 2')); + + checkSelection(onSelectionChange, ['Foo 2']); + expect(rows[1]).toHaveAttribute('aria-selected', 'false'); + expect(rows[2]).toHaveAttribute('aria-selected', 'true'); + checkRowSelection(rows.slice(3), false); + + // Deselect + onSelectionChange.mockReset(); + await user.click(getCell(tree, 'Baz 2')); + + checkSelection(onSelectionChange, []); + expect(rows[1]).toHaveAttribute('aria-selected', 'false'); + expect(rows[2]).toHaveAttribute('aria-selected', 'false'); + checkRowSelection(rows.slice(2), false); + }); + + it('will only select one if pointer is used to click on multiple checkboxes', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + let rows = tree.getAllByRole('row'); + checkRowSelection(rows.slice(1), false); + await user.click(within(rows[1]).getByRole('checkbox')); + + checkSelection(onSelectionChange, ['Foo 1']); + expect(rows[1]).toHaveAttribute('aria-selected', 'true'); + expect(rows[2]).toHaveAttribute('aria-selected', 'false'); + checkRowSelection(rows.slice(2), false); + + onSelectionChange.mockReset(); + await user.click(within(rows[2]).getByRole('checkbox')); + + checkSelection(onSelectionChange, ['Foo 2']); + expect(rows[1]).toHaveAttribute('aria-selected', 'false'); + expect(rows[2]).toHaveAttribute('aria-selected', 'true'); + checkRowSelection(rows.slice(3), false); + + // Deselect + onSelectionChange.mockReset(); + await user.click(within(rows[2]).getByRole('checkbox')); + + checkSelection(onSelectionChange, []); + expect(rows[1]).toHaveAttribute('aria-selected', 'false'); + expect(rows[2]).toHaveAttribute('aria-selected', 'false'); + checkRowSelection(rows.slice(2), false); + }); + + it('should support selecting single row only with the Space key', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + + let rows = tree.getAllByRole('row'); + checkRowSelection(rows.slice(1), false); + await user.tab(); + await user.keyboard('{ArrowRight}{ArrowRight}'); + await user.keyboard(' '); + + checkSelection(onSelectionChange, ['Foo 1']); + expect(rows[1]).toHaveAttribute('aria-selected', 'true'); + expect(rows[2]).toHaveAttribute('aria-selected', 'false'); + checkRowSelection(rows.slice(2), false); + + onSelectionChange.mockReset(); + await user.keyboard('{ArrowDown}'); + await user.keyboard(' '); + + checkSelection(onSelectionChange, ['Foo 2']); + expect(rows[1]).toHaveAttribute('aria-selected', 'false'); + expect(rows[2]).toHaveAttribute('aria-selected', 'true'); + checkRowSelection(rows.slice(3), false); + + // Deselect + onSelectionChange.mockReset(); + await user.keyboard(' '); + + checkSelection(onSelectionChange, []); + expect(rows[1]).toHaveAttribute('aria-selected', 'false'); + expect(rows[2]).toHaveAttribute('aria-selected', 'false'); + checkRowSelection(rows.slice(2), false); + }); + + it('should not select a disabled row from checkbox or keyboard interaction', async function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange, disabledKeys: ['Foo 1']}); + + let row = tree.getAllByRole('row')[1]; + expect(row).toHaveAttribute('aria-selected', 'false'); + await user.click(within(row).getByRole('checkbox')); + await user.click(getCell(tree, 'Baz 1')); + await user.keyboard('{ArrowLeft}{ArrowLeft}{ArrowLeft}'); + await user.keyboard(' '); + await user.keyboard('{ArrowRight}{ArrowRight}'); + await user.keyboard(' '); + await user.keyboard('{Enter}'); + + expect(row).toHaveAttribute('aria-selected', 'false'); + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + }); + + describe('row selection column header', function () { + it('should contain a hidden checkbox and VisuallyHidden accessible text', function () { + let onSelectionChange = jest.fn(); + let tree = renderTable({onSelectionChange}); + let columnheader = tree.getAllByRole('columnheader')[0]; + let checkboxInput = columnheader.querySelector('input[type="checkbox"]'); + expect(columnheader).not.toHaveAttribute('aria-disabled', 'true'); + expect(columnheader.firstElementChild).toBeVisible(); + expect(checkboxInput).not.toBeVisible(); + expect(checkboxInput.getAttribute('aria-label')).toEqual('Select'); + expect(columnheader.firstElementChild.textContent).toEqual(checkboxInput.getAttribute('aria-label')); + }); + }); + }); + + describe('press/hover interactions and selection mode', function () { + let TableWithBreadcrumbs = (props) => ( + + + {column => {column.name}} + + + {item => + ( + {key => {item[key]}} + ) + } + + + ); + + it('displays pressed/hover styles when row is pressed/hovered and selection mode is not "none"', async function () { + let tree = render(); + + let row = tree.getAllByRole('row')[1]; + await user.hover(row); + expect(row.className.includes('is-hovered')).toBeTruthy(); + await user.pointer({target: row, keys: '[MouseLeft>]'}); + expect(row.className.includes('is-active')).toBeTruthy(); + await user.pointer({target: row, keys: '[/MouseLeft]'}); + + rerender(tree, ); + row = tree.getAllByRole('row')[1]; + await user.hover(row); + expect(row.className.includes('is-hovered')).toBeTruthy(); + await user.pointer({target: row, keys: '[MouseLeft>]'}); + expect(row.className.includes('is-active')).toBeTruthy(); + await user.pointer({target: row, keys: '[/MouseLeft]'}); + }); + + it('doesn\'t show pressed/hover styles when row is pressed/hovered and selection mode is "none" and disabledBehavior="all"', async function () { + let tree = render(); + + let row = tree.getAllByRole('row')[1]; + await user.hover(row); + expect(row.className.includes('is-hovered')).toBeFalsy(); + await user.pointer({target: row, keys: '[MouseLeft>]'}); + expect(row.className.includes('is-active')).toBeFalsy(); + await user.pointer({target: row, keys: '[/MouseLeft]'}); + }); + + it('shows pressed/hover styles when row is pressed/hovered and selection mode is "none", disabledBehavior="selection" and has a action', async function () { + let tree = render(); + + let row = tree.getAllByRole('row')[1]; + await user.hover(row); + expect(row.className.includes('is-hovered')).toBeTruthy(); + await user.pointer({target: row, keys: '[MouseLeft>]'}); + expect(row.className.includes('is-active')).toBeTruthy(); + await user.pointer({target: row, keys: '[/MouseLeft]'}); + }); + + it('shows pressed/hover styles when row is pressed/hovered, disabledBehavior="selection", row is disabled and has a action', async function () { + let tree = render(); + + let row = tree.getAllByRole('row')[1]; + await user.hover(row); + expect(row.className.includes('is-hovered')).toBeTruthy(); + await user.pointer({target: row, keys: '[MouseLeft>]'}); + expect(row.className.includes('is-active')).toBeTruthy(); + await user.pointer({target: row, keys: '[/MouseLeft]'}); + }); + + it('doesn\'t show pressed/hover styles when row is pressed/hovered, has a action, but is disabled and disabledBehavior="all"', async function () { + let tree = render(); + + let row = tree.getAllByRole('row')[1]; + await user.hover(row); + expect(row.className.includes('is-hovered')).toBeFalsy(); + await user.pointer({target: row, keys: '[MouseLeft>]'}); + expect(row.className.includes('is-active')).toBeFalsy(); + await user.pointer({target: row, keys: '[/MouseLeft]'}); + }); + }); + + describe('CRUD', function () { + it('can add items', async function () { + let tree = render(); + + let table = tree.getByRole('grid'); + let rows = within(table).getAllByRole('row'); + expect(rows).toHaveLength(3); + expect(rows[1]).toHaveAttribute('aria-rowindex', '2'); + expect(rows[2]).toHaveAttribute('aria-rowindex', '3'); + + let button = tree.getByLabelText('Add item'); + await user.click(button); + act(() => {jest.runAllTimers();}); + + let dialog = tree.getByRole('dialog'); + expect(dialog).toBeVisible(); + + await user.keyboard('Devon'); + await user.tab(); + + await user.keyboard('Govett'); + await user.tab(); + + await user.keyboard('Feb 3'); + await user.tab(); + + let createButton = tree.getByText('Create'); + await user.click(createButton); + act(() => {jest.runAllTimers();}); + + expect(dialog).not.toBeInTheDocument(); + + rows = within(table).getAllByRole('row'); + expect(rows).toHaveLength(4); + expect(rows[1]).toHaveAttribute('aria-rowindex', '2'); + expect(rows[2]).toHaveAttribute('aria-rowindex', '3'); + expect(rows[3]).toHaveAttribute('aria-rowindex', '4'); + + let rowHeaders = within(rows[1]).getAllByRole('rowheader'); + expect(rowHeaders[0]).toHaveTextContent('Devon'); + expect(rowHeaders[1]).toHaveTextContent('Govett'); + + let cells = within(rows[1]).getAllByRole('gridcell'); + expect(cells[1]).toHaveTextContent('Feb 3'); + }); + + it('can remove items', async function () { + let tree = render(); + + let table = tree.getByRole('grid'); + let rows = within(table).getAllByRole('row'); + expect(rows).toHaveLength(3); + + await user.tab(); + await user.tab(); + expect(document.activeElement).toBe(rows[1]); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowLeft'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowLeft'}); + expect(document.activeElement).toBe(within(rows[1]).getByRole('button')); + + fireEvent.keyDown(document.activeElement, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + + let menu = tree.getByRole('menu'); + let menuItems = within(menu).getAllByRole('menuitem'); + expect(menuItems.length).toBe(2); + expect(document.activeElement).toBe(menuItems[0]); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + expect(document.activeElement).toBe(menuItems[1]); + + fireEvent.keyDown(document.activeElement, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + expect(menu).not.toBeInTheDocument(); + + let dialog = tree.getByRole('alertdialog', {hidden: true}); + let deleteButton = within(dialog).getByRole('button', {hidden: true}); + + await user.click(deleteButton); + act(() => jest.runAllTimers()); + expect(dialog).not.toBeInTheDocument(); + + act(() => jest.runAllTimers()); + expect(rows[1]).not.toBeInTheDocument(); + + rows = within(table).getAllByRole('row'); + expect(rows).toHaveLength(2); + + expect(within(rows[1]).getAllByRole('rowheader')[0]).toHaveTextContent('Julia'); + + act(() => jest.runAllTimers()); + expect(document.activeElement).toBe(within(rows[1]).getByRole('button')); + }); + + it('resets row indexes after deleting a row', async function () { + let tree = render(); + + let table = tree.getByRole('grid'); + let rows = within(table).getAllByRole('row'); + expect(rows).toHaveLength(3); + expect(rows[1]).toHaveAttribute('aria-rowindex', '2'); + expect(rows[2]).toHaveAttribute('aria-rowindex', '3'); + + let button = within(rows[1]).getByRole('button'); + await user.click(button); + + let menu = tree.getByRole('menu'); + expect(document.activeElement).toBe(menu); + + let menuItems = within(menu).getAllByRole('menuitem'); + expect(menuItems.length).toBe(2); + + await user.click(menuItems[1]); + act(() => jest.runAllTimers()); + expect(menu).not.toBeInTheDocument(); + + let dialog = tree.getByRole('alertdialog', {hidden: true}); + let deleteButton = within(dialog).getByRole('button', {hidden: true}); + + await user.click(deleteButton); + act(() => jest.runAllTimers()); + expect(dialog).not.toBeInTheDocument(); + + act(() => jest.runAllTimers()); + act(() => jest.runAllTimers()); + expect(rows[1]).not.toBeInTheDocument(); + + rows = within(table).getAllByRole('row'); + expect(rows).toHaveLength(2); + + let rowHeaders = within(rows[1]).getAllByRole('rowheader'); + expect(rowHeaders[0]).toHaveTextContent('Julia'); + + expect(rows[1]).toHaveAttribute('aria-rowindex', '2'); + }); + + it('can bulk remove items', async function () { + let tree = render(); + + let addButton = tree.getAllByRole('button')[0]; + let table = tree.getByRole('grid'); + let rows = within(table).getAllByRole('row'); + expect(rows).toHaveLength(3); + + let checkbox = within(rows[0]).getByRole('checkbox'); + await user.click(checkbox); + expect(checkbox.checked).toBe(true); + + let deleteButton = tree.getByLabelText('Delete selected items'); + await user.click(deleteButton); + + let dialog = tree.getByRole('alertdialog'); + let confirmButton = within(dialog).getByRole('button'); + expect(document.activeElement).toBe(dialog); + + await user.click(confirmButton); + act(() => jest.runAllTimers()); + expect(dialog).not.toBeInTheDocument(); + + act(() => jest.runAllTimers()); + + rows = within(table).getAllByRole('row'); + + // account for renderEmptyState + await act(() => Promise.resolve()); + expect(rows).toHaveLength(2); + expect(rows[1].firstChild.getAttribute('aria-colspan')).toBe('5'); + expect(rows[1].textContent).toBe('No results'); + + expect(checkbox.checked).toBe(false); + + expect(document.activeElement).toBe(addButton); + }); + + it('can edit items', async function () { + let tree = render(); + + let table = tree.getByRole('grid'); + let rows = within(table).getAllByRole('row'); + expect(rows).toHaveLength(3); + + let button = within(rows[2]).getByRole('button'); + await user.click(button); + act(() => {jest.runAllTimers();}); + + let menu = tree.getByRole('menu'); + expect(document.activeElement).toBe(menu); + + let menuItems = within(menu).getAllByRole('menuitem'); + expect(menuItems.length).toBe(2); + + await user.click(menuItems[0]); + act(() => {jest.runAllTimers();}); + expect(menu).not.toBeInTheDocument(); + + let dialog = tree.getByRole('dialog'); + expect(dialog).toBeVisible(); + + let firstName = tree.getByLabelText('First Name'); + expect(document.activeElement).toBe(firstName); + await user.keyboard('Jessica'); + + let saveButton = tree.getByText('Save'); + await user.click(saveButton); + + act(() => {jest.runAllTimers();}); + act(() => {jest.runAllTimers();}); + + expect(dialog).not.toBeInTheDocument(); + + let rowHeaders = within(rows[2]).getAllByRole('rowheader'); + expect(rowHeaders[0]).toHaveTextContent('Jessica'); + expect(rowHeaders[1]).toHaveTextContent('Jones'); + expect(document.activeElement).toBe(button); + }); + + it('keyboard navigation works as expected with menu buttons', async function () { + let tree = render(); + + let table = tree.getByRole('grid'); + let rows = within(table).getAllByRole('row'); + expect(rows).toHaveLength(3); + + act(() => within(rows[1]).getAllByRole('gridcell').pop().focus()); + act(() => {jest.runAllTimers();}); + let button = within(rows[1]).getByRole('button'); + expect(document.activeElement).toBe(button); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); + + expect(tree.queryByRole('menu')).toBeNull(); + + expect(document.activeElement).toBe(within(rows[2]).getByRole('button')); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowUp'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowUp'}); + + expect(tree.queryByRole('menu')).toBeNull(); + + expect(document.activeElement).toBe(button); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown', altKey: true}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowDown', altKey: true}); + act(() => {jest.runAllTimers();}); + + let menu = tree.getByRole('menu'); + expect(document.activeElement).toBe(within(menu).getAllByRole('menuitem')[0]); + + fireEvent.keyDown(document.activeElement, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + act(() => {jest.runAllTimers();}); + await user.tab(); + act(() => {jest.runAllTimers();}); + await user.tab(); + act(() => {jest.runAllTimers();}); + await user.tab(); + act(() => {jest.runAllTimers();}); + await user.tab(); + act(() => {jest.runAllTimers();}); + expect(document.activeElement).toBe(tree.getAllByRole('button')[1]); + + fireEvent.keyDown(document.activeElement, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + act(() => {jest.runAllTimers();}); + act(() => {jest.runAllTimers();}); + + expect(document.activeElement).toBe(button); + }); + + it('menu buttons can be opened with Alt + ArrowDown', function () { + let tree = render(); + + let table = tree.getByRole('grid'); + let rows = within(table).getAllByRole('row'); + expect(rows).toHaveLength(3); + + act(() => within(rows[1]).getAllByRole('gridcell').pop().focus()); + act(() => {jest.runAllTimers();}); + expect(document.activeElement).toBe(within(rows[1]).getByRole('button')); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown', altKey: true}); + + let menu = tree.getByRole('menu'); + expect(menu).toBeInTheDocument(); + expect(document.activeElement).toBe(within(menu).getAllByRole('menuitem')[0]); + }); + + it('menu buttons can be opened with Alt + ArrowUp', function () { + let tree = render(); + + let table = tree.getByRole('grid'); + let rows = within(table).getAllByRole('row'); + expect(rows).toHaveLength(3); + + act(() => within(rows[1]).getAllByRole('gridcell').pop().focus()); + act(() => {jest.runAllTimers();}); + expect(document.activeElement).toBe(within(rows[1]).getByRole('button')); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowUp', altKey: true}); + + let menu = tree.getByRole('menu'); + expect(menu).toBeInTheDocument(); + expect(document.activeElement).toBe(within(menu).getAllByRole('menuitem').pop()); + }); + + it('menu keyboard navigation does not affect table', function () { + let tree = render(); + + let table = tree.getByRole('grid'); + let rows = within(table).getAllByRole('row'); + expect(rows).toHaveLength(3); + + act(() => within(rows[1]).getAllByRole('gridcell').pop().focus()); + act(() => {jest.runAllTimers();}); + expect(document.activeElement).toBe(within(rows[1]).getByRole('button')); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown', altKey: true}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowDown', altKey: true}); + + let menu = tree.getByRole('menu'); + expect(menu).toBeInTheDocument(); + expect(document.activeElement).toBe(within(menu).getAllByRole('menuitem')[0]); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + expect(document.activeElement).toBe(within(menu).getAllByRole('menuitem')[1]); + + fireEvent.keyDown(document.activeElement, {key: 'Escape'}); + fireEvent.keyUp(document.activeElement, {key: 'Escape'}); + + act(() => jest.runAllTimers()); + + expect(menu).not.toBeInTheDocument(); + expect(document.activeElement).toBe(within(rows[1]).getByRole('button')); + }); + }); + + describe('with dialog trigger', function () { + let TableWithBreadcrumbs = (props) => ( + + + Foo + Bar + Baz + + + + One + Two + + + + {close => ( + + The Heading + + + + + + + + + + )} + + + + + + ); + + it('arrow keys interactions don\'t move the focus away from the textfield in the dialog', async function () { + let tree = render(); + let table = tree.getByRole('grid'); + let rows = within(table).getAllByRole('row'); + expect(rows).toHaveLength(2); + + let button = within(rows[1]).getByRole('button'); + await user.click(button); + + let dialog = tree.getByRole('dialog'); + let input = within(dialog).getByTestId('input'); + + expect(input).toBeTruthy(); + await user.type(input, 'blah'); + expect(document.activeElement).toEqual(input); + expect(input.value).toBe('blah'); + + fireEvent.keyDown(input, {key: 'ArrowLeft', code: 37, charCode: 37}); + fireEvent.keyUp(input, {key: 'ArrowLeft', code: 37, charCode: 37}); + act(() => { + jest.runAllTimers(); + }); + + expect(document.activeElement).toEqual(input); + + fireEvent.keyDown(input, {key: 'ArrowRight', code: 39, charCode: 39}); + fireEvent.keyUp(input, {key: 'ArrowRight', code: 39, charCode: 39}); + act(() => { + jest.runAllTimers(); + }); + + expect(document.activeElement).toEqual(input); + + fireEvent.keyDown(input, {key: 'Escape', code: 27, charCode: 27}); + fireEvent.keyUp(input, {key: 'Escape', code: 27, charCode: 27}); + act(() => { + jest.runAllTimers(); + }); + + expect(dialog).not.toBeInTheDocument(); + }); + }); + + describe('async loading', function () { + it('should display a spinner when loading', function () { + let tree = render( + + + Foo + Bar + + + {[]} + + + ); + + let table = tree.getByRole('grid'); + let rows = within(table).getAllByRole('row'); + expect(rows).toHaveLength(2); + expect(rows[1]).toHaveAttribute('aria-rowindex', '2'); + + let cell = within(rows[1]).getByRole('rowheader'); + expect(cell).toHaveAttribute('aria-colspan', '3'); + + let spinner = within(cell).getByRole('progressbar'); + expect(spinner).toBeVisible(); + expect(spinner).toHaveAttribute('aria-label', 'Loading…'); + expect(spinner).not.toHaveAttribute('aria-valuenow'); + + rerender(tree, defaultTable); + act(() => jest.runAllTimers()); + + rows = within(table).getAllByRole('row'); + expect(rows).toHaveLength(3); + expect(spinner).not.toBeInTheDocument(); + }); + + it('should display a spinner at the bottom when loading more', function () { + let tree = render( + + + Foo + Bar + + + + Foo 1 + Bar 1 + + + Foo 2 + Bar 2 + + + + ); + + let table = tree.getByRole('grid'); + let rows = within(table).getAllByRole('row'); + expect(rows).toHaveLength(4); + expect(rows[3]).toHaveAttribute('aria-rowindex', '4'); + + let cell = within(rows[3]).getByRole('rowheader'); + expect(cell).toHaveAttribute('aria-colspan', '2'); + + let spinner = within(cell).getByRole('progressbar'); + expect(spinner).toBeVisible(); + expect(spinner).toHaveAttribute('aria-label', 'Loading more…'); + expect(spinner).not.toHaveAttribute('aria-valuenow'); + + rerender(tree, defaultTable); + act(() => jest.runAllTimers()); + + rows = within(table).getAllByRole('row'); + expect(rows).toHaveLength(3); + expect(spinner).not.toBeInTheDocument(); + }); + + it('should not display a spinner when filtering', function () { + let tree = render( + + + Foo + Bar + + + + Foo 1 + Bar 1 + + + Foo 2 + Bar 2 + + + + ); + + let table = tree.getByRole('grid'); + expect(within(table).queryByRole('progressbar')).toBeNull(); + }); + + it('should fire onLoadMore when scrolling near the bottom', function () { + let scrollHeightMock = jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 4100); + let items = []; + for (let i = 1; i <= 100; i++) { + items.push({id: i, foo: 'Foo ' + i, bar: 'Bar ' + i}); + } + + let onLoadMore = jest.fn(); + let tree = render( + + + Foo + Bar + + + {row => ( + + {key => {row[key]}} + + )} + + + ); + + let body = tree.getAllByRole('rowgroup')[1]; + let scrollView = body; + + let rows = within(body).getAllByRole('row'); + expect(rows).toHaveLength(34); // each row is 41px tall. table is 1000px tall. 25 rows fit. + 1/3 overscan + + scrollView.scrollTop = 250; + fireEvent.scroll(scrollView); + act(() => {jest.runAllTimers();}); + + scrollView.scrollTop = 1500; + fireEvent.scroll(scrollView); + act(() => {jest.runAllTimers();}); + + scrollView.scrollTop = 2800; + fireEvent.scroll(scrollView); + act(() => {jest.runAllTimers();}); + + expect(onLoadMore).toHaveBeenCalledTimes(1); + scrollHeightMock.mockReset(); + }); + + it('should automatically fire onLoadMore if there aren\'t enough items to fill the Table', function () { + let scrollHeightMock = jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 1000); + let items = [{id: 1, foo: 'Foo 1', bar: 'Bar 1'}]; + let onLoadMore = jest.fn(() => { + scrollHeightMock = jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 2000); + }); + + let TableMock = (props) => ( + + + Foo + Bar + + + {row => ( + + {key => {row[key]}} + + )} + + + ); + + render(); + act(() => jest.runAllTimers()); + expect(onLoadMore).toHaveBeenCalledTimes(1); + scrollHeightMock.mockReset(); + }); + }); + + describe('sorting', function () { + it('should set the proper aria-describedby and aria-sort on sortable column headers', function () { + let tree = render( + + + Foo + Bar + Baz + + + + Foo 1 + Bar 1 + Baz 1 + + + + ); + + let table = tree.getByRole('grid'); + let columnheaders = within(table).getAllByRole('columnheader'); + expect(columnheaders).toHaveLength(3); + expect(columnheaders[0]).toHaveAttribute('aria-sort', 'none'); + expect(columnheaders[1]).toHaveAttribute('aria-sort', 'none'); + expect(columnheaders[2]).not.toHaveAttribute('aria-sort'); + expect(columnheaders[0]).toHaveAttribute('aria-describedby'); + expect(document.getElementById(columnheaders[0].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); + expect(columnheaders[1]).toHaveAttribute('aria-describedby'); + expect(document.getElementById(columnheaders[1].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); + expect(columnheaders[2]).not.toHaveAttribute('aria-describedby'); + }); + + it('should set the proper aria-describedby and aria-sort on an ascending sorted column header', function () { + let tree = render( + + + Foo + Bar + Baz + + + + Foo 1 + Bar 1 + Baz 1 + + + + ); + + let table = tree.getByRole('grid'); + let columnheaders = within(table).getAllByRole('columnheader'); + expect(columnheaders).toHaveLength(3); + expect(columnheaders[0]).toHaveAttribute('aria-sort', 'none'); + expect(columnheaders[1]).toHaveAttribute('aria-sort', 'ascending'); + expect(columnheaders[2]).not.toHaveAttribute('aria-sort'); + expect(columnheaders[0]).toHaveAttribute('aria-describedby'); + expect(document.getElementById(columnheaders[0].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); + expect(columnheaders[1]).toHaveAttribute('aria-describedby'); + expect(document.getElementById(columnheaders[1].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); + expect(columnheaders[2]).not.toHaveAttribute('aria-describedby'); + }); + + it('should set the proper aria-describedby and aria-sort on an descending sorted column header', function () { + let tree = render( + + + Foo + Bar + Baz + + + + Foo 1 + Bar 1 + Baz 1 + + + + ); + + let table = tree.getByRole('grid'); + let columnheaders = within(table).getAllByRole('columnheader'); + expect(columnheaders).toHaveLength(3); + expect(columnheaders[0]).toHaveAttribute('aria-sort', 'none'); + expect(columnheaders[1]).toHaveAttribute('aria-sort', 'descending'); + expect(columnheaders[2]).not.toHaveAttribute('aria-sort'); + expect(columnheaders[0]).toHaveAttribute('aria-describedby'); + expect(document.getElementById(columnheaders[0].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); + expect(columnheaders[1]).toHaveAttribute('aria-describedby'); + expect(document.getElementById(columnheaders[1].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); + expect(columnheaders[2]).not.toHaveAttribute('aria-describedby'); + }); + + it('should add sort direction info to the column header\'s aria-describedby for Android', async function () { + let uaMock = jest.spyOn(navigator, 'userAgent', 'get').mockImplementation(() => 'Android'); + let tree = render(); + let tableTester = testUtilUser.createTester('Table', {root: tree.getByRole('grid')}); + tableTester.setInteractionType('keyboard'); + let columnheaders = tableTester.columns; + expect(columnheaders).toHaveLength(3); + expect(columnheaders[0]).not.toHaveAttribute('aria-sort'); + expect(columnheaders[1]).not.toHaveAttribute('aria-sort'); + expect(columnheaders[2]).not.toHaveAttribute('aria-sort'); + expect(columnheaders[0]).toHaveAttribute('aria-describedby'); + expect(document.getElementById(columnheaders[0].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); + expect(columnheaders[1]).toHaveAttribute('aria-describedby'); + expect(document.getElementById(columnheaders[1].getAttribute('aria-describedby'))).toHaveTextContent('sortable column, ascending'); + expect(columnheaders[2]).not.toHaveAttribute('aria-describedby'); + + await tableTester.toggleSort({column: 1}); + expect(document.getElementById(columnheaders[1].getAttribute('aria-describedby'))).toHaveTextContent('sortable column, descending'); + + uaMock.mockRestore(); + }); + + it('should fire onSortChange when there is no existing sortDescriptor', async function () { + let onSortChange = jest.fn(); + let tree = render( + + + Foo + Bar + Baz + + + + Foo 1 + Bar 1 + Baz 1 + + + + ); + + let table = tree.getByRole('grid'); + let columnheaders = within(table).getAllByRole('columnheader'); + expect(columnheaders).toHaveLength(3); + expect(columnheaders[0]).toHaveAttribute('aria-sort', 'none'); + expect(columnheaders[1]).toHaveAttribute('aria-sort', 'none'); + expect(columnheaders[2]).not.toHaveAttribute('aria-sort'); + expect(columnheaders[0]).toHaveAttribute('aria-describedby'); + expect(document.getElementById(columnheaders[0].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); + expect(columnheaders[1]).toHaveAttribute('aria-describedby'); + expect(document.getElementById(columnheaders[1].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); + expect(columnheaders[2]).not.toHaveAttribute('aria-describedby'); + + await user.click(columnheaders[0]); + + expect(onSortChange).toHaveBeenCalledTimes(1); + expect(onSortChange).toHaveBeenCalledWith({column: 'foo', direction: 'ascending'}); + }); + + it('should toggle the sort direction from ascending to descending', async function () { + let onSortChange = jest.fn(); + let tree = render( + + + Foo + Bar + Baz + + + + Foo 1 + Bar 1 + Baz 1 + + + + ); + + let table = tree.getByRole('grid'); + let columnheaders = within(table).getAllByRole('columnheader'); + expect(columnheaders).toHaveLength(3); + expect(columnheaders[0]).toHaveAttribute('aria-sort', 'ascending'); + expect(columnheaders[1]).toHaveAttribute('aria-sort', 'none'); + expect(columnheaders[2]).not.toHaveAttribute('aria-sort'); + expect(columnheaders[0]).toHaveAttribute('aria-describedby'); + expect(document.getElementById(columnheaders[0].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); + expect(columnheaders[1]).toHaveAttribute('aria-describedby'); + expect(document.getElementById(columnheaders[1].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); + expect(columnheaders[2]).not.toHaveAttribute('aria-describedby'); + + await user.click(columnheaders[0]); + + expect(onSortChange).toHaveBeenCalledTimes(1); + expect(onSortChange).toHaveBeenCalledWith({column: 'foo', direction: 'descending'}); + }); + + it('should toggle the sort direction from descending to ascending', async function () { + let onSortChange = jest.fn(); + let tree = render( + + + Foo + Bar + Baz + + + + Foo 1 + Bar 1 + Baz 1 + + + + ); + + let table = tree.getByRole('grid'); + let columnheaders = within(table).getAllByRole('columnheader'); + expect(columnheaders).toHaveLength(3); + expect(columnheaders[0]).toHaveAttribute('aria-describedby'); + expect(columnheaders[0]).toHaveAttribute('aria-sort', 'descending'); + expect(columnheaders[1]).toHaveAttribute('aria-sort', 'none'); + expect(columnheaders[2]).not.toHaveAttribute('aria-sort'); + expect(document.getElementById(columnheaders[0].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); + expect(columnheaders[1]).toHaveAttribute('aria-describedby'); + expect(document.getElementById(columnheaders[1].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); + expect(columnheaders[2]).not.toHaveAttribute('aria-describedby'); + + await user.click(columnheaders[0]); + + expect(onSortChange).toHaveBeenCalledTimes(1); + expect(onSortChange).toHaveBeenCalledWith({column: 'foo', direction: 'ascending'}); + }); + + it('should trigger sorting on a different column', async function () { + let onSortChange = jest.fn(); + let tree = render( + + + Foo + Bar + Baz + + + + Foo 1 + Bar 1 + Baz 1 + + + + ); + + let table = tree.getByRole('grid'); + let columnheaders = within(table).getAllByRole('columnheader'); + expect(columnheaders).toHaveLength(3); + expect(columnheaders[0]).toHaveAttribute('aria-sort', 'ascending'); + expect(columnheaders[1]).toHaveAttribute('aria-sort', 'none'); + expect(columnheaders[2]).not.toHaveAttribute('aria-sort'); + expect(columnheaders[0]).toHaveAttribute('aria-describedby'); + expect(document.getElementById(columnheaders[0].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); + expect(columnheaders[1]).toHaveAttribute('aria-describedby'); + expect(document.getElementById(columnheaders[1].getAttribute('aria-describedby'))).toHaveTextContent('sortable column'); + expect(columnheaders[2]).not.toHaveAttribute('aria-describedby'); + + await user.click(columnheaders[1]); + + expect(onSortChange).toHaveBeenCalledTimes(1); + expect(onSortChange).toHaveBeenCalledWith({column: 'bar', direction: 'ascending'}); + }); + }); + + describe('empty state', function () { + it('should display an empty state when there are no items', async function () { + let tree = render( +

No results

}> + + Foo + Bar + + + {[]} + +
+ ); + + await act(() => Promise.resolve()); // wait for MutationObserver in useHasTabbableChild or we get act warnings + + let table = tree.getByRole('grid'); + let rows = within(table).getAllByRole('row'); + expect(rows).toHaveLength(2); + expect(rows[1]).toHaveAttribute('aria-rowindex', '2'); + + let cell = within(rows[1]).getByRole('rowheader'); + expect(cell).toHaveAttribute('aria-colspan', '2'); + + let heading = within(cell).getByRole('heading'); + expect(heading).toBeVisible(); + expect(heading).toHaveTextContent('No results'); + + rerender(tree, defaultTable); + act(() => jest.runAllTimers()); + + rows = within(table).getAllByRole('row'); + expect(rows).toHaveLength(3); + expect(heading).not.toBeInTheDocument(); + }); + + it('empty table select all should be disabled', async function () { + let onSelectionChange = jest.fn(); + let tree = render( +
+

No results

}> + + Foo + Bar + + + {[]} + +
+ +
+ ); + + await act(() => Promise.resolve()); + + let table = tree.getByRole('grid'); + let selectAll = tree.getByRole('checkbox'); + + await user.tab(); + expect(document.activeElement).toBe(table); + await user.tab(); + expect(document.activeElement).not.toBe(selectAll); + expect(selectAll).toHaveAttribute('disabled'); + }); + + it('should allow the user to tab into the table body', async function () { + let tree = render(); + await act(() => Promise.resolve()); + let toggleButton = tree.getAllByRole('button')[0]; + let link = tree.getByRole('link'); + + await user.tab(); + expect(document.activeElement).toBe(toggleButton); + await user.tab(); + expect(document.activeElement).toBe(link); + }); + + it('should disable keyboard navigation within the table', async function () { + let tree = render(); + await act(() => Promise.resolve()); + let table = tree.getByRole('grid'); + let header = within(table).getAllByRole('columnheader')[2]; + expect(header).toHaveAttribute('tabindex', '-1'); + let headerButton = within(header).getByRole('button'); + expect(headerButton).toHaveAttribute('aria-disabled', 'true'); + }); + + it('should shift focus to the table if table becomes empty via column sort', function () { + let tree = render(); + let rows = tree.getAllByRole('row'); + expect(rows).toHaveLength(3); + focusCell(tree, 'Height'); + expect(document.activeElement).toHaveTextContent('Height'); + fireEvent.keyDown(document.activeElement, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + act(() => jest.advanceTimersByTime(500)); + let table = tree.getByRole('grid'); + expect(document.activeElement).toBe(table); + // Run the rest of the timeout and run the transitions + act(() => {jest.runAllTimers();}); + act(() => {jest.runAllTimers();}); + rows = tree.getAllByRole('row'); + expect(rows).toHaveLength(2); + }); + + it('should disable press interactions with the column headers', async function () { + let tree = render(); + await act(() => Promise.resolve()); + let table = tree.getByRole('grid'); + let headers = within(table).getAllByRole('columnheader'); + let toggleButton = tree.getAllByRole('button')[0]; + + await user.tab(); + expect(document.activeElement).toBe(toggleButton); + + let columnButton = within(headers[2]).getByRole('button'); + await user.click(columnButton); + expect(document.activeElement).toBe(toggleButton); + expect(tree.queryByRole('menuitem')).toBeFalsy(); + fireEvent.mouseEnter(headers[2]); + act(() => {jest.runAllTimers();}); + expect(tree.queryByRole('slider')).toBeFalsy(); + }); + + it.skip('should re-enable functionality when the table recieves items', async function () { + let tree = render(); + let table = tree.getByRole('grid'); + let headers = within(table).getAllByRole('columnheader'); + let toggleButton = tree.getAllByRole('button')[0]; + let selectAll = tree.getByRole('checkbox'); + + await user.tab(); + expect(document.activeElement).toBe(toggleButton); + await user.click(toggleButton); + act(() => {jest.runAllTimers();}); + + expect(selectAll).not.toHaveAttribute('disabled'); + await user.click(selectAll); + act(() => {jest.runAllTimers();}); + expect(selectAll.checked).toBeTruthy(); + expect(document.activeElement).toBe(selectAll); + + fireEvent.mouseEnter(headers[2]); + act(() => {jest.runAllTimers();}); + expect(tree.queryAllByRole('slider')).toBeTruthy(); + + let column1Button = within(headers[1]).getByRole('button'); + let column2Button = within(headers[2]).getByRole('button'); + await user.click(column2Button); + act(() => {jest.runAllTimers();}); + expect(tree.queryAllByRole('menuitem')).toBeTruthy(); + await user.keyboard('{Escape}'); + act(() => {jest.runAllTimers();}); + act(() => {jest.runAllTimers();}); + expect(document.activeElement).toBe(column2Button); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(column1Button); + + await user.click(toggleButton); + act(() => {jest.runAllTimers();}); + expect(selectAll).toHaveAttribute('disabled'); + await user.click(headers[2]); + expect(document.activeElement).toBe(toggleButton); + await user.tab(); + expect(document.activeElement).toBe(table); + expect(table).toHaveAttribute('tabIndex', '0'); + }); + }); + + describe('links', function () { + describe.each(['mouse', 'keyboard'])('%s', (type) => { + let trigger = async (item, key = 'Enter') => { + if (type === 'mouse') { + await user.click(item); + } else { + fireEvent.keyDown(item, {key}); + fireEvent.keyUp(item, {key}); + } + }; + + it('should support links with selectionMode="none"', async function () { + let {getAllByRole} = render( + + + + Foo + Bar + Baz + + + + Foo 1 + Bar 1 + Baz 1 + + + Foo 2 + Bar 2 + Baz 2 + + + + + ); + + let items = getAllByRole('row').slice(1); + for (let item of items) { + expect(item.tagName).not.toBe('A'); + expect(item).toHaveAttribute('data-href'); + } + + let onClick = mockClickDefault(); + await trigger(items[0]); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); + expect(onClick.mock.calls[0][0].target.href).toBe('https://google.com/'); + }); + + it.each(['single', 'multiple'])('should support links with selectionStyle="checkbox" selectionMode="%s"', async function (selectionMode) { + let {getAllByRole} = render( + + + + Foo + Bar + Baz + + + + Foo 1 + Bar 1 + Baz 1 + + + Foo 2 + Bar 2 + Baz 2 + + + + + ); + + let items = getAllByRole('row').slice(1); + for (let item of items) { + expect(item.tagName).not.toBe('A'); + expect(item).toHaveAttribute('data-href'); + } + + let onClick = mockClickDefault(); + await trigger(items[0]); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); + expect(onClick.mock.calls[0][0].target.href).toBe('https://google.com/'); + + await user.click(within(items[0]).getByRole('checkbox')); + expect(items[0]).toHaveAttribute('aria-selected', 'true'); + + await trigger(items[1], ' '); + expect(onClick).toHaveBeenCalledTimes(1); + expect(items[1]).toHaveAttribute('aria-selected', 'true'); + document.removeEventListener('click', onClick); + }); + + it.each(['single', 'multiple'])('should support links with selectionStyle="highlight" selectionMode="%s"', async function (selectionMode) { + let {getAllByRole} = render( + + + + Foo + Bar + Baz + + + + Foo 1 + Bar 1 + Baz 1 + + + Foo 2 + Bar 2 + Baz 2 + + + + + ); + + let items = getAllByRole('row').slice(1); + for (let item of items) { + expect(item.tagName).not.toBe('A'); + expect(item).toHaveAttribute('data-href'); + } + + let onClick = mockClickDefault(); + await trigger(items[0], ' '); + expect(onClick).not.toHaveBeenCalled(); + expect(items[0]).toHaveAttribute('aria-selected', 'true'); + document.removeEventListener('click', onClick); + + if (type === 'mouse') { + await user.dblClick(items[0], {pointerType: 'mouse'}); + } else { + fireEvent.keyDown(items[0], {key: 'Enter'}); + fireEvent.keyUp(items[0], {key: 'Enter'}); + } + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); + expect(onClick.mock.calls[0][0].target.href).toBe('https://google.com/'); + }); + + it('should work with RouterProvider', async () => { + let navigate = jest.fn(); + let {getAllByRole} = render( + + + + Foo + Bar + Baz + + + + Foo 1 + Bar 1 + Baz 1 + + + Foo 2 + Bar 2 + Baz 2 + + + + + ); + + let items = getAllByRole('row').slice(1); + await trigger(items[0]); + expect(navigate).toHaveBeenCalledWith('/one', {foo: 'bar'}); + + navigate.mockReset(); + let onClick = mockClickDefault(); + + await trigger(items[1]); + expect(navigate).not.toHaveBeenCalled(); + expect(onClick).toHaveBeenCalledTimes(1); + }); + }); + }); +}; diff --git a/packages/@react-spectrum/table/test/TestTableUtils.test.js b/packages/@react-spectrum/table/test/TestTableUtils.test.js index 1cd7ad410e0..2e55e6fa9fa 100644 --- a/packages/@react-spectrum/table/test/TestTableUtils.test.js +++ b/packages/@react-spectrum/table/test/TestTableUtils.test.js @@ -28,6 +28,10 @@ let columns = [ {name: 'Baz', key: 'baz'} ]; +// getComputedStyle is very slow in our version of jsdom. +// These tests only care about direct inline styles. We can avoid parsing other stylesheets. +window.getComputedStyle = (el) => el.style; + describe('Table ', function () { let onSelectionChange = jest.fn(); let onSortChange = jest.fn(); diff --git a/packages/@react-spectrum/table/test/TreeGridTable.test.tsx b/packages/@react-spectrum/table/test/TreeGridTable.test.tsx index c148b947b67..acd352fe896 100644 --- a/packages/@react-spectrum/table/test/TreeGridTable.test.tsx +++ b/packages/@react-spectrum/table/test/TreeGridTable.test.tsx @@ -81,6 +81,9 @@ let rerender = (tree, children, scale = 'medium' as Scale) => { return newTree; }; +// getComputedStyle is very slow in our version of jsdom. +// These tests only care about direct inline styles. We can avoid parsing other stylesheets. +window.getComputedStyle = (el) => (el as HTMLElement).style; describe('TableView with expandable rows', function () { let user; @@ -747,7 +750,7 @@ describe('TableView with expandable rows', function () { moveFocus('End'); rows = treegrid.getAllByRole('row'); expect(document.activeElement).toBe(rows.at(-1)); - expect(document.activeElement).toHaveTextContent('Row 19, Lvl 3, Foo'); + expect(document.activeElement).toHaveTextContent('Row 5, Lvl 3, Foo'); }); }); @@ -765,7 +768,7 @@ describe('TableView with expandable rows', function () { describe('PageDown', function () { it('should focus a nested row a page below', function () { - let treegrid = render(); + let treegrid = render(); let rows = treegrid.getAllByRole('row'); act(() => {rows[2].focus();}); moveFocus('PageDown'); @@ -779,7 +782,7 @@ describe('TableView with expandable rows', function () { describe('PageUp', function () { it('should focus a nested row a page above', function () { - let treegrid = render(); + let treegrid = render(); let rows = treegrid.getAllByRole('row'); act(() => {rows[1].focus();}); moveFocus('End'); @@ -811,12 +814,12 @@ describe('TableView with expandable rows', function () { let treegrid = render(); let body = (treegrid.getByRole('treegrid').childNodes[1] as HTMLElement); - focusCell(treegrid, 'Row 9, Lvl 1, Foo'); + focusCell(treegrid, 'Row 4, Lvl 1, Foo'); expect(scrollIntoView).toHaveBeenLastCalledWith(body, document.activeElement); }); it('should scroll to a nested row cell when it is focused off screen', function () { - let treegrid = render(); + let treegrid = render(); let body = (treegrid.getByRole('treegrid').childNodes[1] as HTMLElement); let cell = getCell(treegrid, 'Row 1, Lvl 3, Foo'); act(() => cell.focus()); @@ -866,7 +869,7 @@ describe('TableView with expandable rows', function () { }; let checkSelectAll = (tree, state = 'indeterminate') => { - let checkbox = tree.getByLabelText('Select All'); + let checkbox = tree.getByTestId('selectAll'); if (state === 'indeterminate') { expect(checkbox.indeterminate).toBe(true); } else { @@ -890,7 +893,7 @@ describe('TableView with expandable rows', function () { describe('row selection', function () { describe('with pointer', function () { it('should select a row when clicking on the chevron cell', async function () { - let treegrid = render(); + let treegrid = render(); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); let chevronCell = getCell(treegrid, 'Row 1, Lvl 1, Foo'); @@ -911,7 +914,7 @@ describe('TableView with expandable rows', function () { }); it('should select a nested row on click', async function () { - let treegrid = render(); + let treegrid = render(); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); let cell = getCell(treegrid, 'Row 1, Lvl 3, Foo'); @@ -927,7 +930,7 @@ describe('TableView with expandable rows', function () { describe('with keyboard', function () { it('should select a nested row by pressing the Enter key on a row', function () { - let treegrid = render(); + let treegrid = render(); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); @@ -941,7 +944,7 @@ describe('TableView with expandable rows', function () { }); it('should select a nested row by pressing the Space key on a row', function () { - let treegrid = render(); + let treegrid = render(); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); @@ -955,7 +958,7 @@ describe('TableView with expandable rows', function () { }); it('should select a row by pressing the Enter key on a chevron cell', async function () { - let treegrid = render(); + let treegrid = render(); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); let cell = within(rows[0]).getByRole('checkbox'); @@ -975,7 +978,7 @@ describe('TableView with expandable rows', function () { }); it('should select a row by pressing the Space key on a chevron cell', async function () { - let treegrid = render(); + let treegrid = render(); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); let cell = within(rows[0]).getByRole('checkbox'); @@ -997,7 +1000,7 @@ describe('TableView with expandable rows', function () { it('should select nested rows if select all checkbox is pressed', async function () { let treegrid = render(); - let checkbox = treegrid.getByLabelText('Select All'); + let checkbox = treegrid.getByTestId('selectAll'); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); await user.click(checkbox); @@ -1006,7 +1009,7 @@ describe('TableView with expandable rows', function () { }); it('should not allow selection of disabled nested rows', async function () { - let treegrid = render(); + let treegrid = render(); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); let cell = getCell(treegrid, 'Row 1, Lvl 2, Foo'); @@ -1015,7 +1018,7 @@ describe('TableView with expandable rows', function () { expect(onSelectionChange).not.toHaveBeenCalled(); checkRowSelection(rows, false); - let checkbox = treegrid.getByLabelText('Select All'); + let checkbox = treegrid.getByTestId('selectAll'); await user.click(checkbox); expect(onSelectionChange).toHaveBeenCalledTimes(1); expect(new Set(onSelectionChange.mock.calls[0][0]).has('Row 1 Lvl 2')).toBeFalsy(); @@ -1026,7 +1029,7 @@ describe('TableView with expandable rows', function () { describe('range selection', function () { describe('with pointer', function () { it('should support selecting a range from a top level row to a nested row', async function () { - let treegrid = render(); + let treegrid = render(); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); @@ -1043,7 +1046,7 @@ describe('TableView with expandable rows', function () { }); it('should support selecting a range from a nested row to a top level row', async function () { - let treegrid = render(); + let treegrid = render(); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); @@ -1060,7 +1063,7 @@ describe('TableView with expandable rows', function () { }); it('should support selecting a range from a top level row to a descendent child row', async function () { - let treegrid = render(); + let treegrid = render(); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); @@ -1077,7 +1080,7 @@ describe('TableView with expandable rows', function () { }); it('should support selecting a range from a nested child row to its top level row ancestor', async function () { - let treegrid = render(); + let treegrid = render(); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); @@ -1094,7 +1097,7 @@ describe('TableView with expandable rows', function () { }); it('should not include disabled rows', async function () { - let treegrid = render(); + let treegrid = render(); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); @@ -1114,7 +1117,7 @@ describe('TableView with expandable rows', function () { describe('with keyboard', function () { it('should extend a selection with Shift + ArrowDown through nested keys', async function () { - let treegrid = render(); + let treegrid = render(); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); let cell = within(rows[0]).getByRole('checkbox'); @@ -1153,7 +1156,7 @@ describe('TableView with expandable rows', function () { }); it('should extend a selection with Shift + ArrowUp through nested keys', async function () { - let treegrid = render(); + let treegrid = render(); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); let cell = within(rows[3]).getByRole('checkbox'); @@ -1195,7 +1198,7 @@ describe('TableView with expandable rows', function () { }); it('should extend a selection with Ctrl + Shift + Home', async function () { - let treegrid = render(); + let treegrid = render(); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); let cell = within(rows[6]).getByRole('checkbox'); @@ -1254,7 +1257,7 @@ describe('TableView with expandable rows', function () { }); it('should extend a selection with Shift + PageDown', async function () { - let treegrid = render(); + let treegrid = render(); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); let cell = within(rows[6]).getByRole('checkbox'); @@ -1316,7 +1319,7 @@ describe('TableView with expandable rows', function () { }); it('should not include disabled rows', async function () { - let treegrid = render(); + let treegrid = render(); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); let cell = within(rows[0]).getByRole('checkbox'); @@ -1382,7 +1385,7 @@ describe('TableView with expandable rows', function () { }); it('should trigger onAction when pressing Enter', async function () { - let treegrid = render(); + let treegrid = render(); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); let cell = within(rows[2]).getByRole('checkbox'); @@ -1414,7 +1417,7 @@ describe('TableView with expandable rows', function () { installPointerEvent(); it('should toggle selection with mouse', async function () { - let treegrid = render(); + let treegrid = render(); expect(treegrid.queryByLabelText('Select All')).toBeNull(); let rowgroups = treegrid.getAllByRole('rowgroup'); @@ -1441,7 +1444,7 @@ describe('TableView with expandable rows', function () { }); it('should toggle selection with touch', async function () { - let treegrid = render(); + let treegrid = render(); expect(treegrid.queryByLabelText('Select All')).toBeNull(); let rowgroups = treegrid.getAllByRole('rowgroup'); @@ -1467,7 +1470,7 @@ describe('TableView with expandable rows', function () { }); it('should support long press to enter selection mode on touch', async function () { - let treegrid = render(); + let treegrid = render(); await user.click(document.body); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); @@ -1504,7 +1507,7 @@ describe('TableView with expandable rows', function () { }); it('should support double click to perform onAction with mouse', async function () { - let treegrid = render(); + let treegrid = render(); expect(treegrid.queryByLabelText('Select All')).toBeNull(); let rowgroups = treegrid.getAllByRole('rowgroup'); @@ -1528,7 +1531,7 @@ describe('TableView with expandable rows', function () { }); it('should support single tap to perform onAction with touch', function () { - let treegrid = render(); + let treegrid = render(); expect(treegrid.queryByLabelText('Select All')).toBeNull(); let cell = getCell(treegrid, 'Row 1, Lvl 3, Foo'); @@ -1568,7 +1571,7 @@ describe('TableView with expandable rows', function () { act(() => jest.runAllTimers()); rowgroups = treegrid.getAllByRole('rowgroup'); rows = within(rowgroups[1]).getAllByRole('row'); - expect(rows).toHaveLength(19); + expect(rows).toHaveLength(5); expect(heading).not.toBeInTheDocument(); }); }); @@ -1598,13 +1601,13 @@ describe('TableView with expandable rows', function () { act(() => jest.runAllTimers()); rowgroups = treegrid.getAllByRole('rowgroup'); rows = within(rowgroups[1]).getAllByRole('row'); - expect(rows).toHaveLength(20); + expect(rows).toHaveLength(6); - row = rows[19]; + row = rows[5]; expect(row).not.toHaveAttribute('aria-expanded'); expect(row).toHaveAttribute('aria-level', '1'); - expect(row).toHaveAttribute('aria-posinset', '20'); - expect(row).toHaveAttribute('aria-setsize', '20'); + expect(row).toHaveAttribute('aria-posinset', '6'); + expect(row).toHaveAttribute('aria-setsize', '6'); spinner = within(row).getByRole('progressbar'); expect(spinner).toBeTruthy(); }); From fd7b5dd87fcdfda5bf5879da82c2fc3d3ab8029c Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Wed, 9 Apr 2025 03:52:36 +1000 Subject: [PATCH 11/25] chore: Update typescript to 5.8 (#7888) * chore: update typescript to 5.8 * fix all the types for the upgrade * fix numberfield styles --- .../@types-node-npm-20.14.13-41f92d384c.patch | 13 +++++++ lib/viewTransitions.d.ts | 19 ---------- package.json | 7 ++-- .../dnd/src/useDroppableCollection.ts | 29 +++++++++------ .../grid/src/useGridSelectionAnnouncement.ts | 19 ++++++---- .../calendar/stories/Calendar.stories.tsx | 2 +- .../datepicker/stories/DateField.stories.tsx | 4 +-- .../datepicker/stories/DatePicker.stories.tsx | 2 +- .../stories/DateRangePicker.stories.tsx | 2 +- .../form/stories/Form.stories.tsx | 2 +- .../@react-spectrum/s2/src/Breadcrumbs.tsx | 12 ++++--- .../@react-spectrum/s2/src/CloseButton.tsx | 2 +- packages/@react-spectrum/s2/src/ComboBox.tsx | 3 +- packages/@react-spectrum/s2/src/Menu.tsx | 7 ++-- .../@react-spectrum/s2/src/NumberField.tsx | 20 +++-------- .../s2/src/SegmentedControl.tsx | 23 ++++++------ packages/@react-spectrum/s2/src/Slider.tsx | 7 ++-- packages/@react-spectrum/s2/src/TableView.tsx | 9 +++-- packages/@react-spectrum/s2/src/Tabs.tsx | 14 +++++--- .../@react-spectrum/s2/src/TabsPicker.tsx | 4 +-- packages/@react-spectrum/s2/src/TagGroup.tsx | 3 +- .../s2/style/spectrum-theme.ts | 3 +- .../steplist/stories/StepList.stories.tsx | 4 +-- packages/@react-spectrum/tabs/src/Tabs.tsx | 11 ++++-- .../tabs/stories/Tabs.stories.tsx | 9 +++-- .../disclosure/src/useDisclosureGroupState.ts | 11 +++--- .../tabs/src/useTabListState.ts | 5 +++ .../@react-types/shared/src/selection.d.ts | 2 +- packages/@react-types/tabs/src/index.d.ts | 12 ++++--- packages/dev/codemods/package.json | 4 +-- .../dev/docs/pages/react-aria/home/A11y.tsx | 2 +- .../react-aria-components/docs/Checkbox.mdx | 2 +- .../react-aria-components/docs/GridList.mdx | 2 +- packages/react-aria-components/docs/Menu.mdx | 2 +- packages/react-aria-components/docs/Table.mdx | 2 +- .../react-aria-components/docs/TagGroup.mdx | 2 +- packages/react-aria-components/docs/Tree.mdx | 2 +- scripts/extractExamples.mjs | 1 - tsconfig.json | 1 - yarn.lock | 36 ++++++++++++++----- 40 files changed, 184 insertions(+), 132 deletions(-) create mode 100644 .yarn/patches/@types-node-npm-20.14.13-41f92d384c.patch delete mode 100644 lib/viewTransitions.d.ts diff --git a/.yarn/patches/@types-node-npm-20.14.13-41f92d384c.patch b/.yarn/patches/@types-node-npm-20.14.13-41f92d384c.patch new file mode 100644 index 00000000000..7afc48f1b36 --- /dev/null +++ b/.yarn/patches/@types-node-npm-20.14.13-41f92d384c.patch @@ -0,0 +1,13 @@ +diff --git a/buffer.d.ts b/buffer.d.ts +index 5d6c97d6b5d47fd189f795498aefd6b8d7713b7d..b9a22c4634fa6308006ae17d3527ff3c518a789d 100644 +--- a/buffer.d.ts ++++ b/buffer.d.ts +@@ -629,7 +629,7 @@ declare module "buffer" { + */ + poolSize: number; + } +- interface Buffer extends Uint8Array { ++ interface Buffer extends Uint8Array { + /** + * Writes `string` to `buf` at `offset` according to the character encoding in`encoding`. The `length` parameter is the number of bytes to write. If `buf` did + * not contain enough space to fit the entire string, only part of `string` will be diff --git a/lib/viewTransitions.d.ts b/lib/viewTransitions.d.ts deleted file mode 100644 index db01d159b6a..00000000000 --- a/lib/viewTransitions.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright 2025 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -interface Document { - startViewTransition(fn: () => void): ViewTransition; -} - -interface ViewTransition { - ready: Promise; -} diff --git a/package.json b/package.json index c93c72dbd67..92490328d4e 100644 --- a/package.json +++ b/package.json @@ -206,7 +206,7 @@ "tailwindcss": "^4.0.0", "tailwindcss-animate": "^1.0.7", "tempy": "^0.5.0", - "typescript": "^5.5.0", + "typescript": "^5.8.2", "typescript-eslint": "^8.9.0", "verdaccio": "^6.0.0", "walk-object": "^4.0.0", @@ -234,7 +234,10 @@ "recast": "0.23.6", "ast-types": "0.16.1", "svgo": "^3", - "@testing-library/user-event": "patch:@testing-library/user-event@npm%3A14.6.1#~/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch" + "@testing-library/user-event": "patch:@testing-library/user-event@npm%3A14.6.1#~/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch", + "@types/node@npm:*": "patch:@types/node@npm%3A20.14.13#~/.yarn/patches/@types-node-npm-20.14.13-41f92d384c.patch", + "@types/node@npm:^18.0.0": "patch:@types/node@npm%3A20.14.13#~/.yarn/patches/@types-node-npm-20.14.13-41f92d384c.patch", + "@types/node@npm:>= 8": "patch:@types/node@npm%3A20.14.13#~/.yarn/patches/@types-node-npm-20.14.13-41f92d384c.patch" }, "@parcel/transformer-css": { "cssModules": { diff --git a/packages/@react-aria/dnd/src/useDroppableCollection.ts b/packages/@react-aria/dnd/src/useDroppableCollection.ts index 420d924c7c5..4145081776b 100644 --- a/packages/@react-aria/dnd/src/useDroppableCollection.ts +++ b/packages/@react-aria/dnd/src/useDroppableCollection.ts @@ -258,18 +258,25 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: // inserted item. If selection is disabled, then also show the focus ring so there // is some indication that items were added. if (state.selectionManager.focusedKey === prevFocusedKey) { - let first = newKeys.keys().next().value; - let item = state.collection.getItem(first); - - // If this is a cell, focus the parent row. - if (item?.type === 'cell') { - first = item.parentKey; - } + let first: Key | null | undefined = newKeys.keys().next().value; + if (first != null) { + let item = state.collection.getItem(first); + + // If this is a cell, focus the parent row. + // eslint-disable-next-line max-depth + if (item?.type === 'cell') { + first = item.parentKey; + } - state.selectionManager.setFocusedKey(first); + // eslint-disable-next-line max-depth + if (first != null) { + state.selectionManager.setFocusedKey(first); + } - if (state.selectionManager.selectionMode === 'none') { - setInteractionModality('keyboard'); + // eslint-disable-next-line max-depth + if (state.selectionManager.selectionMode === 'none') { + setInteractionModality('keyboard'); + } } } } else if ( @@ -335,7 +342,7 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: }, 50); }, [localState, defaultOnDrop, ref, updateFocusAfterDrop]); - + useEffect(() => { return () => { if (droppingState.current) { diff --git a/packages/@react-aria/grid/src/useGridSelectionAnnouncement.ts b/packages/@react-aria/grid/src/useGridSelectionAnnouncement.ts index 122aa695a6b..c3d84564c32 100644 --- a/packages/@react-aria/grid/src/useGridSelectionAnnouncement.ts +++ b/packages/@react-aria/grid/src/useGridSelectionAnnouncement.ts @@ -61,20 +61,25 @@ export function useGridSelectionAnnouncement(props: GridSelectionAnnouncement let messages: string[] = []; if ((state.selectionManager.selectedKeys.size === 1 && isReplace)) { - if (state.collection.getItem(state.selectionManager.selectedKeys.keys().next().value)) { - let currentSelectionText = getRowText(state.selectionManager.selectedKeys.keys().next().value); + let firstKey = state.selectionManager.selectedKeys.keys().next().value; + if (firstKey != null && state.collection.getItem(firstKey)) { + let currentSelectionText = getRowText(firstKey); if (currentSelectionText) { messages.push(stringFormatter.format('selectedItem', {item: currentSelectionText})); } } } else if (addedKeys.size === 1 && removedKeys.size === 0) { - let addedText = getRowText(addedKeys.keys().next().value); - if (addedText) { - messages.push(stringFormatter.format('selectedItem', {item: addedText})); + let firstKey = addedKeys.keys().next().value; + if (firstKey != null) { + let addedText = getRowText(firstKey); + if (addedText) { + messages.push(stringFormatter.format('selectedItem', {item: addedText})); + } } } else if (removedKeys.size === 1 && addedKeys.size === 0) { - if (state.collection.getItem(removedKeys.keys().next().value)) { - let removedText = getRowText(removedKeys.keys().next().value); + let firstKey = removedKeys.keys().next().value; + if (firstKey != null && state.collection.getItem(firstKey)) { + let removedText = getRowText(firstKey); if (removedText) { messages.push(stringFormatter.format('deselectedItem', {item: removedText})); } diff --git a/packages/@react-spectrum/calendar/stories/Calendar.stories.tsx b/packages/@react-spectrum/calendar/stories/Calendar.stories.tsx index 5da987a336d..d12df3d13e3 100644 --- a/packages/@react-spectrum/calendar/stories/Calendar.stories.tsx +++ b/packages/@react-spectrum/calendar/stories/Calendar.stories.tsx @@ -209,7 +209,7 @@ const calendars = [ function Example(props) { let [locale, setLocale] = React.useState(''); - let [calendar, setCalendar] = React.useState(calendars[0].key); + let [calendar, setCalendar] = React.useState(calendars[0].key); let {locale: defaultLocale} = useLocale(); let pref = preferences.find(p => p.locale === locale)!; diff --git a/packages/@react-spectrum/datepicker/stories/DateField.stories.tsx b/packages/@react-spectrum/datepicker/stories/DateField.stories.tsx index 682e5484224..3a495dd5ba6 100644 --- a/packages/@react-spectrum/datepicker/stories/DateField.stories.tsx +++ b/packages/@react-spectrum/datepicker/stories/DateField.stories.tsx @@ -212,7 +212,7 @@ export const IsDateUnavailable: DateFieldStory = { ...Default, args: { isDateUnavailable: (date) => { - return date.compare(new CalendarDate(1980, 1, 1)) >= 0 + return date.compare(new CalendarDate(1980, 1, 1)) >= 0 && date.compare(new CalendarDate(1980, 1, 8)) <= 0; }, errorMessage: 'Date unavailable.', @@ -310,7 +310,7 @@ const calendars = [ function Example(props) { let [locale, setLocale] = React.useState(''); - let [calendar, setCalendar] = React.useState(calendars[0].key); + let [calendar, setCalendar] = React.useState(calendars[0].key); let {locale: defaultLocale} = useLocale(); let pref = preferences.find(p => p.locale === locale); diff --git a/packages/@react-spectrum/datepicker/stories/DatePicker.stories.tsx b/packages/@react-spectrum/datepicker/stories/DatePicker.stories.tsx index e51901897ee..f53d6ae8a6f 100644 --- a/packages/@react-spectrum/datepicker/stories/DatePicker.stories.tsx +++ b/packages/@react-spectrum/datepicker/stories/DatePicker.stories.tsx @@ -338,7 +338,7 @@ const calendars = [ function Example(props) { let [locale, setLocale] = React.useState(''); - let [calendar, setCalendar] = React.useState(calendars[0].key); + let [calendar, setCalendar] = React.useState(calendars[0].key); let {locale: defaultLocale} = useLocale(); let pref = preferences.find(p => p.locale === locale); diff --git a/packages/@react-spectrum/datepicker/stories/DateRangePicker.stories.tsx b/packages/@react-spectrum/datepicker/stories/DateRangePicker.stories.tsx index 5ac199a8194..b71709b0f80 100644 --- a/packages/@react-spectrum/datepicker/stories/DateRangePicker.stories.tsx +++ b/packages/@react-spectrum/datepicker/stories/DateRangePicker.stories.tsx @@ -238,7 +238,7 @@ const calendars = [ function Example(props) { let [locale, setLocale] = React.useState(''); - let [calendar, setCalendar] = React.useState(calendars[0].key); + let [calendar, setCalendar] = React.useState(calendars[0].key); let {locale: defaultLocale} = useLocale(); let pref = preferences.find(p => p.locale === locale); diff --git a/packages/@react-spectrum/form/stories/Form.stories.tsx b/packages/@react-spectrum/form/stories/Form.stories.tsx index 131f5592b4f..e154e5efad7 100644 --- a/packages/@react-spectrum/form/stories/Form.stories.tsx +++ b/packages/@react-spectrum/form/stories/Form.stories.tsx @@ -482,7 +482,7 @@ function FormWithControls(props: any = {}) { let [firstName, setFirstName] = useState('hello'); let [isHunter, setIsHunter] = useState(true); let [favoritePet, setFavoritePet] = useState('cats'); - let [favoriteColor, setFavoriteColor] = useState('green' as Key); + let [favoriteColor, setFavoriteColor] = useState('green'); let [howIFeel, setHowIFeel] = useState('I feel good, o I feel so good!'); let [birthday, setBirthday] = useState(new CalendarDate(1732, 2, 22)); let [money, setMoney] = useState(50); diff --git a/packages/@react-spectrum/s2/src/Breadcrumbs.tsx b/packages/@react-spectrum/s2/src/Breadcrumbs.tsx index be0f1cdacf3..a8f696b0bf9 100644 --- a/packages/@react-spectrum/s2/src/Breadcrumbs.tsx +++ b/packages/@react-spectrum/s2/src/Breadcrumbs.tsx @@ -20,6 +20,7 @@ import { DefaultCollectionRenderer, HeadingContext, Link, + LinkRenderProps, Provider, Breadcrumbs as RACBreadcrumbs } from 'react-aria-components'; @@ -97,7 +98,7 @@ const wrapper = style({ const InternalBreadcrumbsContext = createContext>>({}); -/** Breadcrumbs show hierarchy and navigational context for a user’s location within an application. */ +/** Breadcrumbs show hierarchy and navigational context for a user's location within an application. */ export const Breadcrumbs = /*#__PURE__*/ (forwardRef as forwardRefType)(function Breadcrumbs(props: BreadcrumbsProps, ref: DOMRef) { [props, ref] = useSpectrumContextProps(props, ref, BreadcrumbsContext); let domRef = useDOMRef(ref); @@ -200,7 +201,7 @@ let HiddenBreadcrumbs = function (props: {listRef: RefObject({ display: 'flex', alignItems: 'center', justifyContent: 'start', @@ -245,7 +246,7 @@ const chevronStyles = style({ } }); -const linkStyles = style({ +const linkStyles = style({ ...focusRing(), borderRadius: 'sm', font: 'control', @@ -255,7 +256,8 @@ const linkStyles = style({ isCurrent: 'neutral', forcedColors: { default: 'LinkText', - isDisabled: 'GrayText' + isDisabled: 'GrayText', + isCurrent: 'GrayText' } }, transition: 'default', @@ -337,7 +339,7 @@ export const Breadcrumb = /*#__PURE__*/ (forwardRef as forwardRefType)(function ping={ping} referrerPolicy={referrerPolicy} isDisabled={isDisabled || isCurrent} - className={({isFocused, isFocusVisible, isHovered, isDisabled, isPressed}) => linkStyles({isFocused, isFocusVisible, isHovered, isDisabled, size, isPressed})}> + className={({isFocused, isFocusVisible, isHovered, isDisabled, isPressed}) => linkStyles({isFocused, isFocusVisible, isHovered, isDisabled, size, isPressed, isCurrent})}> {children} ({ ...focusRing(), ...staticColor(), display: 'flex', diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index 1a755084b22..99439d91e38 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -16,6 +16,7 @@ import { ListBoxSection as AriaListBoxSection, PopoverProps as AriaPopoverProps, Button, + ButtonRenderProps, ContextValue, InputContext, ListBox, @@ -95,7 +96,7 @@ export interface ComboBoxProps extends export const ComboBoxContext = createContext>, TextFieldRef>>(null); -const inputButton = style({ +const inputButton = style({ display: 'flex', outlineStyle: 'none', textAlign: 'center', diff --git a/packages/@react-spectrum/s2/src/Menu.tsx b/packages/@react-spectrum/s2/src/Menu.tsx index 8872ce1406c..9d8bc4b9f4d 100644 --- a/packages/@react-spectrum/s2/src/Menu.tsx +++ b/packages/@react-spectrum/s2/src/Menu.tsx @@ -23,6 +23,7 @@ import { SubmenuTriggerProps as AriaSubmenuTriggerProps, ContextValue, DEFAULT_SLOT, + MenuItemRenderProps, Provider, Separator, SeparatorProps @@ -146,7 +147,7 @@ export let sectionHeading = style({ margin: 0 }); -export let menuitem = style({ +export let menuitem = style & {isFocused: boolean, size: 'S' | 'M' | 'L' | 'XL', isLink?: boolean, hasSubmenu?: boolean, isOpen?: boolean}>({ ...focusRing(), boxSizing: 'border-box', borderRadius: 'control', @@ -293,7 +294,7 @@ let value = style({ marginStart: 8 }); -let keyboard = style({ +let keyboard = style<{size: 'S' | 'M' | 'L' | 'XL', isDisabled: boolean}>({ gridArea: 'keyboard', marginStart: 8, font: 'ui', @@ -305,7 +306,7 @@ let keyboard = style({ isDisabled: 'GrayText' } }, - background: 'gray-25', + backgroundColor: 'gray-25', unicodeBidi: 'plaintext' }); diff --git a/packages/@react-spectrum/s2/src/NumberField.tsx b/packages/@react-spectrum/s2/src/NumberField.tsx index a18f70ac951..970a477b92b 100644 --- a/packages/@react-spectrum/s2/src/NumberField.tsx +++ b/packages/@react-spectrum/s2/src/NumberField.tsx @@ -16,6 +16,7 @@ import { NumberField as AriaNumberField, NumberFieldProps as AriaNumberFieldProps, ButtonContext, + ButtonRenderProps, ContextValue, InputContext, Text, @@ -56,7 +57,7 @@ export interface NumberFieldProps extends export const NumberFieldContext = createContext, TextFieldRef>>(null); -const inputButton = style({ +const inputButton = style({ display: 'flex', outlineStyle: 'none', textAlign: 'center', @@ -69,9 +70,6 @@ const inputButton = style({ L: '[5px]', XL: '[6px]' } - }, - type: { - decrementStep: 'none' } }, borderBottomRadius: { @@ -82,9 +80,6 @@ const inputButton = style({ L: '[5px]', XL: '[6px]' } - }, - type: { - incrementStep: 'none' } }, alignItems: 'center', @@ -132,13 +127,6 @@ const inputButton = style({ const iconStyles = style({ flexShrink: 0, - rotate: { - default: 0, - type: { - incrementStep: 270, - decrementStep: 90 - } - }, '--iconPrimary': { type: 'fill', value: 'currentColor' @@ -261,7 +249,7 @@ export const NumberField = forwardRef(function NumberField(props: NumberFieldPro type: 'decrement', size })}> - + - +
} diff --git a/packages/@react-spectrum/s2/src/SegmentedControl.tsx b/packages/@react-spectrum/s2/src/SegmentedControl.tsx index f1828b7b92e..d412b098404 100644 --- a/packages/@react-spectrum/s2/src/SegmentedControl.tsx +++ b/packages/@react-spectrum/s2/src/SegmentedControl.tsx @@ -12,7 +12,7 @@ import {AriaLabelingProps, DOMRef, DOMRefValue, FocusableRef, Key} from '@react-types/shared'; import {centerBaseline} from './CenterBaseline'; -import {ContextValue, DEFAULT_SLOT, Provider, TextContext as RACTextContext, SlotProps, ToggleButton, ToggleButtonGroup, ToggleGroupStateContext} from 'react-aria-components'; +import {ContextValue, DEFAULT_SLOT, Provider, TextContext as RACTextContext, SlotProps, ToggleButton, ToggleButtonGroup, ToggleButtonRenderProps, ToggleGroupStateContext} from 'react-aria-components'; import {createContext, forwardRef, ReactNode, RefObject, useCallback, useContext, useRef} from 'react'; import {focusRing, space, style} from '../style' with {type: 'macro'}; import {getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; @@ -62,7 +62,7 @@ const segmentedControl = style({ width: 'fit' }, getAllowedOverrides()); -const controlItem = style({ +const controlItem = style({ ...focusRing(), position: 'relative', display: 'flex', @@ -107,7 +107,7 @@ const controlItem = style({ } }, getAllowedOverrides()); -const slider = style({ +const slider = style<{isDisabled: boolean}>({ backgroundColor: { default: 'gray-25', forcedColors: { @@ -170,14 +170,17 @@ export const SegmentedControl = /*#__PURE__*/ forwardRef(function SegmentedContr if (currentSelectedRef.current) { prevRef.current = currentSelectedRef?.current.getBoundingClientRect(); } - + if (onSelectionChange) { - onSelectionChange(values.values().next().value); + let firstKey = values.values().next().value; + if (firstKey != null) { + onSelectionChange(firstKey); + } } }; return ( - + ]}> {props.children} ); @@ -255,15 +258,15 @@ export const SegmentedControlItem = /*#__PURE__*/ forwardRef(function SegmentedC }, [isSelected, reduceMotion]); return ( - (props.UNSAFE_className || '') + controlItem({...renderProps, isJustified}, props.styles)} > {({isSelected, isPressed, isDisabled}) => ( <> {isSelected &&
} - ({ ...focusRing(), display: 'inline-block', boxSizing: 'border-box', @@ -271,7 +272,7 @@ const trackStyling = { } } as const; -export let upperTrack = style({ +export let upperTrack = style<{isDisabled?: boolean, trackStyle: 'thin' | 'thick'}>({ ...trackStyling, position: 'absolute', backgroundColor: { @@ -292,7 +293,7 @@ export let upperTrack = style({ } }); -export let filledTrack = style({ +export let filledTrack = style<{isDisabled?: boolean, isEmphasized?: boolean, trackStyle: 'thin' | 'thick'}>({ ...trackStyling, position: 'absolute', backgroundColor: { diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 16db0821306..5ed61e71fad 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -642,14 +642,13 @@ const resizerHandleContainer = style({ } }); -const resizerHandle = style({ +const resizerHandle = style<{isFocusVisible: boolean, isResizing: boolean}>({ backgroundColor: { default: 'gray-300', isFocusVisible: lightDark('informative-900', 'informative-700'), // --spectrum-informative-background-color-default, can't use `informative` because that will use the focusVisible version isResizing: lightDark('informative-900', 'informative-700'), forcedColors: { default: 'Background', - isHovered: 'ButtonBorder', isFocusVisible: 'Highlight', isResizing: 'Highlight' } @@ -787,7 +786,7 @@ function ResizableColumnContents(props: ResizableColumnContentProps) { resizerHandleContainer({resizableDirection, isResizing, isInResizeMode})}> {({isFocusVisible, isResizing}) => ( <> - + {(isFocusVisible || isInResizeMode) && isResizing &&
} )} @@ -797,9 +796,9 @@ function ResizableColumnContents(props: ResizableColumnContentProps) { ); } -function ResizerIndicator({isFocusVisible, isResizing, isInResizeMode}) { +function ResizerIndicator({isFocusVisible, isResizing}) { return ( -
+
); } diff --git a/packages/@react-spectrum/s2/src/Tabs.tsx b/packages/@react-spectrum/s2/src/Tabs.tsx index 414b7316429..81b2a38b3a1 100644 --- a/packages/@react-spectrum/s2/src/Tabs.tsx +++ b/packages/@react-spectrum/s2/src/Tabs.tsx @@ -22,7 +22,8 @@ import { Tab as RACTab, TabList as RACTabList, Tabs as RACTabs, - TabListStateContext + TabListStateContext, + TabRenderProps } from 'react-aria-components'; import {centerBaseline} from './CenterBaseline'; import {Collection, DOMRef, DOMRefValue, Key, Node, Orientation, RefObject} from '@react-types/shared'; @@ -233,7 +234,7 @@ interface TabLineProps { density?: 'compact' | 'regular' } -const selectedIndicator = style({ +const selectedIndicator = style<{isDisabled: boolean, orientation?: Orientation}>({ position: 'absolute', backgroundColor: { default: 'neutral', @@ -320,7 +321,7 @@ function TabLine(props: TabLineProps) { ); } -const tab = style({ +const tab = style({ ...focusRing(), display: 'flex', color: { @@ -520,7 +521,12 @@ let HiddenTabs = function (props: { let TabsMenu = (props: {valueId: string, items: Array>, onSelectionChange: TabsProps['onSelectionChange']} & Omit) => { let {id, items, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, valueId} = props; - let {density, onSelectionChange, selectedKey, isDisabled, disabledKeys, labelBehavior} = useContext(InternalTabsContext); + let {density, onSelectionChange: _onSelectionChange, selectedKey, isDisabled, disabledKeys, labelBehavior} = useContext(InternalTabsContext); + let onSelectionChange = useCallback((key: Key | null) => { + if (key != null) { + _onSelectionChange?.(key); + } + }, [_onSelectionChange]); let state = useContext(TabListStateContext); let allKeysDisabled = useMemo(() => { return isAllTabsDisabled(state?.collection, disabledKeys ? new Set(disabledKeys) : new Set()); diff --git a/packages/@react-spectrum/s2/src/TabsPicker.tsx b/packages/@react-spectrum/s2/src/TabsPicker.tsx index f417bba8859..09987c91455 100644 --- a/packages/@react-spectrum/s2/src/TabsPicker.tsx +++ b/packages/@react-spectrum/s2/src/TabsPicker.tsx @@ -281,7 +281,7 @@ let _Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(Picker); export {_Picker as Picker}; -const selectedIndicator = style({ +const selectedIndicator = style<{isDisabled?: boolean}>({ backgroundColor: { default: 'neutral', isDisabled: 'disabled', @@ -297,7 +297,7 @@ const selectedIndicator = style({ transitionDuration: 130, transitionTimingFunction: 'in-out' }); -function TabLine(props) { +function TabLine(props: {isDisabled?: boolean}) { return
; } diff --git a/packages/@react-spectrum/s2/src/TagGroup.tsx b/packages/@react-spectrum/s2/src/TagGroup.tsx index 93fe47d3e94..b2aa19e19be 100644 --- a/packages/@react-spectrum/s2/src/TagGroup.tsx +++ b/packages/@react-spectrum/s2/src/TagGroup.tsx @@ -23,6 +23,7 @@ import { TextContext as RACTextContext, TagList, TagListProps, + TagRenderProps, useLocale, useSlottedContext } from 'react-aria-components'; @@ -437,7 +438,7 @@ function ActionGroup(props) { ); } -const tagStyles = style({ +const tagStyles = style({ ...focusRing(), display: 'inline-flex', boxSizing: 'border-box', diff --git a/packages/@react-spectrum/s2/style/spectrum-theme.ts b/packages/@react-spectrum/s2/style/spectrum-theme.ts index 38ff6ce7b1a..c045b5c976d 100644 --- a/packages/@react-spectrum/s2/style/spectrum-theme.ts +++ b/packages/@react-spectrum/s2/style/spectrum-theme.ts @@ -967,7 +967,8 @@ export const style = createTheme({ // eslint-disable-next-line @typescript-eslint/no-unused-vars disableTapHighlight: createArbitraryProperty((_value: true) => ({ '-webkit-tap-highlight-color': 'rgba(0,0,0,0)' - })) + })), + unicodeBidi: ['normal', 'embed', 'bidi-override', 'isolate', 'isolate-override', 'plaintext'] as const }, shorthands: { padding: ['paddingTop', 'paddingBottom', 'paddingStart', 'paddingEnd'] as const, diff --git a/packages/@react-spectrum/steplist/stories/StepList.stories.tsx b/packages/@react-spectrum/steplist/stories/StepList.stories.tsx index a3c2a55ae19..e18c57d3e08 100644 --- a/packages/@react-spectrum/steplist/stories/StepList.stories.tsx +++ b/packages/@react-spectrum/steplist/stories/StepList.stories.tsx @@ -219,8 +219,8 @@ export const ControlledStory: StepListStory = { }; function Controlled(args) { - const [lastCompletedStep, setLastCompletedStep] = useState(args.lastCompletedStep); - const [selectedKey, setSelectedKey] = useState(args.selectedKey); + const [lastCompletedStep, setLastCompletedStep] = useState(args.lastCompletedStep); + const [selectedKey, setSelectedKey] = useState(args.selectedKey); return ( diff --git a/packages/@react-spectrum/tabs/src/Tabs.tsx b/packages/@react-spectrum/tabs/src/Tabs.tsx index 9de494d5e1b..e6342c14bb7 100644 --- a/packages/@react-spectrum/tabs/src/Tabs.tsx +++ b/packages/@react-spectrum/tabs/src/Tabs.tsx @@ -377,12 +377,13 @@ function TabPanel(props: TabPanelProps) { ); } -interface TabPickerProps extends Omit, 'children'> { +interface TabPickerProps extends Omit, 'children' | 'onSelectionChange'> { density?: 'compact' | 'regular', isEmphasized?: boolean, state: TabListState, className?: string, - visible: boolean + visible: boolean, + onSelectionChange?: (key: Key) => void } function TabPicker(props: TabPickerProps) { @@ -450,7 +451,11 @@ function TabPicker(props: TabPickerProps) { isDisabled={!visible || isDisabled} selectedKey={state.selectedKey} disabledKeys={state.disabledKeys} - onSelectionChange={state.setSelectedKey} + onSelectionChange={key => { + if (key != null) { + state.setSelectedKey(key); + } + }} UNSAFE_className={classNames(styles, 'spectrum-Tabs-picker')}> {item => {item.rendered}} diff --git a/packages/@react-spectrum/tabs/stories/Tabs.stories.tsx b/packages/@react-spectrum/tabs/stories/Tabs.stories.tsx index 66b2c374313..ebc359adeea 100644 --- a/packages/@react-spectrum/tabs/stories/Tabs.stories.tsx +++ b/packages/@react-spectrum/tabs/stories/Tabs.stories.tsx @@ -20,7 +20,7 @@ import Dashboard from '@spectrum-icons/workflow/Dashboard'; import {Item, TabList, TabPanels, Tabs} from '..'; import {Key} from '@react-types/shared'; import {Picker} from '@react-spectrum/picker'; -import React, {ReactNode, useState} from 'react'; +import React, {ReactNode, useCallback, useState} from 'react'; import {RouterProvider} from '@react-aria/utils'; import {SpectrumTabsProps} from '@react-types/tabs'; import {TextField} from '@react-spectrum/textfield'; @@ -907,7 +907,12 @@ let DynamicTabsWithDecoration = (props = {}) => { }; let ControlledSelection = () => { - let [selectedKey, setSelectedKey] = useState('Tab 1'); + let [selectedKey, _setSelectedKey] = useState('Tab 1'); + let setSelectedKey = useCallback((key: Key | null) => { + if (key != null) { + _setSelectedKey(key); + } + }, [_setSelectedKey]); return (
diff --git a/packages/@react-stately/disclosure/src/useDisclosureGroupState.ts b/packages/@react-stately/disclosure/src/useDisclosureGroupState.ts index 81d04ae955b..ef880baa6e9 100644 --- a/packages/@react-stately/disclosure/src/useDisclosureGroupState.ts +++ b/packages/@react-stately/disclosure/src/useDisclosureGroupState.ts @@ -33,7 +33,7 @@ export interface DisclosureGroupState { /** Whether all items are disabled. */ readonly isDisabled: boolean, - + /** A set of keys for items that are expanded. */ readonly expandedKeys: Set, @@ -55,11 +55,14 @@ export function useDisclosureGroupState(props: DisclosureGroupProps): Disclosure useMemo(() => props.defaultExpandedKeys ? new Set(props.defaultExpandedKeys) : new Set(), [props.defaultExpandedKeys]), props.onExpandedChange ); - + useEffect(() => { // Ensure only one item is expanded if allowsMultipleExpanded is false. if (!allowsMultipleExpanded && expandedKeys.size > 1) { - setExpandedKeys(new Set([expandedKeys.values().next().value])); + let firstKey = expandedKeys.values().next().value; + if (firstKey != null) { + setExpandedKeys(new Set([firstKey])); + } } }); @@ -80,7 +83,7 @@ export function useDisclosureGroupState(props: DisclosureGroupProps): Disclosure } else { keys = new Set(expandedKeys.has(key) ? [] : [key]); } - + setExpandedKeys(keys); } }; diff --git a/packages/@react-stately/tabs/src/useTabListState.ts b/packages/@react-stately/tabs/src/useTabListState.ts index c9bc6c3c23d..116e39b059f 100644 --- a/packages/@react-stately/tabs/src/useTabListState.ts +++ b/packages/@react-stately/tabs/src/useTabListState.ts @@ -29,6 +29,11 @@ export interface TabListState extends SingleSelectListState { export function useTabListState(props: TabListStateOptions): TabListState { let state = useSingleSelectListState({ ...props, + onSelectionChange: props.onSelectionChange ? (key => { + if (key != null) { + props.onSelectionChange?.(key); + } + }) : undefined, suppressTextValueWarning: true, defaultSelectedKey: props.defaultSelectedKey ?? findDefaultSelectedKey(props.collection, props.disabledKeys ? new Set(props.disabledKeys) : new Set()) ?? undefined }); diff --git a/packages/@react-types/shared/src/selection.d.ts b/packages/@react-types/shared/src/selection.d.ts index e6b89f6ba39..e59aaf3e435 100644 --- a/packages/@react-types/shared/src/selection.d.ts +++ b/packages/@react-types/shared/src/selection.d.ts @@ -20,7 +20,7 @@ export interface SingleSelection { /** The initial selected key in the collection (uncontrolled). */ defaultSelectedKey?: Key, /** Handler that is called when the selection changes. */ - onSelectionChange?: (key: Key) => void + onSelectionChange?: (key: Key | null) => void } export type SelectionMode = 'none' | 'single' | 'multiple'; diff --git a/packages/@react-types/tabs/src/index.d.ts b/packages/@react-types/tabs/src/index.d.ts index ebd96c99934..e760d2596a1 100644 --- a/packages/@react-types/tabs/src/index.d.ts +++ b/packages/@react-types/tabs/src/index.d.ts @@ -30,12 +30,14 @@ export interface AriaTabProps extends AriaLabelingProps { shouldSelectOnPressUp?: boolean } -export interface TabListProps extends CollectionBase, Omit { +export interface TabListProps extends CollectionBase, Omit { /** * Whether the TabList is disabled. * Shows that a selection exists, but is not available in that circumstance. */ - isDisabled?: boolean + isDisabled?: boolean, + /** Handler that is called when the selection changes. */ + onSelectionChange?: (key: Key) => void } interface AriaTabListBase extends AriaLabelingProps { @@ -55,7 +57,7 @@ export interface AriaTabListProps extends TabListProps, AriaTabListBase, D export interface AriaTabPanelProps extends DOMProps, AriaLabelingProps {} -export interface SpectrumTabsProps extends AriaTabListBase, SingleSelection, DOMProps, StyleProps { +export interface SpectrumTabsProps extends AriaTabListBase, Omit, DOMProps, StyleProps { /** The children of the `` element. Should include `` and `` elements. */ children: ReactNode, /** The item objects for each tab, for dynamic collections. */ @@ -69,7 +71,9 @@ export interface SpectrumTabsProps extends AriaTabListBase, SingleSelection, /** Whether the tabs are displayed in an emphasized style. */ isEmphasized?: boolean, /** The amount of space between the tabs. */ - density?: 'compact' | 'regular' + density?: 'compact' | 'regular', + /** Handler that is called when the selection changes. */ + onSelectionChange?: (key: Key) => void } export interface SpectrumTabListProps extends DOMProps, StyleProps { diff --git a/packages/dev/codemods/package.json b/packages/dev/codemods/package.json index e7e18512004..4e3ec781c4d 100644 --- a/packages/dev/codemods/package.json +++ b/packages/dev/codemods/package.json @@ -26,7 +26,7 @@ "@babel/types": "^7.24.5", "@react-spectrum/s2": "^0.7.1", "@react-types/shared": "^3.28.0", - "@types/node": "^20", + "@types/node": "patch:@types/node@npm%3A20.14.13#~/.yarn/patches/@types-node-npm-20.14.13-41f92d384c.patch", "boxen": "^5.1.2", "build": "^0.1.4", "chalk": "^4.0.0", @@ -38,7 +38,7 @@ }, "devDependencies": { "@types/jscodeshift": "^0.11.11", - "typescript": "^5.5.0" + "typescript": "^5.8.2" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0-rc.1", diff --git a/packages/dev/docs/pages/react-aria/home/A11y.tsx b/packages/dev/docs/pages/react-aria/home/A11y.tsx index 73a14913dd5..d9e9820dfbe 100644 --- a/packages/dev/docs/pages/react-aria/home/A11y.tsx +++ b/packages/dev/docs/pages/react-aria/home/A11y.tsx @@ -66,7 +66,7 @@ export function A11y() { let [fingerPos, setFingerPos] = useState(null); let [isOpen, setOpen] = useState(false); let [caption, setCaption] = useState(''); - let [selectedKey, setSelectedKey] = useState('read'); + let [selectedKey, setSelectedKey] = useState('read'); useIntersectionObserver(ref, useCallback(() => { let button: HTMLButtonElement | null = null; let listbox: HTMLElement | null = null; diff --git a/packages/react-aria-components/docs/Checkbox.mdx b/packages/react-aria-components/docs/Checkbox.mdx index 6fe342d7d97..0df45705d92 100644 --- a/packages/react-aria-components/docs/Checkbox.mdx +++ b/packages/react-aria-components/docs/Checkbox.mdx @@ -167,7 +167,7 @@ This example wraps `Checkbox` and all of its children together into a single com ```tsx example export=true import type {CheckboxProps} from 'react-aria-components'; -export function MyCheckbox({children, ...props}: CheckboxProps) { +export function MyCheckbox({children, ...props}: Omit & {children?: React.ReactNode}) { return ( {({isIndeterminate}) => <> diff --git a/packages/react-aria-components/docs/GridList.mdx b/packages/react-aria-components/docs/GridList.mdx index 7d5483a0cb5..b00bc9796c8 100644 --- a/packages/react-aria-components/docs/GridList.mdx +++ b/packages/react-aria-components/docs/GridList.mdx @@ -318,7 +318,7 @@ export function MyGridList({children, ...props}: GridListProps ); } -export function MyItem({children, ...props}: GridListItemProps) { +export function MyItem({children, ...props}: Omit & {children?: React.ReactNode}) { let textValue = typeof children === 'string' ? children : undefined; return ( diff --git a/packages/react-aria-components/docs/Menu.mdx b/packages/react-aria-components/docs/Menu.mdx index 2001d15b9fc..1589abefabd 100644 --- a/packages/react-aria-components/docs/Menu.mdx +++ b/packages/react-aria-components/docs/Menu.mdx @@ -227,7 +227,7 @@ function MyMenuButton({label, children, ...props}: MyMenuButto ); } -export function MyItem(props: MenuItemProps) { +export function MyItem(props: Omit & {children?: React.ReactNode}) { let textValue = props.textValue || (typeof props.children === 'string' ? props.children : undefined); return ( & {children?: React.ReactNode}) { return ( {({allowsSorting, sortDirection}) => <> diff --git a/packages/react-aria-components/docs/TagGroup.mdx b/packages/react-aria-components/docs/TagGroup.mdx index 0868156df51..79d8e5765cb 100644 --- a/packages/react-aria-components/docs/TagGroup.mdx +++ b/packages/react-aria-components/docs/TagGroup.mdx @@ -228,7 +228,7 @@ function MyTagGroup({label, description, errorMessage, items, ); } -function MyTag({children, ...props}: TagProps) { +function MyTag({children, ...props}: Omit & {children?: React.ReactNode}) { let textValue = typeof children === 'string' ? children : undefined; return ( diff --git a/packages/react-aria-components/docs/Tree.mdx b/packages/react-aria-components/docs/Tree.mdx index 7b7c51b4251..8bbcad5db69 100644 --- a/packages/react-aria-components/docs/Tree.mdx +++ b/packages/react-aria-components/docs/Tree.mdx @@ -350,7 +350,7 @@ If you will use a Tree in multiple places in your app, you can wrap all of the p import type {TreeItemContentProps, TreeItemContentRenderProps} from 'react-aria-components'; import {Button} from 'react-aria-components'; -function MyTreeItemContent(props: TreeItemContentProps) { +function MyTreeItemContent(props: Omit & {children?: React.ReactNode}) { return ( {({hasChildItems, selectionBehavior, selectionMode}: TreeItemContentRenderProps) => ( diff --git a/scripts/extractExamples.mjs b/scripts/extractExamples.mjs index 1757dc4d6cb..137b3d6f174 100644 --- a/scripts/extractExamples.mjs +++ b/scripts/extractExamples.mjs @@ -108,7 +108,6 @@ import ReactDOM from 'react-dom/client'; fs.copyFileSync('lib/svg.d.ts', `${distDir}/svg.d.ts`); fs.copyFileSync('lib/css.d.ts', `${distDir}/css.d.ts`); -fs.copyFileSync('lib/viewTransitions.d.ts', `${distDir}/viewTransitions.d.ts`); fs.writeFileSync(`${distDir}/tsconfig.json`, `{ "compilerOptions": { "target": "es2018", diff --git a/tsconfig.json b/tsconfig.json index b05da2d90c7..d7d52a600cf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -46,7 +46,6 @@ "include": [ "packages", "lib/svg.d.ts", - "lib/viewTransitions.d.ts", "lib/css.d.ts" ], "exclude": [ diff --git a/yarn.lock b/yarn.lock index fc03054ddbd..5cb5a76789c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7193,7 +7193,7 @@ __metadata: "@react-spectrum/s2": "npm:^0.7.1" "@react-types/shared": "npm:^3.28.0" "@types/jscodeshift": "npm:^0.11.11" - "@types/node": "npm:^20" + "@types/node": "patch:@types/node@npm%3A20.14.13#~/.yarn/patches/@types-node-npm-20.14.13-41f92d384c.patch" boxen: "npm:^5.1.2" build: "npm:^0.1.4" chalk: "npm:^4.0.0" @@ -7201,7 +7201,7 @@ __metadata: jscodeshift: "npm:^0.15.2" recast: "npm:^0.23.9" ts-node: "npm:^10.9.2" - typescript: "npm:^5.5.0" + typescript: "npm:^5.8.2" uuid: "npm:^9.0.1" peerDependencies: react: ^18.0.0 || ^19.0.0-rc.1 @@ -11434,7 +11434,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:>= 8, @types/node@npm:^20": +"@types/node@npm:20.14.13": version: 20.14.13 resolution: "@types/node@npm:20.14.13" dependencies: @@ -11443,12 +11443,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^18.0.0": - version: 18.19.31 - resolution: "@types/node@npm:18.19.31" +"@types/node@patch:@types/node@npm%3A20.14.13#~/.yarn/patches/@types-node-npm-20.14.13-41f92d384c.patch": + version: 20.14.13 + resolution: "@types/node@patch:@types/node@npm%3A20.14.13#~/.yarn/patches/@types-node-npm-20.14.13-41f92d384c.patch::version=20.14.13&hash=24fda2" dependencies: undici-types: "npm:~5.26.4" - checksum: 10c0/bfebae8389220c0188492c82eaf328f4ba15e6e9b4abee33d6bf36d3b13f188c2f53eb695d427feb882fff09834f467405e2ed9be6aeb6ad4705509822d2ea08 + checksum: 10c0/1bbbadf2732c27a8f1bc7b20be41bf7faa50436461c1af6e67398c2003a8bde9893e04f762d55b231ef818d4567ec6bcb6bcdf28748f73a715a11dd7df88e9f5 languageName: node linkType: hard @@ -29649,7 +29649,7 @@ __metadata: tailwindcss: "npm:^4.0.0" tailwindcss-animate: "npm:^1.0.7" tempy: "npm:^0.5.0" - typescript: "npm:^5.5.0" + typescript: "npm:^5.8.2" typescript-eslint: "npm:^8.9.0" verdaccio: "npm:^6.0.0" walk-object: "npm:^4.0.0" @@ -33595,6 +33595,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:^5.8.2": + version: 5.8.2 + resolution: "typescript@npm:5.8.2" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/5c4f6fbf1c6389b6928fe7b8fcd5dc73bb2d58cd4e3883f1d774ed5bd83b151cbac6b7ecf11723de56d4676daeba8713894b1e9af56174f2f9780ae7848ec3c6 + languageName: node + linkType: hard + "typescript@patch:typescript@npm%3A^5.5.0#optional!builtin": version: 5.5.2 resolution: "typescript@patch:typescript@npm%3A5.5.2#optional!builtin::version=5.5.2&hash=b45daf" @@ -33605,6 +33615,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@npm%3A^5.8.2#optional!builtin": + version: 5.8.2 + resolution: "typescript@patch:typescript@npm%3A5.8.2#optional!builtin::version=5.8.2&hash=b45daf" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/8a6cd29dfb59bd5a978407b93ae0edb530ee9376a5b95a42ad057a6f80ffb0c410489ccd6fe48d1d0dfad6e8adf5d62d3874bbd251f488ae30e11a1ce6dabd28 + languageName: node + linkType: hard + "ua-parser-js@npm:0.7.17": version: 0.7.17 resolution: "ua-parser-js@npm:0.7.17" From 68c67d4fa9b7a99ec1150219dc4a44084a8154a3 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 8 Apr 2025 12:48:43 -0700 Subject: [PATCH 12/25] fix: Apply touch-action by default in usePress (#8047) * fix: Apply touch-action by default in usePress * fix test --------- Co-authored-by: Daniel Lu --- .../@react-aria/interactions/src/usePress.ts | 20 ++++-- .../interactions/stories/usePress.stories.tsx | 69 ++++++++++++++++++- .../selection/src/useSelectableItem.ts | 4 +- 3 files changed, 86 insertions(+), 7 deletions(-) diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index 5a9edad1fcb..e48240a10c3 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -176,8 +176,7 @@ export function usePress(props: PressHookProps): PressResult { preventFocusOnPress, shouldCancelOnPointerExit, allowTextSelectionOnPress, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - ref: _, // Removing `ref` from `domProps` because TypeScript is dumb + ref: domRef, ...domProps } = usePressResponderContext(props); @@ -814,13 +813,26 @@ export function usePress(props: PressHookProps): PressResult { triggerSyntheticClick ]); - // Remove user-select: none in case component unmounts immediately after pressStart + // Avoid onClick delay for double tap to zoom by default. + useEffect(() => { + let element = domRef?.current; + if (element && (element instanceof getOwnerWindow(element).Element)) { + // Only apply touch-action if not already set by another CSS rule. + let style = getOwnerWindow(element).getComputedStyle(element); + if (style.touchAction === 'auto') { + // touchAction: 'manipulation' is supposed to be equivalent, but in + // Safari it causes onPointerCancel not to fire on scroll. + // https://bugs.webkit.org/show_bug.cgi?id=240917 + (element as HTMLElement).style.touchAction = 'pan-x pan-y pinch-zoom'; + } + } + }, [domRef]); + // Remove user-select: none in case component unmounts immediately after pressStart useEffect(() => { let state = ref.current; return () => { if (!allowTextSelectionOnPress) { - restoreTextSelection(state.target ?? undefined); } for (let dispose of state.disposables) { diff --git a/packages/@react-aria/interactions/stories/usePress.stories.tsx b/packages/@react-aria/interactions/stories/usePress.stories.tsx index ac982394ce3..0ac998aedd3 100644 --- a/packages/@react-aria/interactions/stories/usePress.stories.tsx +++ b/packages/@react-aria/interactions/stories/usePress.stories.tsx @@ -19,7 +19,7 @@ import { Modal, ModalOverlay } from 'react-aria-components'; -import React from 'react'; +import React, {useState} from 'react'; import styles from './usePress-stories.css'; import {usePress} from '@react-aria/interactions'; @@ -234,3 +234,70 @@ export function SoftwareKeyboardIssue() {
); } + +export function AndroidUnmountIssue() { + let [showButton, setShowButton] = useState(true); + + return ( +
+

This story tests an Android issue where tapping a button that unmounts causes the element behind it to receive onClick.

+
+ + {showButton && ( + + )} +
+
+ ); +} + +export function IOSScrollIssue() { + return ( +
+

This story tests an iOS Safari issue that causes onPointerCancel not to be fired with touch-action: manipulation. Scrolling the list should not trigger onPress.

+
+ {Array.from({length: 10}).map((_, i) => ( + + ))} +
+
+ ); +} + +function Card() { + return ( + + ); +} diff --git a/packages/@react-aria/selection/src/useSelectableItem.ts b/packages/@react-aria/selection/src/useSelectableItem.ts index f6bd48f0c11..fe2f556543b 100644 --- a/packages/@react-aria/selection/src/useSelectableItem.ts +++ b/packages/@react-aria/selection/src/useSelectableItem.ts @@ -11,7 +11,7 @@ */ import {DOMAttributes, DOMProps, FocusableElement, Key, LongPressEvent, PointerType, PressEvent, RefObject} from '@react-types/shared'; -import {focusSafely, PressProps, useLongPress, usePress} from '@react-aria/interactions'; +import {focusSafely, PressHookProps, useLongPress, usePress} from '@react-aria/interactions'; import {getCollectionId, isNonContiguousSelectionModifier} from './utils'; import {isCtrlKeyPressed, mergeProps, openLink, useId, useRouter} from '@react-aria/utils'; import {moveVirtualFocus} from '@react-aria/focus'; @@ -239,7 +239,7 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte // we want to be able to have the pointer down on the trigger that opens the menu and // the pointer up on the menu item rather than requiring a separate press. // For keyboard events, selection still occurs on key down. - let itemPressProps: PressProps = {}; + let itemPressProps: PressHookProps = {ref}; if (shouldSelectOnPressUp) { itemPressProps.onPressStart = (e) => { modality.current = e.pointerType; From 5dcf9ce05872f43c968b55ba988205dc75ab4b4c Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Wed, 9 Apr 2025 06:12:09 +1000 Subject: [PATCH 13/25] fix: set some better flex behaviour (#8048) --- packages/@react-spectrum/s2/src/Divider.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/@react-spectrum/s2/src/Divider.tsx b/packages/@react-spectrum/s2/src/Divider.tsx index 31ae57f0f44..09a3cd8a90d 100644 --- a/packages/@react-spectrum/s2/src/Divider.tsx +++ b/packages/@react-spectrum/s2/src/Divider.tsx @@ -61,6 +61,8 @@ export const divider = style({ borderStyle: 'none', borderRadius: 'full', margin: 0, + flexGrow: 0, + flexShrink: 0, height: { orientation: { horizontal: { From a654a339104a5143053eb4937d9f237e44b7d1e9 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 8 Apr 2025 14:21:21 -0700 Subject: [PATCH 14/25] fix: Support React 19 and remove Jest reliance in test utils (#7686) * attempt to get rid of jest calls in menu util * update RSP testing docs to directly mention mocks that maybe needed * bump versions of RTL to 16 * use alternative to calling jest run timers in menu option selection * fixing types and properly testing long press * fix lint * revert to pre testing library bump for clean slate * fix build and another submenu edge case now we shouldnt need to call runAllTimers after selectOption * fix react 16 bug * update return type of advanceTimer and docs copy * move some general fixes from selectionMode="replace" branch here * get rid of unneeded async * getting rid of extraneous dep * fix lint --- package.json | 2 +- packages/@react-aria/test-utils/package.json | 5 +- packages/@react-aria/test-utils/src/events.ts | 2 +- .../@react-aria/test-utils/src/gridlist.ts | 9 +-- .../@react-aria/test-utils/src/listbox.ts | 14 ++-- packages/@react-aria/test-utils/src/menu.ts | 70 ++++++++++++++++--- packages/@react-aria/test-utils/src/tree.ts | 9 +-- packages/@react-aria/test-utils/src/types.ts | 12 ++-- packages/@react-aria/test-utils/src/user.ts | 2 +- .../combobox/docs/ComboBox.mdx | 2 +- .../@react-spectrum/list/docs/ListView.mdx | 2 +- .../@react-spectrum/listbox/docs/ListBox.mdx | 2 +- .../@react-spectrum/menu/docs/MenuTrigger.mdx | 2 +- .../@react-spectrum/picker/docs/Picker.mdx | 2 +- packages/@react-spectrum/s2/package.json | 2 +- .../@react-spectrum/table/docs/TableView.mdx | 2 +- ...eUtils.test.js => TestTableUtils.test.tsx} | 62 ++++++++++++++-- packages/@react-spectrum/tabs/docs/Tabs.mdx | 2 +- .../@react-spectrum/test-utils/package.json | 5 +- .../@react-spectrum/tree/docs/TreeView.mdx | 2 +- .../dev/docs/pages/react-aria/testing.mdx | 2 +- .../dev/docs/pages/react-spectrum/testing.mdx | 2 +- packages/dev/test-utils/package.json | 2 +- .../test/AriaMenu.test-util.tsx | 5 -- .../react-aria-components/test/Menu.test.tsx | 2 +- yarn.lock | 34 +++++---- 26 files changed, 179 insertions(+), 78 deletions(-) rename packages/@react-spectrum/table/test/{TestTableUtils.test.js => TestTableUtils.test.tsx} (77%) diff --git a/package.json b/package.json index 92490328d4e..ff6b02ab288 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,7 @@ "@tailwindcss/postcss": "^4.0.0", "@testing-library/dom": "^10.1.0", "@testing-library/jest-dom": "^5.16.5", - "@testing-library/react": "^15.0.7", + "@testing-library/react": "^16.0.0", "@testing-library/user-event": "patch:@testing-library/user-event@npm%3A14.6.1#~/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch", "@types/react": "npm:types-react@19.0.0-rc.0", "@types/react-dom": "npm:types-react-dom@19.0.0-rc.0", diff --git a/packages/@react-aria/test-utils/package.json b/packages/@react-aria/test-utils/package.json index 0d333c76612..7d7f58e0456 100644 --- a/packages/@react-aria/test-utils/package.json +++ b/packages/@react-aria/test-utils/package.json @@ -25,9 +25,10 @@ "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "@testing-library/react": "^15.0.7", + "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.0.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" }, "publishConfig": { "access": "public" diff --git a/packages/@react-aria/test-utils/src/events.ts b/packages/@react-aria/test-utils/src/events.ts index dfaac028e65..0d09ca80217 100644 --- a/packages/@react-aria/test-utils/src/events.ts +++ b/packages/@react-aria/test-utils/src/events.ts @@ -22,7 +22,7 @@ export const DEFAULT_LONG_PRESS_TIME = 500; * @param opts.advanceTimer - Function that when called advances the timers in your test suite by a specific amount of time(ms). * @param opts.pointeropts - Options to pass to the simulated event. Defaults to mouse. See https://testing-library.com/docs/dom-testing-library/api-events/#fireevent for more info. */ -export async function triggerLongPress(opts: {element: HTMLElement, advanceTimer: (time?: number) => void | Promise, pointerOpts?: Record}): Promise { +export async function triggerLongPress(opts: {element: HTMLElement, advanceTimer: (time: number) => unknown | Promise, pointerOpts?: Record}): Promise { // TODO: note that this only works if the code from installPointerEvent is called somewhere in the test BEFORE the // render. Perhaps we should rely on the user setting that up since I'm not sure there is a great way to set that up here in the // util before first render. Will need to document it well diff --git a/packages/@react-aria/test-utils/src/gridlist.ts b/packages/@react-aria/test-utils/src/gridlist.ts index 1072576eae0..8be873475fd 100644 --- a/packages/@react-aria/test-utils/src/gridlist.ts +++ b/packages/@react-aria/test-utils/src/gridlist.ts @@ -64,6 +64,11 @@ export class GridListTester { if (targetIndex === -1) { throw new Error('Option provided is not in the gridlist'); } + + if (document.activeElement !== this._gridlist || !this._gridlist.contains(document.activeElement)) { + act(() => this._gridlist.focus()); + } + if (document.activeElement === this._gridlist) { await this.user.keyboard('[ArrowDown]'); } else if (this._gridlist.contains(document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') { @@ -161,10 +166,6 @@ export class GridListTester { return; } - if (document.activeElement !== this._gridlist || !this._gridlist.contains(document.activeElement)) { - act(() => this._gridlist.focus()); - } - await this.keyboardNavigateToRow({row}); await this.user.keyboard('[Enter]'); } else { diff --git a/packages/@react-aria/test-utils/src/listbox.ts b/packages/@react-aria/test-utils/src/listbox.ts index 06ef338268c..152d7426665 100644 --- a/packages/@react-aria/test-utils/src/listbox.ts +++ b/packages/@react-aria/test-utils/src/listbox.ts @@ -93,10 +93,12 @@ export class ListBoxTester { throw new Error('Option provided is not in the listbox'); } - if (document.activeElement === this._listbox) { - await this.user.keyboard('[ArrowDown]'); + if (document.activeElement !== this._listbox || !this._listbox.contains(document.activeElement)) { + act(() => this._listbox.focus()); } + await this.user.keyboard('[ArrowDown]'); + // TODO: not sure about doing same while loop that exists in other implementations of keyboardNavigateToOption, // feels like it could break easily if (document.activeElement?.getAttribute('role') !== 'option') { @@ -135,10 +137,6 @@ export class ListBoxTester { return; } - if (document.activeElement !== this._listbox || !this._listbox.contains(document.activeElement)) { - act(() => this._listbox.focus()); - } - await this.keyboardNavigateToOption({option}); await this.user.keyboard(`[${keyboardActivation}]`); } else { @@ -179,10 +177,6 @@ export class ListBoxTester { return; } - if (document.activeElement !== this._listbox || !this._listbox.contains(document.activeElement)) { - act(() => this._listbox.focus()); - } - await this.keyboardNavigateToOption({option}); await this.user.keyboard('[Enter]'); } else { diff --git a/packages/@react-aria/test-utils/src/menu.ts b/packages/@react-aria/test-utils/src/menu.ts index 228a6818c76..232f7b7685c 100644 --- a/packages/@react-aria/test-utils/src/menu.ts +++ b/packages/@react-aria/test-utils/src/menu.ts @@ -64,9 +64,10 @@ export class MenuTester { private _advanceTimer: UserOpts['advanceTimer']; private _trigger: HTMLElement | undefined; private _isSubmenu: boolean = false; + private _rootMenu: HTMLElement | undefined; constructor(opts: MenuTesterOpts) { - let {root, user, interactionType, advanceTimer, isSubmenu} = opts; + let {root, user, interactionType, advanceTimer, isSubmenu, rootMenu} = opts; this.user = user; this._interactionType = interactionType || 'mouse'; this._advanceTimer = advanceTimer; @@ -85,6 +86,7 @@ export class MenuTester { } this._isSubmenu = isSubmenu || false; + this._rootMenu = rootMenu; } /** @@ -226,20 +228,56 @@ export class MenuTester { await this.user.pointer({target: option, keys: '[TouchA]'}); } } - act(() => {jest.runAllTimers();}); - if (option.getAttribute('href') == null && option.getAttribute('aria-haspopup') == null && menuSelectionMode === 'single' && closesOnSelect && keyboardActivation !== 'Space' && !this._isSubmenu) { + // This chain of waitFors is needed in place of running all timers since we don't know how long transitions may take, or what action + // the menu option select may trigger. + if ( + !(menuSelectionMode === 'single' && !closesOnSelect) && + !(menuSelectionMode === 'multiple' && (keyboardActivation === 'Space' || interactionType === 'mouse')) + ) { + // For RSP, clicking on a submenu option seems to briefly lose focus to the body before moving to the clicked option in the test so we need to wait + // for focus to be coerced to somewhere else in place of running all timers. + if (this._isSubmenu) { + await waitFor(() => { + if (document.activeElement === document.body) { + throw new Error('Expected focus to move to somewhere other than the body after selecting a submenu option.'); + } else { + return true; + } + }); + } + + // If user isn't trying to select multiple menu options or closeOnSelect is true then we can assume that + // the menu will close or some action is triggered. In cases like that focus should move somewhere after the menu closes + // but we can't really know where so just make sure it doesn't get lost to the body. await waitFor(() => { - if (document.activeElement !== trigger) { - throw new Error(`Expected the document.activeElement after selecting an option to be the menu trigger but got ${document.activeElement}`); + if (document.activeElement === option) { + throw new Error('Expected focus after selecting an option to move away from the option.'); } else { return true; } }); - if (document.contains(menu)) { - throw new Error('Expected menu element to not be in the document after selecting an option'); + // We'll also want to wait for focus to move away from the original submenu trigger since the entire submenu tree should + // close. In React 16, focus actually makes it all the way to the root menu's submenu trigger so we need check the root menu + if (this._isSubmenu) { + await waitFor(() => { + if (document.activeElement === this.trigger || this._rootMenu?.contains(document.activeElement)) { + throw new Error('Expected focus after selecting an submenu option to move away from the original submenu trigger.'); + } else { + return true; + } + }); } + + // Finally wait for focus to be coerced somewhere final when the menu tree is removed from the DOM + await waitFor(() => { + if (document.activeElement === document.body) { + throw new Error('Expected focus to move to somewhere other than the body after selecting a menu option.'); + } else { + return true; + } + }); } } else { throw new Error("Attempted to select a option in the menu, but menu wasn't found."); @@ -269,18 +307,30 @@ export class MenuTester { submenuTrigger = (within(menu!).getByText(submenuTrigger).closest('[role=menuitem]'))! as HTMLElement; } - let submenuTriggerTester = new MenuTester({user: this.user, interactionType: this._interactionType, root: submenuTrigger, isSubmenu: true}); + let submenuTriggerTester = new MenuTester({ + user: this.user, + interactionType: this._interactionType, + root: submenuTrigger, + isSubmenu: true, + advanceTimer: this._advanceTimer, + rootMenu: (this._isSubmenu ? this._rootMenu : this.menu) || undefined + }); if (interactionType === 'mouse') { await this.user.pointer({target: submenuTrigger}); - act(() => {jest.runAllTimers();}); } else if (interactionType === 'keyboard') { await this.keyboardNavigateToOption({option: submenuTrigger}); await this.user.keyboard('[ArrowRight]'); - act(() => {jest.runAllTimers();}); } else { await submenuTriggerTester.open(); } + await waitFor(() => { + if (submenuTriggerTester._trigger?.getAttribute('aria-expanded') !== 'true') { + throw new Error('aria-expanded for the submenu trigger wasn\'t changed to "true", unable to confirm the existance of the submenu'); + } else { + return true; + } + }); return submenuTriggerTester; } diff --git a/packages/@react-aria/test-utils/src/tree.ts b/packages/@react-aria/test-utils/src/tree.ts index 593e566e3c5..c767948a43c 100644 --- a/packages/@react-aria/test-utils/src/tree.ts +++ b/packages/@react-aria/test-utils/src/tree.ts @@ -71,6 +71,11 @@ export class TreeTester { if (targetIndex === -1) { throw new Error('Option provided is not in the tree'); } + + if (document.activeElement !== this._tree || !this._tree.contains(document.activeElement)) { + act(() => this._tree.focus()); + } + if (document.activeElement === this.tree) { await this.user.keyboard('[ArrowDown]'); } else if (this._tree.contains(document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') { @@ -206,10 +211,6 @@ export class TreeTester { return; } - if (document.activeElement !== this._tree || !this._tree.contains(document.activeElement)) { - act(() => this._tree.focus()); - } - await this.keyboardNavigateToRow({row}); await this.user.keyboard('[Enter]'); } else { diff --git a/packages/@react-aria/test-utils/src/types.ts b/packages/@react-aria/test-utils/src/types.ts index 3c58dbc61f3..c32415cb96f 100644 --- a/packages/@react-aria/test-utils/src/types.ts +++ b/packages/@react-aria/test-utils/src/types.ts @@ -22,14 +22,14 @@ export interface UserOpts { * @default mouse */ interactionType?: 'mouse' | 'touch' | 'keyboard', - // If using fake timers user should provide something like (time) => jest.advanceTimersByTime(time))} - // A real timer user would pass async () => await new Promise((resolve) => setTimeout(resolve, waitTime)) + // If using fake timers user should provide something like (time) => jest.advanceTimersByTime(time))}. + // A real timer user would pass (waitTime) => new Promise((resolve) => setTimeout(resolve, waitTime)) // Time is in ms. /** * A function used by the test utils to advance timers during interactions. Required for certain aria patterns (e.g. table). This can be overridden * at the aria pattern tester level if needed. */ - advanceTimer?: (time?: number) => void | Promise + advanceTimer?: (time: number) => unknown | Promise } export interface BaseTesterOpts extends UserOpts { @@ -69,7 +69,11 @@ export interface MenuTesterOpts extends BaseTesterOpts { /** * Whether the current menu is a submenu. */ - isSubmenu?: boolean + isSubmenu?: boolean, + /** + * The root menu of the menu tree. Only available if the menu is a submenu. + */ + rootMenu?: HTMLElement } export interface SelectTesterOpts extends BaseTesterOpts { diff --git a/packages/@react-aria/test-utils/src/user.ts b/packages/@react-aria/test-utils/src/user.ts index 09b40fdde1a..7f4eae4fec0 100644 --- a/packages/@react-aria/test-utils/src/user.ts +++ b/packages/@react-aria/test-utils/src/user.ts @@ -67,7 +67,7 @@ type TesterOpts = T extends 'Tree' ? TreeTesterOpts : never; -let defaultAdvanceTimer = async (waitTime: number | undefined) => await new Promise((resolve) => setTimeout(resolve, waitTime)); +let defaultAdvanceTimer = (waitTime: number | undefined) => new Promise((resolve) => setTimeout(resolve, waitTime)); export class User { private user; diff --git a/packages/@react-spectrum/combobox/docs/ComboBox.mdx b/packages/@react-spectrum/combobox/docs/ComboBox.mdx index b4000f4de81..20d2e86936e 100644 --- a/packages/@react-spectrum/combobox/docs/ComboBox.mdx +++ b/packages/@react-spectrum/combobox/docs/ComboBox.mdx @@ -1006,7 +1006,7 @@ import {theme} from '@react-spectrum/theme-default'; import {User} from '@react-spectrum/test-utils'; let testUtilUser = new User({interactionType: 'mouse'}); -// ... +// Other setup, be sure to check out the suggested mocks mentioned above in https://react-spectrum.adobe.com/react-spectrum/ComboBox.html#testing it('ComboBox can select an option via keyboard', async function () { // Render your test component/app and initialize the combobox tester diff --git a/packages/@react-spectrum/list/docs/ListView.mdx b/packages/@react-spectrum/list/docs/ListView.mdx index 73c326ebae5..553410132a7 100644 --- a/packages/@react-spectrum/list/docs/ListView.mdx +++ b/packages/@react-spectrum/list/docs/ListView.mdx @@ -1205,7 +1205,7 @@ import {theme} from '@react-spectrum/theme-default'; import {User} from '@react-spectrum/test-utils'; let testUtilUser = new User({interactionType: 'mouse'}); -// ... +// Other setup, be sure to check out the suggested mocks mentioned above in https://react-spectrum.adobe.com/react-spectrum/ListView.html#testing it('ListView can select a row via keyboard', async function () { // Render your test component/app and initialize the gridlist tester diff --git a/packages/@react-spectrum/listbox/docs/ListBox.mdx b/packages/@react-spectrum/listbox/docs/ListBox.mdx index 56de8d1e1e1..93a1a1ad259 100644 --- a/packages/@react-spectrum/listbox/docs/ListBox.mdx +++ b/packages/@react-spectrum/listbox/docs/ListBox.mdx @@ -423,7 +423,7 @@ import {theme} from '@react-spectrum/theme-default'; import {User} from '@react-spectrum/test-utils'; let testUtilUser = new User({interactionType: 'mouse'}); -// ... +// Other setup, be sure to check out the suggested mocks mentioned above in https://react-spectrum.adobe.com/react-spectrum/ListBox.html#testing it('ListBox can select an option via keyboard', async function () { // Render your test component/app and initialize the listbox tester diff --git a/packages/@react-spectrum/menu/docs/MenuTrigger.mdx b/packages/@react-spectrum/menu/docs/MenuTrigger.mdx index 4641da4f53e..6d90a7dc207 100644 --- a/packages/@react-spectrum/menu/docs/MenuTrigger.mdx +++ b/packages/@react-spectrum/menu/docs/MenuTrigger.mdx @@ -270,7 +270,7 @@ import {theme} from '@react-spectrum/theme-default'; import {User} from '@react-spectrum/test-utils'; let testUtilUser = new User({interactionType: 'mouse'}); -// ... +// Other setup, be sure to check out the suggested mocks mentioned above in https://react-spectrum.adobe.com/react-spectrum/MenuTrigger.html#testing it('Menu can open its submenu via keyboard', async function () { // Render your test component/app and initialize the menu tester diff --git a/packages/@react-spectrum/picker/docs/Picker.mdx b/packages/@react-spectrum/picker/docs/Picker.mdx index 323384fcc5f..3a00852e26a 100644 --- a/packages/@react-spectrum/picker/docs/Picker.mdx +++ b/packages/@react-spectrum/picker/docs/Picker.mdx @@ -602,7 +602,7 @@ import {theme} from '@react-spectrum/theme-default'; import {User} from '@react-spectrum/test-utils'; let testUtilUser = new User({interactionType: 'mouse'}); -// ... +// Other setup, be sure to check out the suggested mocks mentioned above in https://react-spectrum.adobe.com/react-spectrum/Picker.html#testing it('Picker can select an option via keyboard', async function () { // Render your test component/app and initialize the select tester diff --git a/packages/@react-spectrum/s2/package.json b/packages/@react-spectrum/s2/package.json index c2bc2d56890..36cb974f753 100644 --- a/packages/@react-spectrum/s2/package.json +++ b/packages/@react-spectrum/s2/package.json @@ -124,7 +124,7 @@ "@parcel/macros": "^2.14.0", "@react-aria/test-utils": "1.0.0-alpha.3", "@testing-library/dom": "^10.1.0", - "@testing-library/react": "^15.0.7", + "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.0.0", "jest": "^29.5.0" }, diff --git a/packages/@react-spectrum/table/docs/TableView.mdx b/packages/@react-spectrum/table/docs/TableView.mdx index 0cf48832f84..2150acb7ce5 100644 --- a/packages/@react-spectrum/table/docs/TableView.mdx +++ b/packages/@react-spectrum/table/docs/TableView.mdx @@ -1974,7 +1974,7 @@ import {theme} from '@react-spectrum/theme-default'; import {User} from '@react-spectrum/test-utils'; let testUtilUser = new User({interactionType: 'mouse', advanceTimer: jest.advanceTimersByTime}); -// ... +// Other setup, be sure to check out the suggested mocks mentioned above in https://react-spectrum.adobe.com/react-spectrum/TableView.html#testing it('TableView can toggle row selection', async function () { // Render your test component/app and initialize the table tester diff --git a/packages/@react-spectrum/table/test/TestTableUtils.test.js b/packages/@react-spectrum/table/test/TestTableUtils.test.tsx similarity index 77% rename from packages/@react-spectrum/table/test/TestTableUtils.test.js rename to packages/@react-spectrum/table/test/TestTableUtils.test.tsx index 2e55e6fa9fa..45080322008 100644 --- a/packages/@react-spectrum/table/test/TestTableUtils.test.js +++ b/packages/@react-spectrum/table/test/TestTableUtils.test.tsx @@ -12,12 +12,12 @@ import {act, render, screen} from '@react-spectrum/test-utils-internal'; import {Cell, Column, Row, TableBody, TableHeader, TableView} from '../'; +import {installPointerEvent, User} from '@react-aria/test-utils'; import {Provider} from '@react-spectrum/provider'; import React, {useState} from 'react'; import {theme} from '@react-spectrum/theme-default'; -import {User} from '@react-aria/test-utils'; -let manyItems = []; +let manyItems = [] as any[]; for (let i = 1; i <= 10; i++) { manyItems.push({id: i, foo: 'Foo ' + i, bar: 'Bar ' + i, baz: 'Baz ' + i}); } @@ -30,12 +30,12 @@ let columns = [ // getComputedStyle is very slow in our version of jsdom. // These tests only care about direct inline styles. We can avoid parsing other stylesheets. -window.getComputedStyle = (el) => el.style; +window.getComputedStyle = (el) => (el as HTMLElement).style; describe('Table ', function () { let onSelectionChange = jest.fn(); let onSortChange = jest.fn(); - let testUtilRealTimer = new User(); + let testUtilRealTimer = new User({advanceTimer: (waitTime) => new Promise((resolve) => setTimeout(resolve, waitTime))}); let TableExample = (props) => { let [sort, setSort] = useState({}); @@ -132,6 +132,30 @@ describe('Table ', function () { }); }); + describe('long press, real timers', () => { + installPointerEvent(); + beforeAll(function () { + jest.useRealTimers(); + }); + + afterEach(function () { + jest.clearAllMocks(); + }); + + it('highlight selection should switch to selection mode on long press', async function () { + render(); + let tableTester = testUtilRealTimer.createTester('Table', {root: screen.getByTestId('test'), interactionType: 'touch'}); + tableTester.setInteractionType('touch'); + await tableTester.toggleRowSelection({row: 2, needsLongPress: true}); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['Foo 3'])); + + await tableTester.toggleRowSelection({row: 'Foo 4'}); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(new Set(onSelectionChange.mock.calls[1][0])).toEqual(new Set(['Foo 3', 'Foo 4'])); + }); + }); + describe.each` interactionType ${'mouse'} @@ -183,11 +207,11 @@ describe('Table ', function () { let tableTester = testUtilFakeTimer.createTester('Table', {root: screen.getByTestId('test')}); tableTester.setInteractionType(interactionType); - await tableTester.toggleRowSelection({row: 2, focusToSelect: true}); + await tableTester.toggleRowSelection({row: 2}); expect(onSelectionChange).toHaveBeenCalledTimes(1); expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['Foo 3'])); - await tableTester.toggleRowSelection({row: 'Foo 4', focusToSelect: true}); + await tableTester.toggleRowSelection({row: 'Foo 4'}); expect(onSelectionChange).toHaveBeenCalledTimes(2); expect(new Set(onSelectionChange.mock.calls[1][0])).toEqual(new Set(['Foo 4'])); @@ -204,4 +228,30 @@ describe('Table ', function () { expect(onSortChange).toHaveBeenLastCalledWith({column: 'foo', direction: 'descending'}); }); }); + + describe('long press, fake timers', () => { + installPointerEvent(); + let testUtilFakeTimer = new User({interactionType: 'touch', advanceTimer: jest.advanceTimersByTime}); + beforeAll(function () { + jest.useFakeTimers(); + }); + + afterEach(function () { + act(() => jest.runAllTimers()); + jest.clearAllMocks(); + }); + + it('highlight selection should switch to selection mode on long press', async function () { + render(); + let tableTester = testUtilFakeTimer.createTester('Table', {root: screen.getByTestId('test')}); + + await tableTester.toggleRowSelection({row: 2, needsLongPress: true}); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['Foo 3'])); + + await tableTester.toggleRowSelection({row: 'Foo 4'}); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(new Set(onSelectionChange.mock.calls[1][0])).toEqual(new Set(['Foo 3', 'Foo 4'])); + }); + }); }); diff --git a/packages/@react-spectrum/tabs/docs/Tabs.mdx b/packages/@react-spectrum/tabs/docs/Tabs.mdx index 85fa97d208c..27f655b55ec 100644 --- a/packages/@react-spectrum/tabs/docs/Tabs.mdx +++ b/packages/@react-spectrum/tabs/docs/Tabs.mdx @@ -649,7 +649,7 @@ import {theme} from '@react-spectrum/theme-default'; import {User} from '@react-spectrum/test-utils'; let testUtilUser = new User({interactionType: 'mouse'}); -// ... +// Other setup, be sure to check out the suggested mocks mentioned above in https://react-spectrum.adobe.com/react-spectrum/Tabs.html#testing it('Tabs can change selection via keyboard', async function () { // Render your test component/app and initialize the listbox tester diff --git a/packages/@react-spectrum/test-utils/package.json b/packages/@react-spectrum/test-utils/package.json index 2e0a4619626..4a29aff7157 100644 --- a/packages/@react-spectrum/test-utils/package.json +++ b/packages/@react-spectrum/test-utils/package.json @@ -28,10 +28,11 @@ "@swc/helpers": "^0.5.0" }, "peerDependencies": { - "@testing-library/react": "^15.0.7", + "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.0.0", "jest": "^29.5.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" }, "publishConfig": { "access": "public" diff --git a/packages/@react-spectrum/tree/docs/TreeView.mdx b/packages/@react-spectrum/tree/docs/TreeView.mdx index 152ca62ff63..97c04a20af9 100644 --- a/packages/@react-spectrum/tree/docs/TreeView.mdx +++ b/packages/@react-spectrum/tree/docs/TreeView.mdx @@ -543,7 +543,7 @@ import {theme} from '@react-spectrum/theme-default'; import {User} from '@react-spectrum/test-utils'; let testUtilUser = new User({interactionType: 'mouse'}); -// ... +// Other setup, be sure to check out the suggested mocks mentioned above in https://react-spectrum.adobe.com/react-spectrum/TreeView.html#testing it('TreeView can select a row via keyboard', async function () { // Render your test component/app and initialize the Tree tester diff --git a/packages/dev/docs/pages/react-aria/testing.mdx b/packages/dev/docs/pages/react-aria/testing.mdx index dd26321bb55..85f8939afaf 100644 --- a/packages/dev/docs/pages/react-aria/testing.mdx +++ b/packages/dev/docs/pages/react-aria/testing.mdx @@ -162,7 +162,7 @@ the resulting state of the component. yarn add --dev @react-aria/test-utils ``` -Please note that this library uses [@testing-library/react@15](https://www.npmjs.com/package/@testing-library/react) and [@testing-library/user-event](https://www.npmjs.com/package/@testing-library/user-event/v/13.1.5). This means that you need +Please note that this library uses [@testing-library/react@16](https://www.npmjs.com/package/@testing-library/react) and [@testing-library/user-event@14](https://www.npmjs.com/package/@testing-library/user-event). This means that you need to be on React 18+ in order for these utilities to work. ### Setup diff --git a/packages/dev/docs/pages/react-spectrum/testing.mdx b/packages/dev/docs/pages/react-spectrum/testing.mdx index 2bfc2600d20..01b7dd001af 100644 --- a/packages/dev/docs/pages/react-spectrum/testing.mdx +++ b/packages/dev/docs/pages/react-spectrum/testing.mdx @@ -353,7 +353,7 @@ we can make assumptions about the existence of various aria attributes in a comp yarn add --dev @react-spectrum/test-utils ``` -Please note that this library uses [@testing-library/react@15](https://www.npmjs.com/package/@testing-library/react) and [@testing-library/user-event](https://www.npmjs.com/package/@testing-library/user-event/v/13.1.5). This means that you need +Please note that this library uses [@testing-library/react@16](https://www.npmjs.com/package/@testing-library/react) and [@testing-library/user-event@14](https://www.npmjs.com/package/@testing-library/user-event). This means that you need to be on React 18+ in order for these utilities to work. ### Setup diff --git a/packages/dev/test-utils/package.json b/packages/dev/test-utils/package.json index 1bf0df3a768..3d5b8161a57 100644 --- a/packages/dev/test-utils/package.json +++ b/packages/dev/test-utils/package.json @@ -26,7 +26,7 @@ "@swc/helpers": "^0.5.0", "@testing-library/dom": "^10.1.0", "@testing-library/jest-dom": "^5.16.4", - "@testing-library/react": "^15.0.7", + "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.0.0", "jest": "^29.5.0", "resolve": "^1.17.0" diff --git a/packages/react-aria-components/test/AriaMenu.test-util.tsx b/packages/react-aria-components/test/AriaMenu.test-util.tsx index 0c22b6a9ebf..2913168417d 100644 --- a/packages/react-aria-components/test/AriaMenu.test-util.tsx +++ b/packages/react-aria-components/test/AriaMenu.test-util.tsx @@ -621,8 +621,6 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) => expect(submenu).toBeInTheDocument(); await submenuUtil.selectOption({option: submenuUtil.options().filter(item => item.getAttribute('aria-haspopup') == null)[0]}); - // TODO: not ideal, this runAllTimers is only needed for RSPv3, not RAC or S2 - act(() => {jest.runAllTimers();}); expect(menu).not.toBeInTheDocument(); expect(submenu).not.toBeInTheDocument(); expect(document.activeElement).toBe(menuTester.trigger); @@ -639,7 +637,6 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) => expect(submenuTrigger).toHaveAttribute('aria-expanded', 'false'); let submenuUtil = (await menuTester.openSubmenu({submenuTrigger}))!; - act(() => {jest.runAllTimers();}); expect(submenuTrigger).toHaveAttribute('aria-expanded', 'true'); let submenu = submenuUtil.menu; expect(submenu).toBeInTheDocument(); @@ -648,13 +645,11 @@ export const AriaMenuTests = ({renderers, setup, prefix}: AriaMenuTestProps) => expect(nestedSubmenuTrigger).toHaveAttribute('aria-expanded', 'false'); let nestedSubmenuUtil = (await submenuUtil.openSubmenu({submenuTrigger: nestedSubmenuTrigger}))!; - act(() => {jest.runAllTimers();}); expect(nestedSubmenuTrigger).toHaveAttribute('aria-expanded', 'true'); let nestedSubmenu = nestedSubmenuUtil.menu; expect(nestedSubmenu).toBeInTheDocument(); await nestedSubmenuUtil.selectOption({option: nestedSubmenuUtil.options().filter(item => item.getAttribute('aria-haspopup') == null)[0]}); - act(() => {jest.runAllTimers();}); expect(menu).not.toBeInTheDocument(); expect(submenu).not.toBeInTheDocument(); expect(nestedSubmenu).not.toBeInTheDocument(); diff --git a/packages/react-aria-components/test/Menu.test.tsx b/packages/react-aria-components/test/Menu.test.tsx index f308b014715..0ced7893214 100644 --- a/packages/react-aria-components/test/Menu.test.tsx +++ b/packages/react-aria-components/test/Menu.test.tsx @@ -55,7 +55,7 @@ let renderMenu = (menuProps = {}, itemProps = {}) => render( { let user; - let testUtilUser = new User(); + let testUtilUser = new User({advanceTimer: jest.advanceTimersByTime}); beforeAll(() => { user = userEvent.setup({delay: null, pointerMap}); diff --git a/yarn.lock b/yarn.lock index 5cb5a76789c..65ee32e6d16 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6730,9 +6730,10 @@ __metadata: dependencies: "@swc/helpers": "npm:^0.5.0" peerDependencies: - "@testing-library/react": ^15.0.7 + "@testing-library/react": ^16.0.0 "@testing-library/user-event": ^14.0.0 react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 languageName: unknown linkType: soft @@ -7954,7 +7955,7 @@ __metadata: "@react-types/table": "npm:^3.11.0" "@react-types/textfield": "npm:^3.12.0" "@testing-library/dom": "npm:^10.1.0" - "@testing-library/react": "npm:^15.0.7" + "@testing-library/react": "npm:^16.0.0" "@testing-library/user-event": "npm:^14.0.0" csstype: "npm:^3.0.2" jest: "npm:^29.5.0" @@ -8219,7 +8220,7 @@ __metadata: "@swc/helpers": "npm:^0.5.0" "@testing-library/dom": "npm:^10.1.0" "@testing-library/jest-dom": "npm:^5.16.4" - "@testing-library/react": "npm:^15.0.7" + "@testing-library/react": "npm:^16.0.0" "@testing-library/user-event": "npm:^14.0.0" jest: "npm:^29.5.0" resolve: "npm:^1.17.0" @@ -8238,10 +8239,11 @@ __metadata: "@react-aria/test-utils": "npm:1.0.0-alpha.5" "@swc/helpers": "npm:^0.5.0" peerDependencies: - "@testing-library/react": ^15.0.7 + "@testing-library/react": ^16.0.0 "@testing-library/user-event": ^14.0.0 jest: ^29.5.0 react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 languageName: unknown linkType: soft @@ -10862,7 +10864,7 @@ __metadata: languageName: node linkType: hard -"@testing-library/dom@npm:^10.0.0, @testing-library/dom@npm:^10.1.0": +"@testing-library/dom@npm:^10.1.0": version: 10.1.0 resolution: "@testing-library/dom@npm:10.1.0" dependencies: @@ -10911,21 +10913,23 @@ __metadata: languageName: node linkType: hard -"@testing-library/react@npm:^15.0.7": - version: 15.0.7 - resolution: "@testing-library/react@npm:15.0.7" +"@testing-library/react@npm:^16.0.0": + version: 16.2.0 + resolution: "@testing-library/react@npm:16.2.0" dependencies: "@babel/runtime": "npm:^7.12.5" - "@testing-library/dom": "npm:^10.0.0" - "@types/react-dom": "npm:^18.0.0" peerDependencies: - "@types/react": ^18.0.0 - react: ^18.0.0 - react-dom: ^18.0.0 + "@testing-library/dom": ^10.0.0 + "@types/react": ^18.0.0 || ^19.0.0 + "@types/react-dom": ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 peerDependenciesMeta: "@types/react": optional: true - checksum: 10c0/ac8ee8968e81949ecb35f7ee34741c2c043f73dd7fee2247d56f6de6a30de4742af94f25264356863974e54387485b46c9448ecf3f6ca41cf4339011c369f2d4 + "@types/react-dom": + optional: true + checksum: 10c0/7adaedaf237002b42e04a6261d2756074a19cbca0f0c79ba375660f618e123c0ee56256ced00aeb0bb7225ba1a8a81b92b692cca053521a21bb92a8cace1e4c6 languageName: node linkType: hard @@ -29568,7 +29572,7 @@ __metadata: "@tailwindcss/postcss": "npm:^4.0.0" "@testing-library/dom": "npm:^10.1.0" "@testing-library/jest-dom": "npm:^5.16.5" - "@testing-library/react": "npm:^15.0.7" + "@testing-library/react": "npm:^16.0.0" "@testing-library/user-event": "patch:@testing-library/user-event@npm%3A14.6.1#~/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch" "@types/react": "npm:types-react@19.0.0-rc.0" "@types/react-dom": "npm:types-react-dom@19.0.0-rc.0" From c81a23ccd70260c6fbe86ad3e6eb276babd997d5 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Wed, 9 Apr 2025 07:28:58 +1000 Subject: [PATCH 15/25] chore: revert ts update (#8060) --- .../@types-node-npm-20.14.13-41f92d384c.patch | 13 ------- lib/viewTransitions.d.ts | 19 ++++++++++ package.json | 7 ++-- .../dnd/src/useDroppableCollection.ts | 29 ++++++--------- .../grid/src/useGridSelectionAnnouncement.ts | 19 ++++------ .../calendar/stories/Calendar.stories.tsx | 2 +- .../datepicker/stories/DateField.stories.tsx | 4 +-- .../datepicker/stories/DatePicker.stories.tsx | 2 +- .../stories/DateRangePicker.stories.tsx | 2 +- .../form/stories/Form.stories.tsx | 2 +- .../@react-spectrum/s2/src/Breadcrumbs.tsx | 12 +++---- .../@react-spectrum/s2/src/CloseButton.tsx | 2 +- packages/@react-spectrum/s2/src/ComboBox.tsx | 3 +- packages/@react-spectrum/s2/src/Menu.tsx | 7 ++-- .../@react-spectrum/s2/src/NumberField.tsx | 20 ++++++++--- .../s2/src/SegmentedControl.tsx | 23 ++++++------ packages/@react-spectrum/s2/src/Slider.tsx | 7 ++-- packages/@react-spectrum/s2/src/TableView.tsx | 9 ++--- packages/@react-spectrum/s2/src/Tabs.tsx | 14 +++----- .../@react-spectrum/s2/src/TabsPicker.tsx | 4 +-- packages/@react-spectrum/s2/src/TagGroup.tsx | 3 +- .../s2/style/spectrum-theme.ts | 3 +- .../steplist/stories/StepList.stories.tsx | 4 +-- packages/@react-spectrum/tabs/src/Tabs.tsx | 11 ++---- .../tabs/stories/Tabs.stories.tsx | 9 ++--- .../disclosure/src/useDisclosureGroupState.ts | 11 +++--- .../tabs/src/useTabListState.ts | 5 --- .../@react-types/shared/src/selection.d.ts | 2 +- packages/@react-types/tabs/src/index.d.ts | 12 +++---- packages/dev/codemods/package.json | 4 +-- .../dev/docs/pages/react-aria/home/A11y.tsx | 2 +- .../react-aria-components/docs/Checkbox.mdx | 2 +- .../react-aria-components/docs/GridList.mdx | 2 +- packages/react-aria-components/docs/Menu.mdx | 2 +- packages/react-aria-components/docs/Table.mdx | 2 +- .../react-aria-components/docs/TagGroup.mdx | 2 +- packages/react-aria-components/docs/Tree.mdx | 2 +- scripts/extractExamples.mjs | 1 + tsconfig.json | 1 + yarn.lock | 36 +++++-------------- 40 files changed, 132 insertions(+), 184 deletions(-) delete mode 100644 .yarn/patches/@types-node-npm-20.14.13-41f92d384c.patch create mode 100644 lib/viewTransitions.d.ts diff --git a/.yarn/patches/@types-node-npm-20.14.13-41f92d384c.patch b/.yarn/patches/@types-node-npm-20.14.13-41f92d384c.patch deleted file mode 100644 index 7afc48f1b36..00000000000 --- a/.yarn/patches/@types-node-npm-20.14.13-41f92d384c.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/buffer.d.ts b/buffer.d.ts -index 5d6c97d6b5d47fd189f795498aefd6b8d7713b7d..b9a22c4634fa6308006ae17d3527ff3c518a789d 100644 ---- a/buffer.d.ts -+++ b/buffer.d.ts -@@ -629,7 +629,7 @@ declare module "buffer" { - */ - poolSize: number; - } -- interface Buffer extends Uint8Array { -+ interface Buffer extends Uint8Array { - /** - * Writes `string` to `buf` at `offset` according to the character encoding in`encoding`. The `length` parameter is the number of bytes to write. If `buf` did - * not contain enough space to fit the entire string, only part of `string` will be diff --git a/lib/viewTransitions.d.ts b/lib/viewTransitions.d.ts new file mode 100644 index 00000000000..db01d159b6a --- /dev/null +++ b/lib/viewTransitions.d.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +interface Document { + startViewTransition(fn: () => void): ViewTransition; +} + +interface ViewTransition { + ready: Promise; +} diff --git a/package.json b/package.json index ff6b02ab288..b23b0cd7c7a 100644 --- a/package.json +++ b/package.json @@ -206,7 +206,7 @@ "tailwindcss": "^4.0.0", "tailwindcss-animate": "^1.0.7", "tempy": "^0.5.0", - "typescript": "^5.8.2", + "typescript": "^5.5.0", "typescript-eslint": "^8.9.0", "verdaccio": "^6.0.0", "walk-object": "^4.0.0", @@ -234,10 +234,7 @@ "recast": "0.23.6", "ast-types": "0.16.1", "svgo": "^3", - "@testing-library/user-event": "patch:@testing-library/user-event@npm%3A14.6.1#~/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch", - "@types/node@npm:*": "patch:@types/node@npm%3A20.14.13#~/.yarn/patches/@types-node-npm-20.14.13-41f92d384c.patch", - "@types/node@npm:^18.0.0": "patch:@types/node@npm%3A20.14.13#~/.yarn/patches/@types-node-npm-20.14.13-41f92d384c.patch", - "@types/node@npm:>= 8": "patch:@types/node@npm%3A20.14.13#~/.yarn/patches/@types-node-npm-20.14.13-41f92d384c.patch" + "@testing-library/user-event": "patch:@testing-library/user-event@npm%3A14.6.1#~/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch" }, "@parcel/transformer-css": { "cssModules": { diff --git a/packages/@react-aria/dnd/src/useDroppableCollection.ts b/packages/@react-aria/dnd/src/useDroppableCollection.ts index 4145081776b..420d924c7c5 100644 --- a/packages/@react-aria/dnd/src/useDroppableCollection.ts +++ b/packages/@react-aria/dnd/src/useDroppableCollection.ts @@ -258,25 +258,18 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: // inserted item. If selection is disabled, then also show the focus ring so there // is some indication that items were added. if (state.selectionManager.focusedKey === prevFocusedKey) { - let first: Key | null | undefined = newKeys.keys().next().value; - if (first != null) { - let item = state.collection.getItem(first); - - // If this is a cell, focus the parent row. - // eslint-disable-next-line max-depth - if (item?.type === 'cell') { - first = item.parentKey; - } + let first = newKeys.keys().next().value; + let item = state.collection.getItem(first); - // eslint-disable-next-line max-depth - if (first != null) { - state.selectionManager.setFocusedKey(first); - } + // If this is a cell, focus the parent row. + if (item?.type === 'cell') { + first = item.parentKey; + } - // eslint-disable-next-line max-depth - if (state.selectionManager.selectionMode === 'none') { - setInteractionModality('keyboard'); - } + state.selectionManager.setFocusedKey(first); + + if (state.selectionManager.selectionMode === 'none') { + setInteractionModality('keyboard'); } } } else if ( @@ -342,7 +335,7 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: }, 50); }, [localState, defaultOnDrop, ref, updateFocusAfterDrop]); - + useEffect(() => { return () => { if (droppingState.current) { diff --git a/packages/@react-aria/grid/src/useGridSelectionAnnouncement.ts b/packages/@react-aria/grid/src/useGridSelectionAnnouncement.ts index c3d84564c32..122aa695a6b 100644 --- a/packages/@react-aria/grid/src/useGridSelectionAnnouncement.ts +++ b/packages/@react-aria/grid/src/useGridSelectionAnnouncement.ts @@ -61,25 +61,20 @@ export function useGridSelectionAnnouncement(props: GridSelectionAnnouncement let messages: string[] = []; if ((state.selectionManager.selectedKeys.size === 1 && isReplace)) { - let firstKey = state.selectionManager.selectedKeys.keys().next().value; - if (firstKey != null && state.collection.getItem(firstKey)) { - let currentSelectionText = getRowText(firstKey); + if (state.collection.getItem(state.selectionManager.selectedKeys.keys().next().value)) { + let currentSelectionText = getRowText(state.selectionManager.selectedKeys.keys().next().value); if (currentSelectionText) { messages.push(stringFormatter.format('selectedItem', {item: currentSelectionText})); } } } else if (addedKeys.size === 1 && removedKeys.size === 0) { - let firstKey = addedKeys.keys().next().value; - if (firstKey != null) { - let addedText = getRowText(firstKey); - if (addedText) { - messages.push(stringFormatter.format('selectedItem', {item: addedText})); - } + let addedText = getRowText(addedKeys.keys().next().value); + if (addedText) { + messages.push(stringFormatter.format('selectedItem', {item: addedText})); } } else if (removedKeys.size === 1 && addedKeys.size === 0) { - let firstKey = removedKeys.keys().next().value; - if (firstKey != null && state.collection.getItem(firstKey)) { - let removedText = getRowText(firstKey); + if (state.collection.getItem(removedKeys.keys().next().value)) { + let removedText = getRowText(removedKeys.keys().next().value); if (removedText) { messages.push(stringFormatter.format('deselectedItem', {item: removedText})); } diff --git a/packages/@react-spectrum/calendar/stories/Calendar.stories.tsx b/packages/@react-spectrum/calendar/stories/Calendar.stories.tsx index d12df3d13e3..5da987a336d 100644 --- a/packages/@react-spectrum/calendar/stories/Calendar.stories.tsx +++ b/packages/@react-spectrum/calendar/stories/Calendar.stories.tsx @@ -209,7 +209,7 @@ const calendars = [ function Example(props) { let [locale, setLocale] = React.useState(''); - let [calendar, setCalendar] = React.useState(calendars[0].key); + let [calendar, setCalendar] = React.useState(calendars[0].key); let {locale: defaultLocale} = useLocale(); let pref = preferences.find(p => p.locale === locale)!; diff --git a/packages/@react-spectrum/datepicker/stories/DateField.stories.tsx b/packages/@react-spectrum/datepicker/stories/DateField.stories.tsx index 3a495dd5ba6..682e5484224 100644 --- a/packages/@react-spectrum/datepicker/stories/DateField.stories.tsx +++ b/packages/@react-spectrum/datepicker/stories/DateField.stories.tsx @@ -212,7 +212,7 @@ export const IsDateUnavailable: DateFieldStory = { ...Default, args: { isDateUnavailable: (date) => { - return date.compare(new CalendarDate(1980, 1, 1)) >= 0 + return date.compare(new CalendarDate(1980, 1, 1)) >= 0 && date.compare(new CalendarDate(1980, 1, 8)) <= 0; }, errorMessage: 'Date unavailable.', @@ -310,7 +310,7 @@ const calendars = [ function Example(props) { let [locale, setLocale] = React.useState(''); - let [calendar, setCalendar] = React.useState(calendars[0].key); + let [calendar, setCalendar] = React.useState(calendars[0].key); let {locale: defaultLocale} = useLocale(); let pref = preferences.find(p => p.locale === locale); diff --git a/packages/@react-spectrum/datepicker/stories/DatePicker.stories.tsx b/packages/@react-spectrum/datepicker/stories/DatePicker.stories.tsx index f53d6ae8a6f..e51901897ee 100644 --- a/packages/@react-spectrum/datepicker/stories/DatePicker.stories.tsx +++ b/packages/@react-spectrum/datepicker/stories/DatePicker.stories.tsx @@ -338,7 +338,7 @@ const calendars = [ function Example(props) { let [locale, setLocale] = React.useState(''); - let [calendar, setCalendar] = React.useState(calendars[0].key); + let [calendar, setCalendar] = React.useState(calendars[0].key); let {locale: defaultLocale} = useLocale(); let pref = preferences.find(p => p.locale === locale); diff --git a/packages/@react-spectrum/datepicker/stories/DateRangePicker.stories.tsx b/packages/@react-spectrum/datepicker/stories/DateRangePicker.stories.tsx index b71709b0f80..5ac199a8194 100644 --- a/packages/@react-spectrum/datepicker/stories/DateRangePicker.stories.tsx +++ b/packages/@react-spectrum/datepicker/stories/DateRangePicker.stories.tsx @@ -238,7 +238,7 @@ const calendars = [ function Example(props) { let [locale, setLocale] = React.useState(''); - let [calendar, setCalendar] = React.useState(calendars[0].key); + let [calendar, setCalendar] = React.useState(calendars[0].key); let {locale: defaultLocale} = useLocale(); let pref = preferences.find(p => p.locale === locale); diff --git a/packages/@react-spectrum/form/stories/Form.stories.tsx b/packages/@react-spectrum/form/stories/Form.stories.tsx index e154e5efad7..131f5592b4f 100644 --- a/packages/@react-spectrum/form/stories/Form.stories.tsx +++ b/packages/@react-spectrum/form/stories/Form.stories.tsx @@ -482,7 +482,7 @@ function FormWithControls(props: any = {}) { let [firstName, setFirstName] = useState('hello'); let [isHunter, setIsHunter] = useState(true); let [favoritePet, setFavoritePet] = useState('cats'); - let [favoriteColor, setFavoriteColor] = useState('green'); + let [favoriteColor, setFavoriteColor] = useState('green' as Key); let [howIFeel, setHowIFeel] = useState('I feel good, o I feel so good!'); let [birthday, setBirthday] = useState(new CalendarDate(1732, 2, 22)); let [money, setMoney] = useState(50); diff --git a/packages/@react-spectrum/s2/src/Breadcrumbs.tsx b/packages/@react-spectrum/s2/src/Breadcrumbs.tsx index a8f696b0bf9..be0f1cdacf3 100644 --- a/packages/@react-spectrum/s2/src/Breadcrumbs.tsx +++ b/packages/@react-spectrum/s2/src/Breadcrumbs.tsx @@ -20,7 +20,6 @@ import { DefaultCollectionRenderer, HeadingContext, Link, - LinkRenderProps, Provider, Breadcrumbs as RACBreadcrumbs } from 'react-aria-components'; @@ -98,7 +97,7 @@ const wrapper = style({ const InternalBreadcrumbsContext = createContext>>({}); -/** Breadcrumbs show hierarchy and navigational context for a user's location within an application. */ +/** Breadcrumbs show hierarchy and navigational context for a user’s location within an application. */ export const Breadcrumbs = /*#__PURE__*/ (forwardRef as forwardRefType)(function Breadcrumbs(props: BreadcrumbsProps, ref: DOMRef) { [props, ref] = useSpectrumContextProps(props, ref, BreadcrumbsContext); let domRef = useDOMRef(ref); @@ -201,7 +200,7 @@ let HiddenBreadcrumbs = function (props: {listRef: RefObject({ +const breadcrumbStyles = style({ display: 'flex', alignItems: 'center', justifyContent: 'start', @@ -246,7 +245,7 @@ const chevronStyles = style({ } }); -const linkStyles = style({ +const linkStyles = style({ ...focusRing(), borderRadius: 'sm', font: 'control', @@ -256,8 +255,7 @@ const linkStyles = style linkStyles({isFocused, isFocusVisible, isHovered, isDisabled, size, isPressed, isCurrent})}> + className={({isFocused, isFocusVisible, isHovered, isDisabled, isPressed}) => linkStyles({isFocused, isFocusVisible, isHovered, isDisabled, size, isPressed})}> {children} ({ +const styles = style({ ...focusRing(), ...staticColor(), display: 'flex', diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index 99439d91e38..1a755084b22 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -16,7 +16,6 @@ import { ListBoxSection as AriaListBoxSection, PopoverProps as AriaPopoverProps, Button, - ButtonRenderProps, ContextValue, InputContext, ListBox, @@ -96,7 +95,7 @@ export interface ComboBoxProps extends export const ComboBoxContext = createContext>, TextFieldRef>>(null); -const inputButton = style({ +const inputButton = style({ display: 'flex', outlineStyle: 'none', textAlign: 'center', diff --git a/packages/@react-spectrum/s2/src/Menu.tsx b/packages/@react-spectrum/s2/src/Menu.tsx index 9d8bc4b9f4d..8872ce1406c 100644 --- a/packages/@react-spectrum/s2/src/Menu.tsx +++ b/packages/@react-spectrum/s2/src/Menu.tsx @@ -23,7 +23,6 @@ import { SubmenuTriggerProps as AriaSubmenuTriggerProps, ContextValue, DEFAULT_SLOT, - MenuItemRenderProps, Provider, Separator, SeparatorProps @@ -147,7 +146,7 @@ export let sectionHeading = style({ margin: 0 }); -export let menuitem = style & {isFocused: boolean, size: 'S' | 'M' | 'L' | 'XL', isLink?: boolean, hasSubmenu?: boolean, isOpen?: boolean}>({ +export let menuitem = style({ ...focusRing(), boxSizing: 'border-box', borderRadius: 'control', @@ -294,7 +293,7 @@ let value = style({ marginStart: 8 }); -let keyboard = style<{size: 'S' | 'M' | 'L' | 'XL', isDisabled: boolean}>({ +let keyboard = style({ gridArea: 'keyboard', marginStart: 8, font: 'ui', @@ -306,7 +305,7 @@ let keyboard = style<{size: 'S' | 'M' | 'L' | 'XL', isDisabled: boolean}>({ isDisabled: 'GrayText' } }, - backgroundColor: 'gray-25', + background: 'gray-25', unicodeBidi: 'plaintext' }); diff --git a/packages/@react-spectrum/s2/src/NumberField.tsx b/packages/@react-spectrum/s2/src/NumberField.tsx index 970a477b92b..a18f70ac951 100644 --- a/packages/@react-spectrum/s2/src/NumberField.tsx +++ b/packages/@react-spectrum/s2/src/NumberField.tsx @@ -16,7 +16,6 @@ import { NumberField as AriaNumberField, NumberFieldProps as AriaNumberFieldProps, ButtonContext, - ButtonRenderProps, ContextValue, InputContext, Text, @@ -57,7 +56,7 @@ export interface NumberFieldProps extends export const NumberFieldContext = createContext, TextFieldRef>>(null); -const inputButton = style({ +const inputButton = style({ display: 'flex', outlineStyle: 'none', textAlign: 'center', @@ -70,6 +69,9 @@ const inputButton = style - + - +
} diff --git a/packages/@react-spectrum/s2/src/SegmentedControl.tsx b/packages/@react-spectrum/s2/src/SegmentedControl.tsx index d412b098404..f1828b7b92e 100644 --- a/packages/@react-spectrum/s2/src/SegmentedControl.tsx +++ b/packages/@react-spectrum/s2/src/SegmentedControl.tsx @@ -12,7 +12,7 @@ import {AriaLabelingProps, DOMRef, DOMRefValue, FocusableRef, Key} from '@react-types/shared'; import {centerBaseline} from './CenterBaseline'; -import {ContextValue, DEFAULT_SLOT, Provider, TextContext as RACTextContext, SlotProps, ToggleButton, ToggleButtonGroup, ToggleButtonRenderProps, ToggleGroupStateContext} from 'react-aria-components'; +import {ContextValue, DEFAULT_SLOT, Provider, TextContext as RACTextContext, SlotProps, ToggleButton, ToggleButtonGroup, ToggleGroupStateContext} from 'react-aria-components'; import {createContext, forwardRef, ReactNode, RefObject, useCallback, useContext, useRef} from 'react'; import {focusRing, space, style} from '../style' with {type: 'macro'}; import {getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; @@ -62,7 +62,7 @@ const segmentedControl = style({ width: 'fit' }, getAllowedOverrides()); -const controlItem = style({ +const controlItem = style({ ...focusRing(), position: 'relative', display: 'flex', @@ -107,7 +107,7 @@ const controlItem = style({ } }, getAllowedOverrides()); -const slider = style<{isDisabled: boolean}>({ +const slider = style({ backgroundColor: { default: 'gray-25', forcedColors: { @@ -170,17 +170,14 @@ export const SegmentedControl = /*#__PURE__*/ forwardRef(function SegmentedContr if (currentSelectedRef.current) { prevRef.current = currentSelectedRef?.current.getBoundingClientRect(); } - + if (onSelectionChange) { - let firstKey = values.values().next().value; - if (firstKey != null) { - onSelectionChange(firstKey); - } + onSelectionChange(values.values().next().value); } }; return ( - + ]}> {props.children} ); @@ -258,15 +255,15 @@ export const SegmentedControlItem = /*#__PURE__*/ forwardRef(function SegmentedC }, [isSelected, reduceMotion]); return ( - (props.UNSAFE_className || '') + controlItem({...renderProps, isJustified}, props.styles)} > {({isSelected, isPressed, isDisabled}) => ( <> {isSelected &&
} - ({ +export let thumb = style({ ...focusRing(), display: 'inline-block', boxSizing: 'border-box', @@ -272,7 +271,7 @@ const trackStyling = { } } as const; -export let upperTrack = style<{isDisabled?: boolean, trackStyle: 'thin' | 'thick'}>({ +export let upperTrack = style({ ...trackStyling, position: 'absolute', backgroundColor: { @@ -293,7 +292,7 @@ export let upperTrack = style<{isDisabled?: boolean, trackStyle: 'thin' | 'thick } }); -export let filledTrack = style<{isDisabled?: boolean, isEmphasized?: boolean, trackStyle: 'thin' | 'thick'}>({ +export let filledTrack = style({ ...trackStyling, position: 'absolute', backgroundColor: { diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 5ed61e71fad..16db0821306 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -642,13 +642,14 @@ const resizerHandleContainer = style({ } }); -const resizerHandle = style<{isFocusVisible: boolean, isResizing: boolean}>({ +const resizerHandle = style({ backgroundColor: { default: 'gray-300', isFocusVisible: lightDark('informative-900', 'informative-700'), // --spectrum-informative-background-color-default, can't use `informative` because that will use the focusVisible version isResizing: lightDark('informative-900', 'informative-700'), forcedColors: { default: 'Background', + isHovered: 'ButtonBorder', isFocusVisible: 'Highlight', isResizing: 'Highlight' } @@ -786,7 +787,7 @@ function ResizableColumnContents(props: ResizableColumnContentProps) { resizerHandleContainer({resizableDirection, isResizing, isInResizeMode})}> {({isFocusVisible, isResizing}) => ( <> - + {(isFocusVisible || isInResizeMode) && isResizing &&
} )} @@ -796,9 +797,9 @@ function ResizableColumnContents(props: ResizableColumnContentProps) { ); } -function ResizerIndicator({isFocusVisible, isResizing}) { +function ResizerIndicator({isFocusVisible, isResizing, isInResizeMode}) { return ( -
+
); } diff --git a/packages/@react-spectrum/s2/src/Tabs.tsx b/packages/@react-spectrum/s2/src/Tabs.tsx index 81b2a38b3a1..414b7316429 100644 --- a/packages/@react-spectrum/s2/src/Tabs.tsx +++ b/packages/@react-spectrum/s2/src/Tabs.tsx @@ -22,8 +22,7 @@ import { Tab as RACTab, TabList as RACTabList, Tabs as RACTabs, - TabListStateContext, - TabRenderProps + TabListStateContext } from 'react-aria-components'; import {centerBaseline} from './CenterBaseline'; import {Collection, DOMRef, DOMRefValue, Key, Node, Orientation, RefObject} from '@react-types/shared'; @@ -234,7 +233,7 @@ interface TabLineProps { density?: 'compact' | 'regular' } -const selectedIndicator = style<{isDisabled: boolean, orientation?: Orientation}>({ +const selectedIndicator = style({ position: 'absolute', backgroundColor: { default: 'neutral', @@ -321,7 +320,7 @@ function TabLine(props: TabLineProps) { ); } -const tab = style({ +const tab = style({ ...focusRing(), display: 'flex', color: { @@ -521,12 +520,7 @@ let HiddenTabs = function (props: { let TabsMenu = (props: {valueId: string, items: Array>, onSelectionChange: TabsProps['onSelectionChange']} & Omit) => { let {id, items, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, valueId} = props; - let {density, onSelectionChange: _onSelectionChange, selectedKey, isDisabled, disabledKeys, labelBehavior} = useContext(InternalTabsContext); - let onSelectionChange = useCallback((key: Key | null) => { - if (key != null) { - _onSelectionChange?.(key); - } - }, [_onSelectionChange]); + let {density, onSelectionChange, selectedKey, isDisabled, disabledKeys, labelBehavior} = useContext(InternalTabsContext); let state = useContext(TabListStateContext); let allKeysDisabled = useMemo(() => { return isAllTabsDisabled(state?.collection, disabledKeys ? new Set(disabledKeys) : new Set()); diff --git a/packages/@react-spectrum/s2/src/TabsPicker.tsx b/packages/@react-spectrum/s2/src/TabsPicker.tsx index 09987c91455..f417bba8859 100644 --- a/packages/@react-spectrum/s2/src/TabsPicker.tsx +++ b/packages/@react-spectrum/s2/src/TabsPicker.tsx @@ -281,7 +281,7 @@ let _Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(Picker); export {_Picker as Picker}; -const selectedIndicator = style<{isDisabled?: boolean}>({ +const selectedIndicator = style({ backgroundColor: { default: 'neutral', isDisabled: 'disabled', @@ -297,7 +297,7 @@ const selectedIndicator = style<{isDisabled?: boolean}>({ transitionDuration: 130, transitionTimingFunction: 'in-out' }); -function TabLine(props: {isDisabled?: boolean}) { +function TabLine(props) { return
; } diff --git a/packages/@react-spectrum/s2/src/TagGroup.tsx b/packages/@react-spectrum/s2/src/TagGroup.tsx index b2aa19e19be..93fe47d3e94 100644 --- a/packages/@react-spectrum/s2/src/TagGroup.tsx +++ b/packages/@react-spectrum/s2/src/TagGroup.tsx @@ -23,7 +23,6 @@ import { TextContext as RACTextContext, TagList, TagListProps, - TagRenderProps, useLocale, useSlottedContext } from 'react-aria-components'; @@ -438,7 +437,7 @@ function ActionGroup(props) { ); } -const tagStyles = style({ +const tagStyles = style({ ...focusRing(), display: 'inline-flex', boxSizing: 'border-box', diff --git a/packages/@react-spectrum/s2/style/spectrum-theme.ts b/packages/@react-spectrum/s2/style/spectrum-theme.ts index c045b5c976d..38ff6ce7b1a 100644 --- a/packages/@react-spectrum/s2/style/spectrum-theme.ts +++ b/packages/@react-spectrum/s2/style/spectrum-theme.ts @@ -967,8 +967,7 @@ export const style = createTheme({ // eslint-disable-next-line @typescript-eslint/no-unused-vars disableTapHighlight: createArbitraryProperty((_value: true) => ({ '-webkit-tap-highlight-color': 'rgba(0,0,0,0)' - })), - unicodeBidi: ['normal', 'embed', 'bidi-override', 'isolate', 'isolate-override', 'plaintext'] as const + })) }, shorthands: { padding: ['paddingTop', 'paddingBottom', 'paddingStart', 'paddingEnd'] as const, diff --git a/packages/@react-spectrum/steplist/stories/StepList.stories.tsx b/packages/@react-spectrum/steplist/stories/StepList.stories.tsx index e18c57d3e08..a3c2a55ae19 100644 --- a/packages/@react-spectrum/steplist/stories/StepList.stories.tsx +++ b/packages/@react-spectrum/steplist/stories/StepList.stories.tsx @@ -219,8 +219,8 @@ export const ControlledStory: StepListStory = { }; function Controlled(args) { - const [lastCompletedStep, setLastCompletedStep] = useState(args.lastCompletedStep); - const [selectedKey, setSelectedKey] = useState(args.selectedKey); + const [lastCompletedStep, setLastCompletedStep] = useState(args.lastCompletedStep); + const [selectedKey, setSelectedKey] = useState(args.selectedKey); return ( diff --git a/packages/@react-spectrum/tabs/src/Tabs.tsx b/packages/@react-spectrum/tabs/src/Tabs.tsx index e6342c14bb7..9de494d5e1b 100644 --- a/packages/@react-spectrum/tabs/src/Tabs.tsx +++ b/packages/@react-spectrum/tabs/src/Tabs.tsx @@ -377,13 +377,12 @@ function TabPanel(props: TabPanelProps) { ); } -interface TabPickerProps extends Omit, 'children' | 'onSelectionChange'> { +interface TabPickerProps extends Omit, 'children'> { density?: 'compact' | 'regular', isEmphasized?: boolean, state: TabListState, className?: string, - visible: boolean, - onSelectionChange?: (key: Key) => void + visible: boolean } function TabPicker(props: TabPickerProps) { @@ -451,11 +450,7 @@ function TabPicker(props: TabPickerProps) { isDisabled={!visible || isDisabled} selectedKey={state.selectedKey} disabledKeys={state.disabledKeys} - onSelectionChange={key => { - if (key != null) { - state.setSelectedKey(key); - } - }} + onSelectionChange={state.setSelectedKey} UNSAFE_className={classNames(styles, 'spectrum-Tabs-picker')}> {item => {item.rendered}} diff --git a/packages/@react-spectrum/tabs/stories/Tabs.stories.tsx b/packages/@react-spectrum/tabs/stories/Tabs.stories.tsx index ebc359adeea..66b2c374313 100644 --- a/packages/@react-spectrum/tabs/stories/Tabs.stories.tsx +++ b/packages/@react-spectrum/tabs/stories/Tabs.stories.tsx @@ -20,7 +20,7 @@ import Dashboard from '@spectrum-icons/workflow/Dashboard'; import {Item, TabList, TabPanels, Tabs} from '..'; import {Key} from '@react-types/shared'; import {Picker} from '@react-spectrum/picker'; -import React, {ReactNode, useCallback, useState} from 'react'; +import React, {ReactNode, useState} from 'react'; import {RouterProvider} from '@react-aria/utils'; import {SpectrumTabsProps} from '@react-types/tabs'; import {TextField} from '@react-spectrum/textfield'; @@ -907,12 +907,7 @@ let DynamicTabsWithDecoration = (props = {}) => { }; let ControlledSelection = () => { - let [selectedKey, _setSelectedKey] = useState('Tab 1'); - let setSelectedKey = useCallback((key: Key | null) => { - if (key != null) { - _setSelectedKey(key); - } - }, [_setSelectedKey]); + let [selectedKey, setSelectedKey] = useState('Tab 1'); return (
diff --git a/packages/@react-stately/disclosure/src/useDisclosureGroupState.ts b/packages/@react-stately/disclosure/src/useDisclosureGroupState.ts index ef880baa6e9..81d04ae955b 100644 --- a/packages/@react-stately/disclosure/src/useDisclosureGroupState.ts +++ b/packages/@react-stately/disclosure/src/useDisclosureGroupState.ts @@ -33,7 +33,7 @@ export interface DisclosureGroupState { /** Whether all items are disabled. */ readonly isDisabled: boolean, - + /** A set of keys for items that are expanded. */ readonly expandedKeys: Set, @@ -55,14 +55,11 @@ export function useDisclosureGroupState(props: DisclosureGroupProps): Disclosure useMemo(() => props.defaultExpandedKeys ? new Set(props.defaultExpandedKeys) : new Set(), [props.defaultExpandedKeys]), props.onExpandedChange ); - + useEffect(() => { // Ensure only one item is expanded if allowsMultipleExpanded is false. if (!allowsMultipleExpanded && expandedKeys.size > 1) { - let firstKey = expandedKeys.values().next().value; - if (firstKey != null) { - setExpandedKeys(new Set([firstKey])); - } + setExpandedKeys(new Set([expandedKeys.values().next().value])); } }); @@ -83,7 +80,7 @@ export function useDisclosureGroupState(props: DisclosureGroupProps): Disclosure } else { keys = new Set(expandedKeys.has(key) ? [] : [key]); } - + setExpandedKeys(keys); } }; diff --git a/packages/@react-stately/tabs/src/useTabListState.ts b/packages/@react-stately/tabs/src/useTabListState.ts index 116e39b059f..c9bc6c3c23d 100644 --- a/packages/@react-stately/tabs/src/useTabListState.ts +++ b/packages/@react-stately/tabs/src/useTabListState.ts @@ -29,11 +29,6 @@ export interface TabListState extends SingleSelectListState { export function useTabListState(props: TabListStateOptions): TabListState { let state = useSingleSelectListState({ ...props, - onSelectionChange: props.onSelectionChange ? (key => { - if (key != null) { - props.onSelectionChange?.(key); - } - }) : undefined, suppressTextValueWarning: true, defaultSelectedKey: props.defaultSelectedKey ?? findDefaultSelectedKey(props.collection, props.disabledKeys ? new Set(props.disabledKeys) : new Set()) ?? undefined }); diff --git a/packages/@react-types/shared/src/selection.d.ts b/packages/@react-types/shared/src/selection.d.ts index e59aaf3e435..e6b89f6ba39 100644 --- a/packages/@react-types/shared/src/selection.d.ts +++ b/packages/@react-types/shared/src/selection.d.ts @@ -20,7 +20,7 @@ export interface SingleSelection { /** The initial selected key in the collection (uncontrolled). */ defaultSelectedKey?: Key, /** Handler that is called when the selection changes. */ - onSelectionChange?: (key: Key | null) => void + onSelectionChange?: (key: Key) => void } export type SelectionMode = 'none' | 'single' | 'multiple'; diff --git a/packages/@react-types/tabs/src/index.d.ts b/packages/@react-types/tabs/src/index.d.ts index e760d2596a1..ebd96c99934 100644 --- a/packages/@react-types/tabs/src/index.d.ts +++ b/packages/@react-types/tabs/src/index.d.ts @@ -30,14 +30,12 @@ export interface AriaTabProps extends AriaLabelingProps { shouldSelectOnPressUp?: boolean } -export interface TabListProps extends CollectionBase, Omit { +export interface TabListProps extends CollectionBase, Omit { /** * Whether the TabList is disabled. * Shows that a selection exists, but is not available in that circumstance. */ - isDisabled?: boolean, - /** Handler that is called when the selection changes. */ - onSelectionChange?: (key: Key) => void + isDisabled?: boolean } interface AriaTabListBase extends AriaLabelingProps { @@ -57,7 +55,7 @@ export interface AriaTabListProps extends TabListProps, AriaTabListBase, D export interface AriaTabPanelProps extends DOMProps, AriaLabelingProps {} -export interface SpectrumTabsProps extends AriaTabListBase, Omit, DOMProps, StyleProps { +export interface SpectrumTabsProps extends AriaTabListBase, SingleSelection, DOMProps, StyleProps { /** The children of the `` element. Should include `` and `` elements. */ children: ReactNode, /** The item objects for each tab, for dynamic collections. */ @@ -71,9 +69,7 @@ export interface SpectrumTabsProps extends AriaTabListBase, Omit void + density?: 'compact' | 'regular' } export interface SpectrumTabListProps extends DOMProps, StyleProps { diff --git a/packages/dev/codemods/package.json b/packages/dev/codemods/package.json index 4e3ec781c4d..e7e18512004 100644 --- a/packages/dev/codemods/package.json +++ b/packages/dev/codemods/package.json @@ -26,7 +26,7 @@ "@babel/types": "^7.24.5", "@react-spectrum/s2": "^0.7.1", "@react-types/shared": "^3.28.0", - "@types/node": "patch:@types/node@npm%3A20.14.13#~/.yarn/patches/@types-node-npm-20.14.13-41f92d384c.patch", + "@types/node": "^20", "boxen": "^5.1.2", "build": "^0.1.4", "chalk": "^4.0.0", @@ -38,7 +38,7 @@ }, "devDependencies": { "@types/jscodeshift": "^0.11.11", - "typescript": "^5.8.2" + "typescript": "^5.5.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0-rc.1", diff --git a/packages/dev/docs/pages/react-aria/home/A11y.tsx b/packages/dev/docs/pages/react-aria/home/A11y.tsx index d9e9820dfbe..73a14913dd5 100644 --- a/packages/dev/docs/pages/react-aria/home/A11y.tsx +++ b/packages/dev/docs/pages/react-aria/home/A11y.tsx @@ -66,7 +66,7 @@ export function A11y() { let [fingerPos, setFingerPos] = useState(null); let [isOpen, setOpen] = useState(false); let [caption, setCaption] = useState(''); - let [selectedKey, setSelectedKey] = useState('read'); + let [selectedKey, setSelectedKey] = useState('read'); useIntersectionObserver(ref, useCallback(() => { let button: HTMLButtonElement | null = null; let listbox: HTMLElement | null = null; diff --git a/packages/react-aria-components/docs/Checkbox.mdx b/packages/react-aria-components/docs/Checkbox.mdx index 0df45705d92..6fe342d7d97 100644 --- a/packages/react-aria-components/docs/Checkbox.mdx +++ b/packages/react-aria-components/docs/Checkbox.mdx @@ -167,7 +167,7 @@ This example wraps `Checkbox` and all of its children together into a single com ```tsx example export=true import type {CheckboxProps} from 'react-aria-components'; -export function MyCheckbox({children, ...props}: Omit & {children?: React.ReactNode}) { +export function MyCheckbox({children, ...props}: CheckboxProps) { return ( {({isIndeterminate}) => <> diff --git a/packages/react-aria-components/docs/GridList.mdx b/packages/react-aria-components/docs/GridList.mdx index b00bc9796c8..7d5483a0cb5 100644 --- a/packages/react-aria-components/docs/GridList.mdx +++ b/packages/react-aria-components/docs/GridList.mdx @@ -318,7 +318,7 @@ export function MyGridList({children, ...props}: GridListProps ); } -export function MyItem({children, ...props}: Omit & {children?: React.ReactNode}) { +export function MyItem({children, ...props}: GridListItemProps) { let textValue = typeof children === 'string' ? children : undefined; return ( diff --git a/packages/react-aria-components/docs/Menu.mdx b/packages/react-aria-components/docs/Menu.mdx index 1589abefabd..2001d15b9fc 100644 --- a/packages/react-aria-components/docs/Menu.mdx +++ b/packages/react-aria-components/docs/Menu.mdx @@ -227,7 +227,7 @@ function MyMenuButton({label, children, ...props}: MyMenuButto ); } -export function MyItem(props: Omit & {children?: React.ReactNode}) { +export function MyItem(props: MenuItemProps) { let textValue = props.textValue || (typeof props.children === 'string' ? props.children : undefined); return ( & {children?: React.ReactNode}) { +export function MyColumn(props: ColumnProps) { return ( {({allowsSorting, sortDirection}) => <> diff --git a/packages/react-aria-components/docs/TagGroup.mdx b/packages/react-aria-components/docs/TagGroup.mdx index 79d8e5765cb..0868156df51 100644 --- a/packages/react-aria-components/docs/TagGroup.mdx +++ b/packages/react-aria-components/docs/TagGroup.mdx @@ -228,7 +228,7 @@ function MyTagGroup({label, description, errorMessage, items, ); } -function MyTag({children, ...props}: Omit & {children?: React.ReactNode}) { +function MyTag({children, ...props}: TagProps) { let textValue = typeof children === 'string' ? children : undefined; return ( diff --git a/packages/react-aria-components/docs/Tree.mdx b/packages/react-aria-components/docs/Tree.mdx index 8bbcad5db69..7b7c51b4251 100644 --- a/packages/react-aria-components/docs/Tree.mdx +++ b/packages/react-aria-components/docs/Tree.mdx @@ -350,7 +350,7 @@ If you will use a Tree in multiple places in your app, you can wrap all of the p import type {TreeItemContentProps, TreeItemContentRenderProps} from 'react-aria-components'; import {Button} from 'react-aria-components'; -function MyTreeItemContent(props: Omit & {children?: React.ReactNode}) { +function MyTreeItemContent(props: TreeItemContentProps) { return ( {({hasChildItems, selectionBehavior, selectionMode}: TreeItemContentRenderProps) => ( diff --git a/scripts/extractExamples.mjs b/scripts/extractExamples.mjs index 137b3d6f174..1757dc4d6cb 100644 --- a/scripts/extractExamples.mjs +++ b/scripts/extractExamples.mjs @@ -108,6 +108,7 @@ import ReactDOM from 'react-dom/client'; fs.copyFileSync('lib/svg.d.ts', `${distDir}/svg.d.ts`); fs.copyFileSync('lib/css.d.ts', `${distDir}/css.d.ts`); +fs.copyFileSync('lib/viewTransitions.d.ts', `${distDir}/viewTransitions.d.ts`); fs.writeFileSync(`${distDir}/tsconfig.json`, `{ "compilerOptions": { "target": "es2018", diff --git a/tsconfig.json b/tsconfig.json index d7d52a600cf..b05da2d90c7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -46,6 +46,7 @@ "include": [ "packages", "lib/svg.d.ts", + "lib/viewTransitions.d.ts", "lib/css.d.ts" ], "exclude": [ diff --git a/yarn.lock b/yarn.lock index 65ee32e6d16..20f62a679d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7194,7 +7194,7 @@ __metadata: "@react-spectrum/s2": "npm:^0.7.1" "@react-types/shared": "npm:^3.28.0" "@types/jscodeshift": "npm:^0.11.11" - "@types/node": "patch:@types/node@npm%3A20.14.13#~/.yarn/patches/@types-node-npm-20.14.13-41f92d384c.patch" + "@types/node": "npm:^20" boxen: "npm:^5.1.2" build: "npm:^0.1.4" chalk: "npm:^4.0.0" @@ -7202,7 +7202,7 @@ __metadata: jscodeshift: "npm:^0.15.2" recast: "npm:^0.23.9" ts-node: "npm:^10.9.2" - typescript: "npm:^5.8.2" + typescript: "npm:^5.5.0" uuid: "npm:^9.0.1" peerDependencies: react: ^18.0.0 || ^19.0.0-rc.1 @@ -11438,7 +11438,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:20.14.13": +"@types/node@npm:*, @types/node@npm:>= 8, @types/node@npm:^20": version: 20.14.13 resolution: "@types/node@npm:20.14.13" dependencies: @@ -11447,12 +11447,12 @@ __metadata: languageName: node linkType: hard -"@types/node@patch:@types/node@npm%3A20.14.13#~/.yarn/patches/@types-node-npm-20.14.13-41f92d384c.patch": - version: 20.14.13 - resolution: "@types/node@patch:@types/node@npm%3A20.14.13#~/.yarn/patches/@types-node-npm-20.14.13-41f92d384c.patch::version=20.14.13&hash=24fda2" +"@types/node@npm:^18.0.0": + version: 18.19.31 + resolution: "@types/node@npm:18.19.31" dependencies: undici-types: "npm:~5.26.4" - checksum: 10c0/1bbbadf2732c27a8f1bc7b20be41bf7faa50436461c1af6e67398c2003a8bde9893e04f762d55b231ef818d4567ec6bcb6bcdf28748f73a715a11dd7df88e9f5 + checksum: 10c0/bfebae8389220c0188492c82eaf328f4ba15e6e9b4abee33d6bf36d3b13f188c2f53eb695d427feb882fff09834f467405e2ed9be6aeb6ad4705509822d2ea08 languageName: node linkType: hard @@ -29653,7 +29653,7 @@ __metadata: tailwindcss: "npm:^4.0.0" tailwindcss-animate: "npm:^1.0.7" tempy: "npm:^0.5.0" - typescript: "npm:^5.8.2" + typescript: "npm:^5.5.0" typescript-eslint: "npm:^8.9.0" verdaccio: "npm:^6.0.0" walk-object: "npm:^4.0.0" @@ -33599,16 +33599,6 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^5.8.2": - version: 5.8.2 - resolution: "typescript@npm:5.8.2" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10c0/5c4f6fbf1c6389b6928fe7b8fcd5dc73bb2d58cd4e3883f1d774ed5bd83b151cbac6b7ecf11723de56d4676daeba8713894b1e9af56174f2f9780ae7848ec3c6 - languageName: node - linkType: hard - "typescript@patch:typescript@npm%3A^5.5.0#optional!builtin": version: 5.5.2 resolution: "typescript@patch:typescript@npm%3A5.5.2#optional!builtin::version=5.5.2&hash=b45daf" @@ -33619,16 +33609,6 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^5.8.2#optional!builtin": - version: 5.8.2 - resolution: "typescript@patch:typescript@npm%3A5.8.2#optional!builtin::version=5.8.2&hash=b45daf" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10c0/8a6cd29dfb59bd5a978407b93ae0edb530ee9376a5b95a42ad057a6f80ffb0c410489ccd6fe48d1d0dfad6e8adf5d62d3874bbd251f488ae30e11a1ce6dabd28 - languageName: node - linkType: hard - "ua-parser-js@npm:0.7.17": version: 0.7.17 resolution: "ua-parser-js@npm:0.7.17" From 04b9fe1583793171eeab6b4d88dd6a4afa4757ce Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Wed, 9 Apr 2025 11:29:28 -0700 Subject: [PATCH 16/25] fix: add static color to s2 notification badge (#8055) * fix: add static color to s2 notification badge * make opaque * updates * use locale * fix lint --------- Co-authored-by: Robert Snow --- packages/@react-spectrum/s2/src/ActionButton.tsx | 1 + packages/@react-spectrum/s2/src/NotificationBadge.tsx | 10 +++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ActionButton.tsx b/packages/@react-spectrum/s2/src/ActionButton.tsx index 5d34216a225..87e0042cbc1 100644 --- a/packages/@react-spectrum/s2/src/ActionButton.tsx +++ b/packages/@react-spectrum/s2/src/ActionButton.tsx @@ -312,6 +312,7 @@ export const ActionButton = forwardRef(function ActionButton(props: ActionButton styles: style({marginStart: '--iconMargin', flexShrink: 0, order: 0}) }], [NotificationBadgeContext, { + staticColor: staticColor, size: props.size === 'XS' ? undefined : props.size, isDisabled: props.isDisabled, styles: style({position: 'absolute', top: '--badgeTop', insetStart: '[var(--badgePosition)]', marginTop: '[calc((self(height) * -1)/2)]', marginStart: '[calc((self(height) * -1)/2)]'}) diff --git a/packages/@react-spectrum/s2/src/NotificationBadge.tsx b/packages/@react-spectrum/s2/src/NotificationBadge.tsx index 80b5d26da16..961d8f20553 100644 --- a/packages/@react-spectrum/s2/src/NotificationBadge.tsx +++ b/packages/@react-spectrum/s2/src/NotificationBadge.tsx @@ -40,7 +40,8 @@ export interface NotificationBadgeProps extends DOMProps, AriaLabelingProps, Sty } interface NotificationBadgeContextProps extends Partial { - isDisabled?: boolean + isDisabled?: boolean, + staticColor?: 'black' | 'white' | 'auto' } export const NotificationBadgeContext = createContext, DOMRefValue>>(null); @@ -53,6 +54,7 @@ const badge = style({ font: 'control', color: { default: 'white', + isStaticColor: 'auto', forcedColors: 'ButtonText' }, fontSize: { @@ -76,6 +78,7 @@ const badge = style({ alignItems: 'center', backgroundColor: { default: 'accent', + isStaticColor: 'transparent-overlay-1000', forcedColors: 'ButtonFace' }, height: { @@ -102,7 +105,7 @@ const badge = style({ isIndicatorOnly: 'square', isSingleDigit: 'square' }, - width: 'fit', + width: 'max', paddingX: { isDoubleDigit: 'edge-to-text' }, @@ -119,6 +122,7 @@ export const NotificationBadge = forwardRef(function Badge(props: NotificationBa size = 'S', value, isDisabled = false, + staticColor, ...otherProps } = props as NotificationBadgeContextProps; let domRef = useDOMRef(ref); @@ -159,7 +163,7 @@ export const NotificationBadge = forwardRef(function Badge(props: NotificationBa {...filterDOMProps(otherProps, {labelable: true})} role={ariaLabel && 'img'} aria-label={ariaLabel} - className={(props.UNSAFE_className || '') + badge({size, isIndicatorOnly, isSingleDigit, isDoubleDigit, isDisabled}, props.styles)} + className={(props.UNSAFE_className || '') + badge({size, isIndicatorOnly, isSingleDigit, isDoubleDigit, isDisabled, isStaticColor: !!staticColor}, props.styles)} style={props.UNSAFE_style} ref={domRef}> {formattedValue} From d93947ea9edeab5e0831e7f56c66460dfd85839c Mon Sep 17 00:00:00 2001 From: Richard Geraghty Date: Wed, 9 Apr 2025 11:43:32 -0700 Subject: [PATCH 17/25] chore: Latest translations (#8036) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Latest translations * Translation correction * Couple of translation corrections * Remove å from Norwegian string --------- Co-authored-by: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> --- packages/@react-aria/table/intl/es-ES.json | 8 ++++---- packages/@react-spectrum/s2/intl/ar-AE.json | 8 +++++--- packages/@react-spectrum/s2/intl/bg-BG.json | 8 +++++--- packages/@react-spectrum/s2/intl/cs-CZ.json | 10 ++++++---- packages/@react-spectrum/s2/intl/da-DK.json | 12 +++++++----- packages/@react-spectrum/s2/intl/de-DE.json | 10 ++++++---- packages/@react-spectrum/s2/intl/el-GR.json | 8 +++++--- packages/@react-spectrum/s2/intl/es-ES.json | 16 +++++++++------- packages/@react-spectrum/s2/intl/et-EE.json | 8 +++++--- packages/@react-spectrum/s2/intl/fi-FI.json | 10 ++++++---- packages/@react-spectrum/s2/intl/fr-FR.json | 10 ++++++---- packages/@react-spectrum/s2/intl/he-IL.json | 10 ++++++---- packages/@react-spectrum/s2/intl/hr-HR.json | 12 +++++++----- packages/@react-spectrum/s2/intl/hu-HU.json | 8 +++++--- packages/@react-spectrum/s2/intl/it-IT.json | 10 ++++++---- packages/@react-spectrum/s2/intl/ja-JP.json | 10 ++++++---- packages/@react-spectrum/s2/intl/ko-KR.json | 12 +++++++----- packages/@react-spectrum/s2/intl/lt-LT.json | 12 +++++++----- packages/@react-spectrum/s2/intl/lv-LV.json | 12 +++++++----- packages/@react-spectrum/s2/intl/nb-NO.json | 14 ++++++++------ packages/@react-spectrum/s2/intl/nl-NL.json | 8 +++++--- packages/@react-spectrum/s2/intl/pl-PL.json | 12 +++++++----- packages/@react-spectrum/s2/intl/pt-BR.json | 8 +++++--- packages/@react-spectrum/s2/intl/pt-PT.json | 8 +++++--- packages/@react-spectrum/s2/intl/ro-RO.json | 14 ++++++++------ packages/@react-spectrum/s2/intl/ru-RU.json | 12 +++++++----- packages/@react-spectrum/s2/intl/sk-SK.json | 10 ++++++---- packages/@react-spectrum/s2/intl/sl-SI.json | 12 +++++++----- packages/@react-spectrum/s2/intl/sr-SP.json | 10 ++++++---- packages/@react-spectrum/s2/intl/sv-SE.json | 10 ++++++---- packages/@react-spectrum/s2/intl/tr-TR.json | 10 ++++++---- packages/@react-spectrum/s2/intl/uk-UA.json | 10 ++++++---- packages/@react-spectrum/s2/intl/zh-CN.json | 8 +++++--- packages/@react-spectrum/s2/intl/zh-TW.json | 8 +++++--- packages/@react-spectrum/table/intl/es-ES.json | 4 ++-- 35 files changed, 209 insertions(+), 143 deletions(-) diff --git a/packages/@react-aria/table/intl/es-ES.json b/packages/@react-aria/table/intl/es-ES.json index 7fd613fac1f..632b0f551a7 100644 --- a/packages/@react-aria/table/intl/es-ES.json +++ b/packages/@react-aria/table/intl/es-ES.json @@ -1,9 +1,9 @@ { - "ascending": "de subida", - "ascendingSort": "ordenado por columna {columnName} en orden de subida", + "ascending": "ascendente", + "ascendingSort": "ordenado por columna {columnName} en sentido ascendente", "columnSize": "{value} píxeles", - "descending": "de bajada", - "descendingSort": "ordenado por columna {columnName} en orden de bajada", + "descending": "descendente", + "descendingSort": "ordenado por columna {columnName} en orden descendente", "resizerDescription": "Pulse Intro para empezar a redimensionar", "select": "Seleccionar", "selectAll": "Seleccionar todos", diff --git a/packages/@react-spectrum/s2/intl/ar-AE.json b/packages/@react-spectrum/s2/intl/ar-AE.json index 965951fe97c..77712f7e602 100644 --- a/packages/@react-spectrum/s2/intl/ar-AE.json +++ b/packages/@react-spectrum/s2/intl/ar-AE.json @@ -4,6 +4,7 @@ "actionbar.clearSelection": "إزالة التحديد", "actionbar.selected": "{count, plural, =0 {غير محدد} other {# محدد}}", "actionbar.selectedAll": "تم تحديد الكل", + "breadcrumbs.more": "المزيد من العناصر", "button.pending": "قيد الانتظار", "contextualhelp.help": "مساعدة", "contextualhelp.info": "معلومات", @@ -17,6 +18,8 @@ "label.(optional)": "(اختياري)", "label.(required)": "(مطلوب)", "menu.moreActions": "المزيد من الإجراءات", + "notificationbadge.indicatorOnly": "نشاط جديد", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "تحديد…", "slider.maximum": "أقصى", "slider.minimum": "أدنى", @@ -28,6 +31,5 @@ "tag.actions": "الإجراءات", "tag.hideButtonLabel": "إظهار أقل", "tag.noTags": "بدون", - "tag.showAllButtonLabel": "عرض الكل ({tagCount, number})", - "breadcrumbs.more": "المزيد من العناصر" -} \ No newline at end of file + "tag.showAllButtonLabel": "عرض الكل ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/bg-BG.json b/packages/@react-spectrum/s2/intl/bg-BG.json index e8dd799ecb1..9d11b165c36 100644 --- a/packages/@react-spectrum/s2/intl/bg-BG.json +++ b/packages/@react-spectrum/s2/intl/bg-BG.json @@ -4,6 +4,7 @@ "actionbar.clearSelection": "Изчистване на избора", "actionbar.selected": "{count, plural, =0 {Няма избрани} one {# избран} other {# избрани}}", "actionbar.selectedAll": "Всички избрани", + "breadcrumbs.more": "Още елементи", "button.pending": "недовършено", "contextualhelp.help": "Помощ", "contextualhelp.info": "Информация", @@ -17,6 +18,8 @@ "label.(optional)": "(незадължително)", "label.(required)": "(задължително)", "menu.moreActions": "Повече действия", + "notificationbadge.indicatorOnly": "Нова дейност", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Изберете…", "slider.maximum": "Максимум", "slider.minimum": "Минимум", @@ -28,6 +31,5 @@ "tag.actions": "Действия", "tag.hideButtonLabel": "Показване на по-малко", "tag.noTags": "Нито един", - "tag.showAllButtonLabel": "Показване на всички ({tagCount, number})", - "breadcrumbs.more": "Още елементи" -} \ No newline at end of file + "tag.showAllButtonLabel": "Показване на всички ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/cs-CZ.json b/packages/@react-spectrum/s2/intl/cs-CZ.json index 168d538e0b0..63824f66dce 100644 --- a/packages/@react-spectrum/s2/intl/cs-CZ.json +++ b/packages/@react-spectrum/s2/intl/cs-CZ.json @@ -2,8 +2,9 @@ "actionbar.actions": "Akce", "actionbar.actionsAvailable": "Dostupné akce.", "actionbar.clearSelection": "Vymazat výběr", - "actionbar.selected": "Vybráno: { count }", + "actionbar.selected": "{count, plural, =0 {žádné vybrané} other {# vybraných}}", "actionbar.selectedAll": "Vybráno vše", + "breadcrumbs.more": "Další položky", "button.pending": "čeká na vyřízení", "contextualhelp.help": "Nápověda", "contextualhelp.info": "Informace", @@ -17,6 +18,8 @@ "label.(optional)": "(volitelně)", "label.(required)": "(požadováno)", "menu.moreActions": "Další akce", + "notificationbadge.indicatorOnly": "Nová aktivita", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Vybrat…", "slider.maximum": "Maximum", "slider.minimum": "Minimum", @@ -28,6 +31,5 @@ "tag.actions": "Akce", "tag.hideButtonLabel": "Zobrazit méně", "tag.noTags": "Žádný", - "tag.showAllButtonLabel": "Zobrazit vše ({tagCount, number})", - "breadcrumbs.more": "Více položek" -} \ No newline at end of file + "tag.showAllButtonLabel": "Zobrazit vše ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/da-DK.json b/packages/@react-spectrum/s2/intl/da-DK.json index 3db448b2f85..fa1856df85e 100644 --- a/packages/@react-spectrum/s2/intl/da-DK.json +++ b/packages/@react-spectrum/s2/intl/da-DK.json @@ -4,6 +4,7 @@ "actionbar.clearSelection": "Ryd markering", "actionbar.selected": "{count, plural, =0 {Ingen valgt} other {# valgt}}", "actionbar.selectedAll": "Alle valgt", + "breadcrumbs.more": "Flere elementer", "button.pending": "afventende", "contextualhelp.help": "Hjælp", "contextualhelp.info": "Oplysninger", @@ -17,17 +18,18 @@ "label.(optional)": "(valgfrit)", "label.(required)": "(obligatorisk)", "menu.moreActions": "Flere handlinger", + "notificationbadge.indicatorOnly": "Ny aktivitet", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Vælg…", "slider.maximum": "Maksimum", "slider.minimum": "Minimum", - "table.loading": "Indlæser ...", - "table.loadingMore": "Indlæser flere ...", + "table.loading": "Indlæser...", + "table.loadingMore": "Indlæser flere...", "table.resizeColumn": "Tilpas størrelse på kolonne", "table.sortAscending": "Sorter stigende", "table.sortDescending": "Sorter faldende", "tag.actions": "Handlinger", "tag.hideButtonLabel": "Vis mindre", "tag.noTags": "Ingen", - "tag.showAllButtonLabel": "Vis alle ({tagCount, number})", - "breadcrumbs.more": "Flere elementer" -} \ No newline at end of file + "tag.showAllButtonLabel": "Vis alle ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/de-DE.json b/packages/@react-spectrum/s2/intl/de-DE.json index 891e129cb88..4fde5acd104 100644 --- a/packages/@react-spectrum/s2/intl/de-DE.json +++ b/packages/@react-spectrum/s2/intl/de-DE.json @@ -2,8 +2,9 @@ "actionbar.actions": "Aktionen", "actionbar.actionsAvailable": "Aktionen verfügbar.", "actionbar.clearSelection": "Auswahl löschen", - "actionbar.selected": "{count, plural, =0 {Nichts ausgewählt} one {# ausgewählt} other {# ausgewählt}}", + "actionbar.selected": "{count, plural, =0 {Keine ausgewählt} other {# ausgewählt}}", "actionbar.selectedAll": "Alles ausgewählt", + "breadcrumbs.more": "Weitere Elemente", "button.pending": "Ausstehend", "contextualhelp.help": "Hilfe", "contextualhelp.info": "Informationen", @@ -17,6 +18,8 @@ "label.(optional)": "(optional)", "label.(required)": "(erforderlich)", "menu.moreActions": "Mehr Aktionen", + "notificationbadge.indicatorOnly": "Neue Aktivität", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Auswählen…", "slider.maximum": "Maximum", "slider.minimum": "Minimum", @@ -28,6 +31,5 @@ "tag.actions": "Aktionen", "tag.hideButtonLabel": "Weniger zeigen", "tag.noTags": "Keine", - "tag.showAllButtonLabel": "Alle anzeigen ({tagCount, number})", - "breadcrumbs.more": "Weitere Elemente" -} \ No newline at end of file + "tag.showAllButtonLabel": "Alle anzeigen ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/el-GR.json b/packages/@react-spectrum/s2/intl/el-GR.json index bc4699cc74b..e6b87cc1bfe 100644 --- a/packages/@react-spectrum/s2/intl/el-GR.json +++ b/packages/@react-spectrum/s2/intl/el-GR.json @@ -4,6 +4,7 @@ "actionbar.clearSelection": "Εκκαθάριση επιλογής", "actionbar.selected": "{count, plural, =0 {Δεν επιλέχθηκε κανένα} one {# επιλεγμένο} other {# επιλεγμένα}}", "actionbar.selectedAll": "Επιλέχθηκαν όλα", + "breadcrumbs.more": "Περισσότερα στοιχεία", "button.pending": "σε εκκρεμότητα", "contextualhelp.help": "Βοήθεια", "contextualhelp.info": "Πληροφορίες", @@ -17,6 +18,8 @@ "label.(optional)": "(προαιρετικό)", "label.(required)": "(απαιτείται)", "menu.moreActions": "Περισσότερες ενέργειες", + "notificationbadge.indicatorOnly": "Νέα δραστηριότητα", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Επιλογή…", "slider.maximum": "Μέγιστο", "slider.minimum": "Ελάχιστο", @@ -28,6 +31,5 @@ "tag.actions": "Ενέργειες", "tag.hideButtonLabel": "Εμφάνιση λιγότερων", "tag.noTags": "Κανένα", - "tag.showAllButtonLabel": "Εμφάνιση όλων ({tagCount, number})", - "breadcrumbs.more": "Περισσότερα στοιχεία" -} \ No newline at end of file + "tag.showAllButtonLabel": "Εμφάνιση όλων ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/es-ES.json b/packages/@react-spectrum/s2/intl/es-ES.json index 06f3d9b3ea4..22652deb2df 100644 --- a/packages/@react-spectrum/s2/intl/es-ES.json +++ b/packages/@react-spectrum/s2/intl/es-ES.json @@ -2,8 +2,9 @@ "actionbar.actions": "Acciones", "actionbar.actionsAvailable": "Acciones disponibles.", "actionbar.clearSelection": "Borrar selección", - "actionbar.selected": "{count, plural, =0 {Nada seleccionado} one {# seleccionado} other {# seleccionados}}", - "actionbar.selectedAll": "Todo seleccionado", + "actionbar.selected": "{count, plural, =0 {Ninguno seleccionado} other {# seleccionados}}", + "actionbar.selectedAll": "Todos seleccionados", + "breadcrumbs.more": "Más elementos", "button.pending": "pendiente", "contextualhelp.help": "Ayuda", "contextualhelp.info": "Información", @@ -17,17 +18,18 @@ "label.(optional)": "(opcional)", "label.(required)": "(obligatorio)", "menu.moreActions": "Más acciones", + "notificationbadge.indicatorOnly": "Nueva actividad", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Seleccione…", "slider.maximum": "Máximo", "slider.minimum": "Mínimo", "table.loading": "Cargando…", "table.loadingMore": "Cargando más…", "table.resizeColumn": "Cambiar el tamaño de la columna", - "table.sortAscending": "Orden de subida", - "table.sortDescending": "Orden de bajada", + "table.sortAscending": "Orden ascendente", + "table.sortDescending": "Orden descendente", "tag.actions": "Acciones", "tag.hideButtonLabel": "Mostrar menos", "tag.noTags": "Ninguno", - "tag.showAllButtonLabel": "Mostrar todo ({tagCount, number})", - "breadcrumbs.more": "Más elementos" -} \ No newline at end of file + "tag.showAllButtonLabel": "Mostrar todo ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/et-EE.json b/packages/@react-spectrum/s2/intl/et-EE.json index 960b34f573c..de4d84e9ab4 100644 --- a/packages/@react-spectrum/s2/intl/et-EE.json +++ b/packages/@react-spectrum/s2/intl/et-EE.json @@ -4,6 +4,7 @@ "actionbar.clearSelection": "Puhasta valik", "actionbar.selected": "{count, plural, =0 {Pole valitud} other {# valitud}}", "actionbar.selectedAll": "Kõik valitud", + "breadcrumbs.more": "Veel üksusi", "button.pending": "ootel", "contextualhelp.help": "Spikker", "contextualhelp.info": "Teave", @@ -17,6 +18,8 @@ "label.(optional)": "(valikuline)", "label.(required)": "(nõutav)", "menu.moreActions": "Veel toiminguid", + "notificationbadge.indicatorOnly": "Uus tegevus", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Valige…", "slider.maximum": "Maksimaalne", "slider.minimum": "Minimaalne", @@ -28,6 +31,5 @@ "tag.actions": "Toimingud", "tag.hideButtonLabel": "Kuva vähem", "tag.noTags": "Puudub", - "tag.showAllButtonLabel": "Kuva kõik ({tagCount, number})", - "breadcrumbs.more": "Veel üksusi" -} \ No newline at end of file + "tag.showAllButtonLabel": "Kuva kõik ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/fi-FI.json b/packages/@react-spectrum/s2/intl/fi-FI.json index de86d5df965..524d1aa6793 100644 --- a/packages/@react-spectrum/s2/intl/fi-FI.json +++ b/packages/@react-spectrum/s2/intl/fi-FI.json @@ -2,8 +2,9 @@ "actionbar.actions": "Toiminnot", "actionbar.actionsAvailable": "Toiminnot käytettävissä.", "actionbar.clearSelection": "Poista valinta", - "actionbar.selected": "{count, plural, =0 {Ei mitään valittu} other {# valittu}}", + "actionbar.selected": "{count, plural, =0 {Ei yhtään valittu} other {# valittu}}", "actionbar.selectedAll": "Kaikki valittu", + "breadcrumbs.more": "Lisää kohteita", "button.pending": "odottaa", "contextualhelp.help": "Ohje", "contextualhelp.info": "Tiedot", @@ -17,6 +18,8 @@ "label.(optional)": "(valinnainen)", "label.(required)": "(pakollinen)", "menu.moreActions": "Lisää toimintoja", + "notificationbadge.indicatorOnly": "Uusi toiminta", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Valitse…", "slider.maximum": "Maksimi", "slider.minimum": "Minimi", @@ -28,6 +31,5 @@ "tag.actions": "Toiminnot", "tag.hideButtonLabel": "Näytä vähemmän", "tag.noTags": "Ei mitään", - "tag.showAllButtonLabel": "Näytä kaikki ({tagCount, number})", - "breadcrumbs.more": "Lisää kohteita" -} \ No newline at end of file + "tag.showAllButtonLabel": "Näytä kaikki ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/fr-FR.json b/packages/@react-spectrum/s2/intl/fr-FR.json index c897ee90022..f240c7235d9 100644 --- a/packages/@react-spectrum/s2/intl/fr-FR.json +++ b/packages/@react-spectrum/s2/intl/fr-FR.json @@ -2,8 +2,9 @@ "actionbar.actions": "Actions", "actionbar.actionsAvailable": "Actions disponibles.", "actionbar.clearSelection": "Supprimer la sélection", - "actionbar.selected": "{count, plural, =0 {Aucun élément sélectionné} one {# sélectionné} other {# sélectionnés}}", + "actionbar.selected": "{count, plural, =0 {Aucun élément sélectionné} other {# sélectionnés}}", "actionbar.selectedAll": "Toute la sélection", + "breadcrumbs.more": "Plus d’éléments", "button.pending": "En attente", "contextualhelp.help": "Aide", "contextualhelp.info": "Informations", @@ -17,6 +18,8 @@ "label.(optional)": "(facultatif)", "label.(required)": "(requis)", "menu.moreActions": "Autres actions", + "notificationbadge.indicatorOnly": "Nouvelle activité", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Sélectionner…", "slider.maximum": "Maximum", "slider.minimum": "Minimum", @@ -28,6 +31,5 @@ "tag.actions": "Actions", "tag.hideButtonLabel": "Afficher moins", "tag.noTags": "Aucun", - "tag.showAllButtonLabel": "Tout afficher ({tagCount, number})", - "breadcrumbs.more": "Plus d'éléments" -} \ No newline at end of file + "tag.showAllButtonLabel": "Tout afficher ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/he-IL.json b/packages/@react-spectrum/s2/intl/he-IL.json index b433b945340..fa96f1706db 100644 --- a/packages/@react-spectrum/s2/intl/he-IL.json +++ b/packages/@react-spectrum/s2/intl/he-IL.json @@ -2,8 +2,9 @@ "actionbar.actions": "פעולות", "actionbar.actionsAvailable": "פעולות זמינות.", "actionbar.clearSelection": "נקה בחירה", - "actionbar.selected": "{count, plural, =0 {לא בוצעה בחירה} one { # בחר} other {# נבחרו}}\",", + "actionbar.selected": "{count, plural, =0 {לא נבחר} other {# נבחר}}", "actionbar.selectedAll": "כל הפריטים שנבחרו", + "breadcrumbs.more": "פריטים נוספים", "button.pending": "ממתין ל", "contextualhelp.help": "עזרה", "contextualhelp.info": "מידע", @@ -17,6 +18,8 @@ "label.(optional)": "(אופציונלי)", "label.(required)": "(נדרש)", "menu.moreActions": "פעולות נוספות", + "notificationbadge.indicatorOnly": "פעילות חדשה", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "בחר…", "slider.maximum": "מקסימום", "slider.minimum": "מינימום", @@ -28,6 +31,5 @@ "tag.actions": "פעולות", "tag.hideButtonLabel": "הצג פחות", "tag.noTags": "ללא", - "tag.showAllButtonLabel": "הצג הכל ({tagCount, number})", - "breadcrumbs.more": "פריטים נוספים" -} \ No newline at end of file + "tag.showAllButtonLabel": "הצג הכל ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/hr-HR.json b/packages/@react-spectrum/s2/intl/hr-HR.json index 4b3ed04d6f1..598c7dde926 100644 --- a/packages/@react-spectrum/s2/intl/hr-HR.json +++ b/packages/@react-spectrum/s2/intl/hr-HR.json @@ -2,8 +2,9 @@ "actionbar.actions": "Radnje", "actionbar.actionsAvailable": "Dostupne radnje.", "actionbar.clearSelection": "Poništi odabir", - "actionbar.selected": "Odabrano: { count }", - "actionbar.selectedAll": "Sve je odabrano", + "actionbar.selected": "{count, plural, =0 {ništa nije odabrano} other {# odabrano}}", + "actionbar.selectedAll": "Sve odabrano", + "breadcrumbs.more": "Više stavki", "button.pending": "u tijeku", "contextualhelp.help": "Pomoć", "contextualhelp.info": "Informacije", @@ -17,6 +18,8 @@ "label.(optional)": "(opcionalno)", "label.(required)": "(obvezno)", "menu.moreActions": "Dodatne radnje", + "notificationbadge.indicatorOnly": "Nova aktivnost", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Odaberite…", "slider.maximum": "Najviše", "slider.minimum": "Najmanje", @@ -28,6 +31,5 @@ "tag.actions": "Radnje", "tag.hideButtonLabel": "Prikaži manje", "tag.noTags": "Nema", - "tag.showAllButtonLabel": "Prikaži sve ({tagCount, number})", - "breadcrumbs.more": "Više stavki" -} \ No newline at end of file + "tag.showAllButtonLabel": "Prikaži sve ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/hu-HU.json b/packages/@react-spectrum/s2/intl/hu-HU.json index 735c4a134e7..f4482de2764 100644 --- a/packages/@react-spectrum/s2/intl/hu-HU.json +++ b/packages/@react-spectrum/s2/intl/hu-HU.json @@ -4,6 +4,7 @@ "actionbar.clearSelection": "Kijelölés törlése", "actionbar.selected": "{count, plural, =0 {Egy sincs kijelölve} other {# kijelölve}}", "actionbar.selectedAll": "Mind kijelölve", + "breadcrumbs.more": "További elemek", "button.pending": "függőben levő", "contextualhelp.help": "Súgó", "contextualhelp.info": "Információ", @@ -17,6 +18,8 @@ "label.(optional)": "(opcionális)", "label.(required)": "(kötelező)", "menu.moreActions": "További lehetőségek", + "notificationbadge.indicatorOnly": "Új tevékenység", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Kiválasztás…", "slider.maximum": "Maximum", "slider.minimum": "Minimum", @@ -28,6 +31,5 @@ "tag.actions": "Műveletek", "tag.hideButtonLabel": "Mutass kevesebbet", "tag.noTags": "Egyik sem", - "tag.showAllButtonLabel": "Az összes megjelenítése ({tagCount, number})", - "breadcrumbs.more": "További elemek" -} \ No newline at end of file + "tag.showAllButtonLabel": "Az összes megjelenítése ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/it-IT.json b/packages/@react-spectrum/s2/intl/it-IT.json index 65e94d807cc..0bb4798489e 100644 --- a/packages/@react-spectrum/s2/intl/it-IT.json +++ b/packages/@react-spectrum/s2/intl/it-IT.json @@ -2,8 +2,9 @@ "actionbar.actions": "Azioni", "actionbar.actionsAvailable": "Azioni disponibili.", "actionbar.clearSelection": "Annulla selezione", - "actionbar.selected": "{count, plural, =0 {Nessuno selezionato} one {# selezionato} other {# selezionati}}", + "actionbar.selected": "{count, plural, =0 {Nessuno selezionato} other {# selezionati}}", "actionbar.selectedAll": "Tutti selezionati", + "breadcrumbs.more": "Altri elementi", "button.pending": "in sospeso", "contextualhelp.help": "Aiuto", "contextualhelp.info": "Informazioni", @@ -17,6 +18,8 @@ "label.(optional)": "(facoltativo)", "label.(required)": "(obbligatorio)", "menu.moreActions": "Altre azioni", + "notificationbadge.indicatorOnly": "Nuova attività", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Seleziona…", "slider.maximum": "Massimo", "slider.minimum": "Minimo", @@ -28,6 +31,5 @@ "tag.actions": "Azioni", "tag.hideButtonLabel": "Mostra meno", "tag.noTags": "Nessuno", - "tag.showAllButtonLabel": "Mostra tutto ({tagCount, number})", - "breadcrumbs.more": "Altri elementi" -} \ No newline at end of file + "tag.showAllButtonLabel": "Mostra tutto ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/ja-JP.json b/packages/@react-spectrum/s2/intl/ja-JP.json index 6d404d68678..6fc53158f91 100644 --- a/packages/@react-spectrum/s2/intl/ja-JP.json +++ b/packages/@react-spectrum/s2/intl/ja-JP.json @@ -2,8 +2,9 @@ "actionbar.actions": "アクション", "actionbar.actionsAvailable": "アクションを利用できます。", "actionbar.clearSelection": "選択をクリア", - "actionbar.selected": "{count, plural, =0 {選択されていません} other {# 個を選択しました}}", + "actionbar.selected": "{count, plural, =0 {選択されていません} other {# 個を選択済み}}", "actionbar.selectedAll": "すべてを選択", + "breadcrumbs.more": "その他の項目", "button.pending": "保留", "contextualhelp.help": "ヘルプ", "contextualhelp.info": "情報", @@ -17,6 +18,8 @@ "label.(optional)": "(オプション)", "label.(required)": "(必須)", "menu.moreActions": "その他のアクション", + "notificationbadge.indicatorOnly": "新規アクティビティ", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "選択…", "slider.maximum": "最大", "slider.minimum": "最小", @@ -28,6 +31,5 @@ "tag.actions": "アクション", "tag.hideButtonLabel": "表示を減らす", "tag.noTags": "なし", - "tag.showAllButtonLabel": "すべての ({tagCount, number}) を表示", - "breadcrumbs.more": "項目を追加" -} \ No newline at end of file + "tag.showAllButtonLabel": "すべての ({tagCount, number}) を表示" +} diff --git a/packages/@react-spectrum/s2/intl/ko-KR.json b/packages/@react-spectrum/s2/intl/ko-KR.json index df6c1f748bd..b49a62cdeb6 100644 --- a/packages/@react-spectrum/s2/intl/ko-KR.json +++ b/packages/@react-spectrum/s2/intl/ko-KR.json @@ -4,6 +4,7 @@ "actionbar.clearSelection": "선택 항목 지우기", "actionbar.selected": "{count, plural, =0 {선택된 항목 없음} other {#개 선택됨}}", "actionbar.selectedAll": "모두 선택됨", + "breadcrumbs.more": "기타 항목", "button.pending": "보류 중", "contextualhelp.help": "도움말", "contextualhelp.info": "정보", @@ -17,17 +18,18 @@ "label.(optional)": "(선택 사항)", "label.(required)": "(필수 사항)", "menu.moreActions": "기타 액션", + "notificationbadge.indicatorOnly": "새로운 활동", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "선택…", "slider.maximum": "최대", "slider.minimum": "최소", - "table.loading": "로드 중", - "table.loadingMore": "추가 로드 중", + "table.loading": "로드 중…", + "table.loadingMore": "추가 로드 중…", "table.resizeColumn": "열 크기 조정", "table.sortAscending": "오름차순 정렬", "table.sortDescending": "내림차순 정렬", "tag.actions": "액션", "tag.hideButtonLabel": "간단히 표시", "tag.noTags": "없음", - "tag.showAllButtonLabel": "모두 표시 ({tagCount, number})", - "breadcrumbs.more": "항목 더 보기" -} \ No newline at end of file + "tag.showAllButtonLabel": "모두 표시 ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/lt-LT.json b/packages/@react-spectrum/s2/intl/lt-LT.json index 371d1697f59..51072f29138 100644 --- a/packages/@react-spectrum/s2/intl/lt-LT.json +++ b/packages/@react-spectrum/s2/intl/lt-LT.json @@ -4,6 +4,7 @@ "actionbar.clearSelection": "Išvalyti pasirinkimą", "actionbar.selected": "Pasirinkta: {count}", "actionbar.selectedAll": "Pasirinkta viskas", + "breadcrumbs.more": "Daugiau elementų", "button.pending": "laukiama", "contextualhelp.help": "Žinynas", "contextualhelp.info": "Informacija", @@ -17,17 +18,18 @@ "label.(optional)": "(pasirenkama)", "label.(required)": "(privaloma)", "menu.moreActions": "Daugiau veiksmų", + "notificationbadge.indicatorOnly": "Nauja veikla", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Pasirinkite…", "slider.maximum": "Daugiausia", "slider.minimum": "Mažiausia", - "table.loading": "Įkeliama...", - "table.loadingMore": "Įkeliama daugiau...", + "table.loading": "Įkeliama…", + "table.loadingMore": "Įkeliama daugiau…", "table.resizeColumn": "Keisti stulpelio dydį", "table.sortAscending": "Rikiuoti didėjimo tvarka", "table.sortDescending": "Rikiuoti mažėjimo tvarka", "tag.actions": "Veiksmai", "tag.hideButtonLabel": "Rodyti mažiau", "tag.noTags": "Nėra", - "tag.showAllButtonLabel": "Rodyti viską ({tagCount, number})", - "breadcrumbs.more": "Daugiau elementų" -} \ No newline at end of file + "tag.showAllButtonLabel": "Rodyti viską ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/lv-LV.json b/packages/@react-spectrum/s2/intl/lv-LV.json index b634cbf5a57..93dd8ddde74 100644 --- a/packages/@react-spectrum/s2/intl/lv-LV.json +++ b/packages/@react-spectrum/s2/intl/lv-LV.json @@ -2,8 +2,9 @@ "actionbar.actions": "Darbības", "actionbar.actionsAvailable": "Pieejamas darbības.", "actionbar.clearSelection": "Notīrīt atlasi", - "actionbar.selected": "{count, plural, =0 {Nav atlasīts nekas} other {Atlasīts(-i): #}}", + "actionbar.selected": "{count, plural, =0 {Nav atlasīts} other {# atlasīti vienumi}}", "actionbar.selectedAll": "Atlasīts viss", + "breadcrumbs.more": "Vairāk vienumu", "button.pending": "gaida", "contextualhelp.help": "Palīdzība", "contextualhelp.info": "Informācija", @@ -17,10 +18,12 @@ "label.(optional)": "(neobligāti)", "label.(required)": "(obligāti)", "menu.moreActions": "Citas darbības", + "notificationbadge.indicatorOnly": "Jauna aktivitāte", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Izvēlēties…", "slider.maximum": "Maksimālā vērtība", "slider.minimum": "Minimālā vērtība", - "table.loading": "Notiek ielāde...", + "table.loading": "Notiek ielāde…", "table.loadingMore": "Tiek ielādēts vēl...", "table.resizeColumn": "Mainīt kolonnas lielumu", "table.sortAscending": "Kārtot augošā secībā", @@ -28,6 +31,5 @@ "tag.actions": "Darbības", "tag.hideButtonLabel": "Rādīt mazāk", "tag.noTags": "Nav", - "tag.showAllButtonLabel": "Rādīt visu ({tagCount, number})", - "breadcrumbs.more": "Vairāk vienumu" -} \ No newline at end of file + "tag.showAllButtonLabel": "Rādīt visu ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/nb-NO.json b/packages/@react-spectrum/s2/intl/nb-NO.json index 1088d9f5817..b4584af71c1 100644 --- a/packages/@react-spectrum/s2/intl/nb-NO.json +++ b/packages/@react-spectrum/s2/intl/nb-NO.json @@ -2,8 +2,9 @@ "actionbar.actions": "Handlinger", "actionbar.actionsAvailable": "Tilgjengelige handlinger.", "actionbar.clearSelection": "Tøm utvalg", - "actionbar.selected": "Valde element: {count}", + "actionbar.selected": "{count, plural, =0 {Ingen valgt} other {# valgt}}", "actionbar.selectedAll": "Alle er valgt", + "breadcrumbs.more": "Flere elementer", "button.pending": "avventer", "contextualhelp.help": "Hjelp", "contextualhelp.info": "Informasjon", @@ -17,17 +18,18 @@ "label.(optional)": "(valgfritt)", "label.(required)": "(obligatorisk)", "menu.moreActions": "Flere handlinger", + "notificationbadge.indicatorOnly": "Ny aktivitet", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Velg …", "slider.maximum": "Maksimum", "slider.minimum": "Minimum", - "table.loading": "Laster inn ...", - "table.loadingMore": "Laster inn flere ...", + "table.loading": "Laster inn...", + "table.loadingMore": "Laster inn flere...", "table.resizeColumn": "Endre størrelse på kolonne", "table.sortAscending": "Sorter stigende", "table.sortDescending": "Sorter synkende", "tag.actions": "Handlinger", "tag.hideButtonLabel": "Vis mindre", "tag.noTags": "Ingen", - "tag.showAllButtonLabel": "Vis alle ({tagCount, number})", - "breadcrumbs.more": "Flere elementer" -} \ No newline at end of file + "tag.showAllButtonLabel": "Vis alle ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/nl-NL.json b/packages/@react-spectrum/s2/intl/nl-NL.json index c06c9597893..d43e2ea5915 100644 --- a/packages/@react-spectrum/s2/intl/nl-NL.json +++ b/packages/@react-spectrum/s2/intl/nl-NL.json @@ -4,6 +4,7 @@ "actionbar.clearSelection": "Selectie wissen", "actionbar.selected": "{count, plural, =0 {Niets geselecteerd} other {# geselecteerd}}", "actionbar.selectedAll": "Alles geselecteerd", + "breadcrumbs.more": "Meer items", "button.pending": "in behandeling", "contextualhelp.help": "Help", "contextualhelp.info": "Informatie", @@ -17,6 +18,8 @@ "label.(optional)": "(optioneel)", "label.(required)": "(vereist)", "menu.moreActions": "Meer handelingen", + "notificationbadge.indicatorOnly": "Nieuwe activiteit", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Selecteren…", "slider.maximum": "Maximum", "slider.minimum": "Minimum", @@ -28,6 +31,5 @@ "tag.actions": "Acties", "tag.hideButtonLabel": "Minder weergeven", "tag.noTags": "Geen", - "tag.showAllButtonLabel": "Alles tonen ({tagCount, number})", - "breadcrumbs.more": "Meer items" -} \ No newline at end of file + "tag.showAllButtonLabel": "Alles tonen ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/pl-PL.json b/packages/@react-spectrum/s2/intl/pl-PL.json index 535652c37f7..a93875589eb 100644 --- a/packages/@react-spectrum/s2/intl/pl-PL.json +++ b/packages/@react-spectrum/s2/intl/pl-PL.json @@ -2,8 +2,9 @@ "actionbar.actions": "Działania", "actionbar.actionsAvailable": "Dostępne działania.", "actionbar.clearSelection": "Wyczyść zaznaczenie", - "actionbar.selected": "Zaznaczono: {count}", + "actionbar.selected": "{count, plural, =0 {nie wybrano} other {# wybrano}}", "actionbar.selectedAll": "Wszystkie zaznaczone", + "breadcrumbs.more": "Więcej elementów", "button.pending": "oczekujące", "contextualhelp.help": "Pomoc", "contextualhelp.info": "Informacja", @@ -17,10 +18,12 @@ "label.(optional)": "(opcjonalne)", "label.(required)": "(wymagane)", "menu.moreActions": "Więcej akcji", + "notificationbadge.indicatorOnly": "Nowa aktywność", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Zaznacz…", "slider.maximum": "Maksimum", "slider.minimum": "Minimum", - "table.loading": "Ładowanie...", + "table.loading": "Wczytywanie...", "table.loadingMore": "Wczytywanie większej liczby...", "table.resizeColumn": "Zmień rozmiar kolumny", "table.sortAscending": "Sortuj rosnąco", @@ -28,6 +31,5 @@ "tag.actions": "Działania", "tag.hideButtonLabel": "Wyświetl mniej", "tag.noTags": "Brak", - "tag.showAllButtonLabel": "Pokaż wszystko ({tagCount, number})", - "breadcrumbs.more": "Więcej elementów" -} \ No newline at end of file + "tag.showAllButtonLabel": "Pokaż wszystko ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/pt-BR.json b/packages/@react-spectrum/s2/intl/pt-BR.json index 1b08dae1153..db7049d24ee 100644 --- a/packages/@react-spectrum/s2/intl/pt-BR.json +++ b/packages/@react-spectrum/s2/intl/pt-BR.json @@ -4,6 +4,7 @@ "actionbar.clearSelection": "Limpar seleção", "actionbar.selected": "{count, plural, =0 {Nenhum selecionado} one {# selecionado} other {# selecionados}}", "actionbar.selectedAll": "Todos selecionados", + "breadcrumbs.more": "Mais itens", "button.pending": "pendente", "contextualhelp.help": "Ajuda", "contextualhelp.info": "Informações", @@ -17,6 +18,8 @@ "label.(optional)": "(opcional)", "label.(required)": "(obrigatório)", "menu.moreActions": "Mais ações", + "notificationbadge.indicatorOnly": "Nova atividade", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Selecionar…", "slider.maximum": "Máximo", "slider.minimum": "Mínimo", @@ -28,6 +31,5 @@ "tag.actions": "Ações", "tag.hideButtonLabel": "Mostrar menos", "tag.noTags": "Nenhum", - "tag.showAllButtonLabel": "Mostrar tudo ({tagCount, number})", - "breadcrumbs.more": "Mais itens" -} \ No newline at end of file + "tag.showAllButtonLabel": "Mostrar tudo ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/pt-PT.json b/packages/@react-spectrum/s2/intl/pt-PT.json index 9d8c3f24aa8..f411c0a0b3f 100644 --- a/packages/@react-spectrum/s2/intl/pt-PT.json +++ b/packages/@react-spectrum/s2/intl/pt-PT.json @@ -4,6 +4,7 @@ "actionbar.clearSelection": "Limpar seleção", "actionbar.selected": "{count, plural, =0 {Nenhum selecionado} one {# selecionado} other {# selecionados}}", "actionbar.selectedAll": "Tudo selecionado", + "breadcrumbs.more": "Mais artigos", "button.pending": "pendente", "contextualhelp.help": "Ajuda", "contextualhelp.info": "Informação", @@ -17,6 +18,8 @@ "label.(optional)": "(opcional)", "label.(required)": "(obrigatório)", "menu.moreActions": "Mais ações", + "notificationbadge.indicatorOnly": "Nova atividade", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Selecionar…", "slider.maximum": "Máximo", "slider.minimum": "Mínimo", @@ -28,6 +31,5 @@ "tag.actions": "Ações", "tag.hideButtonLabel": "Mostrar menos", "tag.noTags": "Nenhum", - "tag.showAllButtonLabel": "Mostrar tudo ({tagCount, number})", - "breadcrumbs.more": "Mais artigos" -} \ No newline at end of file + "tag.showAllButtonLabel": "Mostrar tudo ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/ro-RO.json b/packages/@react-spectrum/s2/intl/ro-RO.json index 4b7db48ad82..87bcb4a6cd0 100644 --- a/packages/@react-spectrum/s2/intl/ro-RO.json +++ b/packages/@react-spectrum/s2/intl/ro-RO.json @@ -2,11 +2,12 @@ "actionbar.actions": "Acțiuni", "actionbar.actionsAvailable": "Acțiuni disponibile.", "actionbar.clearSelection": "Goliți selecția", - "actionbar.selected": "{count, plural, =0 {Niciunul selectat} one { # selectat} other {# selectate}}", - "actionbar.selectedAll": "Toate selectate", + "actionbar.selected": "{count, plural, =0 {Niciunul selectat} other {# selectate}}", + "actionbar.selectedAll": "Toate elementele selectate", + "breadcrumbs.more": "Mai multe articole", "button.pending": "în așteptare", "contextualhelp.help": "Ajutor", - "contextualhelp.info": "Informaţii", + "contextualhelp.info": "Informații", "dialog.alert": "Alertă", "dialog.dismiss": "Revocare", "dropzone.replaceMessage": "Plasați fișierul pentru a înlocui", @@ -17,6 +18,8 @@ "label.(optional)": "(opţional)", "label.(required)": "(obligatoriu)", "menu.moreActions": "Mai multe acțiuni", + "notificationbadge.indicatorOnly": "Activitate nouă", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Selectați…", "slider.maximum": "Maximum", "slider.minimum": "Minimum", @@ -28,6 +31,5 @@ "tag.actions": "Acțiuni", "tag.hideButtonLabel": "Se afișează mai puțin", "tag.noTags": "Niciuna", - "tag.showAllButtonLabel": "Se afișează tot ({tagCount, number})", - "breadcrumbs.more": "Mai multe articole" -} \ No newline at end of file + "tag.showAllButtonLabel": "Se afișează tot ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/ru-RU.json b/packages/@react-spectrum/s2/intl/ru-RU.json index ebfd378ee47..774755348da 100644 --- a/packages/@react-spectrum/s2/intl/ru-RU.json +++ b/packages/@react-spectrum/s2/intl/ru-RU.json @@ -1,9 +1,10 @@ { "actionbar.actions": "Действия", - "actionbar.actionsAvailable": "Возможно выполнение действий.", + "actionbar.actionsAvailable": "Действия доступны.", "actionbar.clearSelection": "Очистить выбор", - "actionbar.selected": "Выбрано: {count}", + "actionbar.selected": "{count, plural, =0 {Не выбрано} other {Выбрано #}}", "actionbar.selectedAll": "Выбрано все", + "breadcrumbs.more": "Дополнительные элементы", "button.pending": "в ожидании", "contextualhelp.help": "Справка", "contextualhelp.info": "Информация", @@ -17,6 +18,8 @@ "label.(optional)": "(дополнительно)", "label.(required)": "(обязательно)", "menu.moreActions": "Дополнительные действия", + "notificationbadge.indicatorOnly": "Новая активность", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Выбрать…", "slider.maximum": "Максимум", "slider.minimum": "Минимум", @@ -28,6 +31,5 @@ "tag.actions": "Действия", "tag.hideButtonLabel": "Показать меньше", "tag.noTags": "Нет", - "tag.showAllButtonLabel": "Показать все ({tagCount, number})", - "breadcrumbs.more": "Дополнительные элементы" -} \ No newline at end of file + "tag.showAllButtonLabel": "Показать все ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/sk-SK.json b/packages/@react-spectrum/s2/intl/sk-SK.json index 0cf7dacf666..c38b0177355 100644 --- a/packages/@react-spectrum/s2/intl/sk-SK.json +++ b/packages/@react-spectrum/s2/intl/sk-SK.json @@ -2,8 +2,9 @@ "actionbar.actions": "Akcie", "actionbar.actionsAvailable": "Dostupné akcie.", "actionbar.clearSelection": "Vymazať výber", - "actionbar.selected": "Vybrané položky: {count}", + "actionbar.selected": "{count, plural, =0 {Žiadne vybraté položky} other {Počet vybratých položiek: #}}", "actionbar.selectedAll": "Všetky vybraté položky", + "breadcrumbs.more": "Ďalšie položky", "button.pending": "čakajúce", "contextualhelp.help": "Pomoc", "contextualhelp.info": "Informácie", @@ -17,6 +18,8 @@ "label.(optional)": "(nepovinné)", "label.(required)": "(povinné)", "menu.moreActions": "Ďalšie akcie", + "notificationbadge.indicatorOnly": "Nová aktivita", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Vybrať…", "slider.maximum": "Maximum", "slider.minimum": "Minimum", @@ -28,6 +31,5 @@ "tag.actions": "Akcie", "tag.hideButtonLabel": "Zobraziť menej", "tag.noTags": "Žiadne", - "tag.showAllButtonLabel": "Zobraziť všetko ({tagCount, number})", - "breadcrumbs.more": "Ďalšie položky" -} \ No newline at end of file + "tag.showAllButtonLabel": "Zobraziť všetko ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/sl-SI.json b/packages/@react-spectrum/s2/intl/sl-SI.json index f2e14ac1421..914ac0debfe 100644 --- a/packages/@react-spectrum/s2/intl/sl-SI.json +++ b/packages/@react-spectrum/s2/intl/sl-SI.json @@ -2,8 +2,9 @@ "actionbar.actions": "Dejanja", "actionbar.actionsAvailable": "Na voljo so dejanja.", "actionbar.clearSelection": "Počisti izbor", - "actionbar.selected": "Izbrano: {count}", - "actionbar.selectedAll": "Vsi izbrani", + "actionbar.selected": "{count, plural, =0 {Nič ni izbrano} other {# izbrano}}", + "actionbar.selectedAll": "Izbrano vse", + "breadcrumbs.more": "Več elementov", "button.pending": "v teku", "contextualhelp.help": "Pomoč", "contextualhelp.info": "Informacije", @@ -17,6 +18,8 @@ "label.(optional)": "(opcijsko)", "label.(required)": "(obvezno)", "menu.moreActions": "Več možnosti", + "notificationbadge.indicatorOnly": "Nova dejavnost", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Izberite…", "slider.maximum": "Največji", "slider.minimum": "Najmanj", @@ -28,6 +31,5 @@ "tag.actions": "Dejanja", "tag.hideButtonLabel": "Prikaži manj", "tag.noTags": "Nič", - "tag.showAllButtonLabel": "Prikaž vse ({tagCount, number})", - "breadcrumbs.more": "Več elementov" -} \ No newline at end of file + "tag.showAllButtonLabel": "Prikaž vse ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/sr-SP.json b/packages/@react-spectrum/s2/intl/sr-SP.json index 39b29c3d492..5c515402ec8 100644 --- a/packages/@react-spectrum/s2/intl/sr-SP.json +++ b/packages/@react-spectrum/s2/intl/sr-SP.json @@ -2,8 +2,9 @@ "actionbar.actions": "Radnje", "actionbar.actionsAvailable": "Dostupne su radnje.", "actionbar.clearSelection": "Poništi izbor", - "actionbar.selected": "Izabrano: {count}", + "actionbar.selected": "{count, plural, =0 {Nema izabranih stavki} other {# izabrano}}", "actionbar.selectedAll": "Sve je izabrano", + "breadcrumbs.more": "Više stavki", "button.pending": "nerešeno", "contextualhelp.help": "Pomoć", "contextualhelp.info": "Informacije", @@ -17,6 +18,8 @@ "label.(optional)": "(opciono)", "label.(required)": "(obavezno)", "menu.moreActions": "Dodatne radnje", + "notificationbadge.indicatorOnly": "Nova aktivnost", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Izaberite...", "slider.maximum": "Najviše", "slider.minimum": "Najmanje", @@ -28,6 +31,5 @@ "tag.actions": "Radnje", "tag.hideButtonLabel": "Prikaži manje", "tag.noTags": "Ne postoji", - "tag.showAllButtonLabel": "Prikaži sve ({tagCount, number})", - "breadcrumbs.more": "Više stavki" -} \ No newline at end of file + "tag.showAllButtonLabel": "Prikaži sve ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/sv-SE.json b/packages/@react-spectrum/s2/intl/sv-SE.json index 7ace4f1064e..00459b84af2 100644 --- a/packages/@react-spectrum/s2/intl/sv-SE.json +++ b/packages/@react-spectrum/s2/intl/sv-SE.json @@ -2,8 +2,9 @@ "actionbar.actions": "Åtgärder", "actionbar.actionsAvailable": "Åtgärder finns.", "actionbar.clearSelection": "Rensa markering", - "actionbar.selected": "{count, plural, =0 {Inga valda} one {# vald} other {# valda}}", + "actionbar.selected": "{count, plural, =0 {Inga valda} other {# valda}}", "actionbar.selectedAll": "Alla markerade", + "breadcrumbs.more": "Fler artiklar", "button.pending": "väntande", "contextualhelp.help": "Hjälp", "contextualhelp.info": "Information", @@ -17,6 +18,8 @@ "label.(optional)": "(valfritt)", "label.(required)": "(krävs)", "menu.moreActions": "Fler åtgärder", + "notificationbadge.indicatorOnly": "Ny aktivitet", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Välj…", "slider.maximum": "Maximum", "slider.minimum": "Minimum", @@ -28,6 +31,5 @@ "tag.actions": "Åtgärder", "tag.hideButtonLabel": "Visa mindre", "tag.noTags": "Ingen", - "tag.showAllButtonLabel": "Visa alla ({tagCount, number})", - "breadcrumbs.more": "Fler alternativ" -} \ No newline at end of file + "tag.showAllButtonLabel": "Visa alla ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/tr-TR.json b/packages/@react-spectrum/s2/intl/tr-TR.json index 2748190ed61..edbb30e4735 100644 --- a/packages/@react-spectrum/s2/intl/tr-TR.json +++ b/packages/@react-spectrum/s2/intl/tr-TR.json @@ -1,9 +1,10 @@ { "actionbar.actions": "Eylemler", - "actionbar.actionsAvailable": "Eylemler mevcut.", + "actionbar.actionsAvailable": "Mevcut eylemler var.", "actionbar.clearSelection": "Seçimi temizle", "actionbar.selected": "{count, plural, =0 {Hiçbiri seçilmedi} other {# seçildi}}", "actionbar.selectedAll": "Tümü seçildi", + "breadcrumbs.more": "Daha fazla öğe", "button.pending": "beklemede", "contextualhelp.help": "Yardım", "contextualhelp.info": "Bilgiler", @@ -17,6 +18,8 @@ "label.(optional)": "(isteğe bağlı)", "label.(required)": "(gerekli)", "menu.moreActions": "Daha fazla eylem", + "notificationbadge.indicatorOnly": "Yeni etkinlik", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Seçin…", "slider.maximum": "Maksimum", "slider.minimum": "Minimum", @@ -28,6 +31,5 @@ "tag.actions": "Eylemler", "tag.hideButtonLabel": "Daha az göster", "tag.noTags": "Hiçbiri", - "tag.showAllButtonLabel": "Tümünü göster ({tagCount, number})", - "breadcrumbs.more": "Daha Fazla Öğe" -} \ No newline at end of file + "tag.showAllButtonLabel": "Tümünü göster ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/uk-UA.json b/packages/@react-spectrum/s2/intl/uk-UA.json index e14a8818f23..d38d33c3a82 100644 --- a/packages/@react-spectrum/s2/intl/uk-UA.json +++ b/packages/@react-spectrum/s2/intl/uk-UA.json @@ -2,8 +2,9 @@ "actionbar.actions": "Дії", "actionbar.actionsAvailable": "Доступні дії.", "actionbar.clearSelection": "Очистити вибір", - "actionbar.selected": "Вибрано: {count}", + "actionbar.selected": "{count, plural, =0 {Не вибрано нічого} other {Вибрано #}}", "actionbar.selectedAll": "Усе вибрано", + "breadcrumbs.more": "Більше елементів", "button.pending": "в очікуванні", "contextualhelp.help": "Довідка", "contextualhelp.info": "Інформація", @@ -17,6 +18,8 @@ "label.(optional)": "(необов’язково)", "label.(required)": "(обов’язково)", "menu.moreActions": "Більше дій", + "notificationbadge.indicatorOnly": "Нова активність", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "Вибрати…", "slider.maximum": "Максимум", "slider.minimum": "Мінімум", @@ -28,6 +31,5 @@ "tag.actions": "Дії", "tag.hideButtonLabel": "Показувати менше", "tag.noTags": "Немає", - "tag.showAllButtonLabel": "Показати всі ({tagCount, number})", - "breadcrumbs.more": "Більше елементів" -} \ No newline at end of file + "tag.showAllButtonLabel": "Показати всі ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/zh-CN.json b/packages/@react-spectrum/s2/intl/zh-CN.json index 79db13c4c6d..c904547950e 100644 --- a/packages/@react-spectrum/s2/intl/zh-CN.json +++ b/packages/@react-spectrum/s2/intl/zh-CN.json @@ -4,6 +4,7 @@ "actionbar.clearSelection": "清除选择", "actionbar.selected": "{count, plural, =0 {无选择} other {已选择 # 个}}", "actionbar.selectedAll": "全选", + "breadcrumbs.more": "更多项目", "button.pending": "待处理", "contextualhelp.help": "帮助", "contextualhelp.info": "信息", @@ -17,6 +18,8 @@ "label.(optional)": "(可选)", "label.(required)": "(必填)", "menu.moreActions": "更多操作", + "notificationbadge.indicatorOnly": "新活动", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "选择...", "slider.maximum": "最大", "slider.minimum": "最小", @@ -28,6 +31,5 @@ "tag.actions": "操作", "tag.hideButtonLabel": "显示更少", "tag.noTags": "无", - "tag.showAllButtonLabel": "全部显示 ({tagCount, number})", - "breadcrumbs.more": "更多项目" -} \ No newline at end of file + "tag.showAllButtonLabel": "全部显示 ({tagCount, number})" +} diff --git a/packages/@react-spectrum/s2/intl/zh-TW.json b/packages/@react-spectrum/s2/intl/zh-TW.json index ced78708cf5..9f93d50fc6c 100644 --- a/packages/@react-spectrum/s2/intl/zh-TW.json +++ b/packages/@react-spectrum/s2/intl/zh-TW.json @@ -4,6 +4,7 @@ "actionbar.clearSelection": "清除選取項目", "actionbar.selected": "{count, plural, =0 {未選取任何項目} other {已選取 # 個}}", "actionbar.selectedAll": "已選取所有項目", + "breadcrumbs.more": "更多項目", "button.pending": "待處理", "contextualhelp.help": "說明", "contextualhelp.info": "資訊", @@ -17,6 +18,8 @@ "label.(optional)": "(選填)", "label.(required)": "(必填)", "menu.moreActions": "更多動作", + "notificationbadge.indicatorOnly": "新活動", + "notificationbadge.plus": "{notifications}+", "picker.placeholder": "選取…", "slider.maximum": "最大值", "slider.minimum": "最小值", @@ -28,6 +31,5 @@ "tag.actions": "動作", "tag.hideButtonLabel": "顯示較少", "tag.noTags": "無", - "tag.showAllButtonLabel": "顯示全部 ({tagCount, number})", - "breadcrumbs.more": "更多項目" -} \ No newline at end of file + "tag.showAllButtonLabel": "顯示全部 ({tagCount, number})" +} diff --git a/packages/@react-spectrum/table/intl/es-ES.json b/packages/@react-spectrum/table/intl/es-ES.json index d12eb1f0ae7..b6597bbc613 100644 --- a/packages/@react-spectrum/table/intl/es-ES.json +++ b/packages/@react-spectrum/table/intl/es-ES.json @@ -6,6 +6,6 @@ "loading": "Cargando…", "loadingMore": "Cargando más…", "resizeColumn": "Cambiar el tamaño de la columna", - "sortAscending": "Orden de subida", - "sortDescending": "Orden de bajada" + "sortAscending": "Orden ascendente", + "sortDescending": "Orden descendente" } From 77589c24df654eebfa59369adb4bbdc8abc5b661 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 9 Apr 2025 14:31:58 -0700 Subject: [PATCH 18/25] fix: Relax Parcel version range in public plugins (#8067) --- packages/dev/parcel-resolver-optimize-locales/package.json | 4 ++-- packages/dev/parcel-transformer-s2-icon/package.json | 2 +- yarn.lock | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/dev/parcel-resolver-optimize-locales/package.json b/packages/dev/parcel-resolver-optimize-locales/package.json index dbd04b1cf4c..c16cd329a73 100644 --- a/packages/dev/parcel-resolver-optimize-locales/package.json +++ b/packages/dev/parcel-resolver-optimize-locales/package.json @@ -3,10 +3,10 @@ "version": "1.2.0", "main": "LocalesResolver.js", "engines": { - "parcel": "^2.12.0" + "parcel": "^2.0.0" }, "dependencies": { - "@parcel/plugin": "^2.14.0" + "@parcel/plugin": "^2.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/dev/parcel-transformer-s2-icon/package.json b/packages/dev/parcel-transformer-s2-icon/package.json index d5c2b160919..d2e6cb31318 100644 --- a/packages/dev/parcel-transformer-s2-icon/package.json +++ b/packages/dev/parcel-transformer-s2-icon/package.json @@ -7,7 +7,7 @@ }, "dependencies": { "@adobe/spectrum-tokens": "^13.0.0-beta.56", - "@parcel/plugin": "^2.14.0", + "@parcel/plugin": "^2.0.0", "@svgr/core": "^8.1.0", "@svgr/plugin-jsx": "^8.1.0", "@svgr/plugin-svgo": "^8.1.0" diff --git a/yarn.lock b/yarn.lock index 20f62a679d6..fe180b7c5f2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4735,7 +4735,7 @@ __metadata: languageName: node linkType: hard -"@parcel/plugin@npm:2.14.0, @parcel/plugin@npm:^2.10.2, @parcel/plugin@npm:^2.14.0": +"@parcel/plugin@npm:2.14.0, @parcel/plugin@npm:^2.0.0, @parcel/plugin@npm:^2.10.2, @parcel/plugin@npm:^2.14.0": version: 2.14.0 resolution: "@parcel/plugin@npm:2.14.0" dependencies: @@ -6460,7 +6460,7 @@ __metadata: version: 0.0.0-use.local resolution: "@react-aria/parcel-resolver-optimize-locales@workspace:packages/dev/parcel-resolver-optimize-locales" dependencies: - "@parcel/plugin": "npm:^2.14.0" + "@parcel/plugin": "npm:^2.0.0" languageName: unknown linkType: soft @@ -7817,7 +7817,7 @@ __metadata: resolution: "@react-spectrum/parcel-transformer-s2-icon@workspace:packages/dev/parcel-transformer-s2-icon" dependencies: "@adobe/spectrum-tokens": "npm:^13.0.0-beta.56" - "@parcel/plugin": "npm:^2.14.0" + "@parcel/plugin": "npm:^2.0.0" "@svgr/core": "npm:^8.1.0" "@svgr/plugin-jsx": "npm:^8.1.0" "@svgr/plugin-svgo": "npm:^8.1.0" From 12180a0520bcabe8a94351a621b46044676c930c Mon Sep 17 00:00:00 2001 From: Kyle Taborski Date: Wed, 9 Apr 2025 15:16:02 -0700 Subject: [PATCH 19/25] Disclosure button label size update to match new sizes from Specturm (#8006) Co-authored-by: Danni --- .../@react-spectrum/s2/src/Disclosure.tsx | 42 ++++++++----------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/packages/@react-spectrum/s2/src/Disclosure.tsx b/packages/@react-spectrum/s2/src/Disclosure.tsx index f74f1fcf74a..fda4cca6ad7 100644 --- a/packages/@react-spectrum/s2/src/Disclosure.tsx +++ b/packages/@react-spectrum/s2/src/Disclosure.tsx @@ -130,10 +130,10 @@ const buttonStyles = style({ fontWeight: 'bold', fontSize: { size: { - S: 'heading-xs', - M: 'heading-sm', - L: 'heading', - XL: 'heading-lg' + S: 'title-sm', + M: 'title', + L: 'title-lg', + XL: 'title-xl' } }, lineHeight: 'ui', @@ -147,32 +147,32 @@ const buttonStyles = style({ // compact is equivalent to 'control', but other densities have more padding. size: { S: { + density: { + compact: 18, + regular: 24, + spacious: 32 + } + }, + M: { density: { compact: 24, regular: 32, spacious: 40 } }, - M: { + L: { density: { compact: 32, regular: 40, spacious: 48 } }, - L: { + XL: { density: { compact: 40, regular: 48, spacious: 56 } - }, - XL: { - density: { - compact: 48, - regular: 56, - spacious: 64 - } } } }, @@ -219,18 +219,12 @@ function DisclosureHeaderWithForwardRef(props: DisclosureHeaderProps, ref: DOMRe let domRef = useDOMRef(ref); let {size, isQuiet, density} = useSlottedContext(DisclosureContext)!; - let mapSize = { - S: 'XS', - M: 'S', - L: 'M', - XL: 'L' - }; - - // maps to one size smaller in the compact density to ensure there is space between the top and bottom of the action button and container + // Shift button size down by 2 for compact density, 1 for regular/spacious to ensure there is space between the top and bottom of the action button and container let newSize : 'XS' | 'S' | 'M' | 'L' | 'XL' | undefined = size; - if (density === 'compact') { - newSize = mapSize[size ?? 'M'] as 'XS' | 'S' | 'M' | 'L'; - } + const sizes = ['XS', 'S', 'M', 'L', 'XL']; + const currentIndex = sizes.indexOf(size ?? 'M'); + const shift = density === 'compact' ? 2 : 1; + newSize = sizes[Math.max(0, currentIndex - shift)] as 'XS' | 'S' | 'M' | 'L' | 'XL'; return ( Date: Thu, 10 Apr 2025 09:04:21 +1000 Subject: [PATCH 20/25] chore: audit 3.41 (#8064) * chore: audit 3.41 * remove deprecation --- packages/@react-aria/overlays/src/Overlay.tsx | 1 - packages/@react-spectrum/s2/src/index.ts | 2 +- packages/@react-stately/layout/src/GridLayout.ts | 4 ++-- packages/react-aria-components/src/ColorWheel.tsx | 3 +-- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/@react-aria/overlays/src/Overlay.tsx b/packages/@react-aria/overlays/src/Overlay.tsx index ee818bcefc6..05de47ac90e 100644 --- a/packages/@react-aria/overlays/src/Overlay.tsx +++ b/packages/@react-aria/overlays/src/Overlay.tsx @@ -22,7 +22,6 @@ export interface OverlayProps { /** * The container element in which the overlay portal will be placed. * @default document.body - * @deprecated - Use a parent UNSAFE_PortalProvider to set your portal container instead. */ portalContainer?: Element, /** The overlay to render in the portal. */ diff --git a/packages/@react-spectrum/s2/src/index.ts b/packages/@react-spectrum/s2/src/index.ts index 717ccf75515..6bf54ab0577 100644 --- a/packages/@react-spectrum/s2/src/index.ts +++ b/packages/@react-spectrum/s2/src/index.ts @@ -81,7 +81,7 @@ export {TreeView, TreeViewItem, TreeViewItemContent} from './TreeView'; export {pressScale} from './pressScale'; -export {Autocomplete, AutocompleteContext, AutocompleteStateContext} from 'react-aria-components'; +export {Autocomplete} from 'react-aria-components'; export {Collection} from 'react-aria-components'; export {FileTrigger} from 'react-aria-components'; diff --git a/packages/@react-stately/layout/src/GridLayout.ts b/packages/@react-stately/layout/src/GridLayout.ts index e569a2e21e7..14c8e0865b4 100644 --- a/packages/@react-stately/layout/src/GridLayout.ts +++ b/packages/@react-stately/layout/src/GridLayout.ts @@ -181,8 +181,8 @@ export class GridLayout exte this.contentSize = new Size(this.virtualizer!.visibleRect.width, y); } - getLayoutInfo(key: Key): LayoutInfo { - return this.layoutInfos.get(key)!; + getLayoutInfo(key: Key): LayoutInfo | null { + return this.layoutInfos.get(key) || null; } getContentSize(): Size { diff --git a/packages/react-aria-components/src/ColorWheel.tsx b/packages/react-aria-components/src/ColorWheel.tsx index 9f39c93445c..71dd0301849 100644 --- a/packages/react-aria-components/src/ColorWheel.tsx +++ b/packages/react-aria-components/src/ColorWheel.tsx @@ -2,7 +2,6 @@ import {AriaColorWheelOptions, useColorWheel} from 'react-aria'; import {ColorWheelContext} from './RSPContexts'; import {ColorWheelState, useColorWheelState} from 'react-stately'; import {ContextValue, Provider, RenderProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps} from './utils'; -import {DOMProps} from '@react-types/shared'; import {filterDOMProps} from '@react-aria/utils'; import {InternalColorThumbContext} from './ColorThumb'; import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, useContext, useRef} from 'react'; @@ -67,7 +66,7 @@ export const ColorWheel = forwardRef(function ColorWheel(props: ColorWheelProps, }); export interface ColorWheelTrackRenderProps extends ColorWheelRenderProps {} -export interface ColorWheelTrackProps extends StyleRenderProps, DOMProps {} +export interface ColorWheelTrackProps extends StyleRenderProps {} interface ColorWheelTrackContextValue extends Omit, 'children' | 'className' | 'style'>, ColorWheelTrackProps {} export const ColorWheelTrackContext = createContext>(null); From 58d96cacedf2b6518403bf7783caaf7134491405 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Thu, 10 Apr 2025 09:27:44 +1000 Subject: [PATCH 21/25] chore: audit 3.41 (#8064) * chore: audit 3.41 * remove deprecation From b453490225bcdc898ed735ad2c5c392a783cbc4e Mon Sep 17 00:00:00 2001 From: Ritesh Kumar Date: Thu, 10 Apr 2025 16:41:55 +0530 Subject: [PATCH 22/25] chore: Update package dependencies for @react-aria/overlays and @react-aria-components * Added @react-aria/ssr and updated @react-aria/overlays in @react-aria/overlays package.json * Added @react-aria/overlays, @react-aria/ssr, and @react-aria/utils in @react-aria-components package.json --- packages/@react-aria/overlays/package.json | 1 + packages/react-aria-components/package.json | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/@react-aria/overlays/package.json b/packages/@react-aria/overlays/package.json index 2b026fdbfba..0b54d2c041e 100644 --- a/packages/@react-aria/overlays/package.json +++ b/packages/@react-aria/overlays/package.json @@ -28,6 +28,7 @@ "@react-aria-nutrient/ssr": "^3.9.7", "@react-aria-nutrient/utils": "^3.28.1", "@react-aria-nutrient/visually-hidden": "^3.8.21", + "@react-aria/ssr": "^3.9.7", "@react-stately/overlays": "^3.6.14", "@react-types/button": "^3.11.0", "@react-types/overlays": "^3.8.13", diff --git a/packages/react-aria-components/package.json b/packages/react-aria-components/package.json index f5b16a3f91a..1e745250afb 100644 --- a/packages/react-aria-components/package.json +++ b/packages/react-aria-components/package.json @@ -51,6 +51,9 @@ "@react-aria-nutrient/toolbar": "3.0.0-beta.14", "@react-aria-nutrient/utils": "^3.28.1", "@react-aria-nutrient/virtualizer": "^4.1.3", + "@react-aria/overlays": "^3.26.1", + "@react-aria/ssr": "^3.9.7", + "@react-aria/utils": "^3.28.1", "@react-stately/autocomplete": "3.0.0-beta.0", "@react-stately/layout": "^4.2.1", "@react-stately/selection": "^3.20.0", From f926dd545bec1a7523a51e6a2177d64abc6ee35b Mon Sep 17 00:00:00 2001 From: Ritesh Kumar Date: Thu, 10 Apr 2025 16:50:51 +0530 Subject: [PATCH 23/25] chore: Update import paths and dependencies for @react-aria-nutrient * Refactored import statements in various components and tests to use @react-aria-nutrient/overlays instead of @react-aria/overlays. * Removed references to @react-aria/overlays from package.json and yarn.lock. * Updated documentation links to reflect the new package structure. --- .../overlays/docs/PortalProvider.mdx | 8 +-- .../docs/pages/blog/building-a-combobox.mdx | 4 +- packages/react-aria-components/package.json | 1 - .../react-aria-components/test/Dialog.test.js | 2 +- .../test/Popover.test.js | 2 +- .../react-aria-components/test/Toast.test.js | 2 +- .../test/Tooltip.test.js | 2 +- yarn.lock | 60 +------------------ 8 files changed, 11 insertions(+), 70 deletions(-) diff --git a/packages/@react-aria/overlays/docs/PortalProvider.mdx b/packages/@react-aria/overlays/docs/PortalProvider.mdx index 69b63e72fc8..848d3bfe7f3 100644 --- a/packages/@react-aria/overlays/docs/PortalProvider.mdx +++ b/packages/@react-aria/overlays/docs/PortalProvider.mdx @@ -10,9 +10,9 @@ governing permissions and limitations under the License. */} import {Layout} from '@react-spectrum/docs'; export default Layout; -import docs from 'docs:@react-aria/overlays'; +import docs from 'docs:@react-aria-nutrient/overlays'; import {HeaderInfo, PropTable, FunctionAPI, PageDescription} from '@react-spectrum/docs'; -import packageData from '@react-aria/overlays/package.json'; +import packageData from '@react-aria-nutrient/overlays/package.json'; --- category: Utilities @@ -83,7 +83,7 @@ function MyToastRegion() { ``` ```tsx example -import {UNSAFE_PortalProvider} from '@react-aria/overlays'; +import {UNSAFE_PortalProvider} from '@react-aria-nutrient/overlays'; // See the above Toast docs link for the ToastRegion implementation function App() { @@ -128,7 +128,7 @@ used by custom overlay components to ensure that they are also being consistentl ```tsx -import {useUNSAFE_PortalContext} from '@react-aria/overlays'; +import {useUNSAFE_PortalContext} from '@react-aria-nutrient/overlays'; function MyOverlay(props) { let {children} = props; diff --git a/packages/dev/docs/pages/blog/building-a-combobox.mdx b/packages/dev/docs/pages/blog/building-a-combobox.mdx index 82380869299..65c5aafd942 100644 --- a/packages/dev/docs/pages/blog/building-a-combobox.mdx +++ b/packages/dev/docs/pages/blog/building-a-combobox.mdx @@ -72,7 +72,7 @@ to see how the ComboBox tray worked before and after we switched to the VisualVi Another issue we encountered had to do with iOS Safari page scrolling behavior. When the onscreen keyboard is visible, iOS Safari makes the page scrollable so that users can still access content that is hidden behind the keyboard. However, now that our ComboBox tray sizes itself to fit in the visual viewport, users could now scroll the entire tray itself off screen. To stop this from happening, we prevent default on `touchmove` events that happen on the document body or root element of the document. This preserves the user's ability to scroll through the options in the tray but blocks any attempt to scroll the page itself until the tray is closed. The video below -illustrates the difference in scrolling behavior before and after our fix. If you are building your own overlays and would like to prevent this kind of document scrolling behavior, check out the [usePreventScroll](https://github.com/adobe/react-spectrum/blob/main/packages/@react-aria/overlays/src/usePreventScroll.ts) hook in the `react-aria/overlays` package. +illustrates the difference in scrolling behavior before and after our fix. If you are building your own overlays and would like to prevent this kind of document scrolling behavior, check out the [usePreventScroll](https://github.com/adobe/react-spectrum/blob/main/packages/@react-aria-nutrient/overlays/src/usePreventScroll.ts) hook in the `react-aria/overlays` package.