diff --git a/package.json b/package.json index d27f7150321..b1bbf008fb7 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/@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'; 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/grid/src/useGrid.ts b/packages/@react-aria/grid/src/useGrid.ts index f7642a0a590..50b0ad96c79 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 { 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; diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index 8769e5508c0..96093946a69 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 3c32328109e..bfdd44324f2 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-nutrient/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/overlays/docs/PortalProvider.mdx b/packages/@react-aria/overlays/docs/PortalProvider.mdx new file mode 100644 index 00000000000..848d3bfe7f3 --- /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-nutrient/overlays'; +import {HeaderInfo, PropTable, FunctionAPI, PageDescription} from '@react-spectrum/docs'; +import packageData from '@react-aria-nutrient/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-nutrient/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-nutrient/overlays'; + +function MyOverlay(props) { + let {children} = props; + let {getContainer} = useUNSAFE_PortalContext(); + return ReactDOM.createPortal(children, getContainer()); +} +``` 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/overlays/src/Overlay.tsx b/packages/@react-aria/overlays/src/Overlay.tsx index 7e7c5c52b24..a23913731c9 100644 --- a/packages/@react-aria/overlays/src/Overlay.tsx +++ b/packages/@react-aria/overlays/src/Overlay.tsx @@ -16,7 +16,7 @@ import React, {ReactNode, useContext, useMemo, useState} from 'react'; import ReactDOM from 'react-dom'; import {useIsSSR} from '@react-aria-nutrient/ssr'; import {useLayoutEffect} from '@react-aria-nutrient/utils'; -import {useUNSTABLE_PortalContext} from './PortalProvider'; +import {useUNSAFE_PortalContext} from './PortalProvider'; export interface OverlayProps { /** @@ -55,8 +55,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 c4a850baa51..6de74fba2a5 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-nutrient/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-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index 853180301c4..3175e543db6 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -54,6 +54,11 @@ export interface AriaSelectableCollectionOptions { * @default false */ disallowSelectAll?: boolean, + /** + * Whether pressing the Escape should clear selection in the collection or not. + * @default 'clearSelection' + */ + escapeKeyBehavior?: 'clearSelection' | 'none', /** * Whether selection should occur automatically on focus. * @default false @@ -108,6 +113,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions shouldFocusWrap = false, disallowEmptySelection = false, disallowSelectAll = false, + escapeKeyBehavior = 'clearSelection', selectOnFocus = manager.selectionBehavior === 'replace', disallowTypeAhead = false, shouldUseVirtualFocus, @@ -279,7 +285,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions } break; case 'Escape': - if (!disallowEmptySelection && manager.selectedKeys.size !== 0) { + if (escapeKeyBehavior === 'clearSelection' && !disallowEmptySelection && manager.selectedKeys.size !== 0) { e.stopPropagation(); e.preventDefault(); manager.clearSelection(); diff --git a/packages/@react-aria/selection/src/useSelectableItem.ts b/packages/@react-aria/selection/src/useSelectableItem.ts index 18b5ae8682e..e0772a9129b 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-nutrient/interactions'; +import {focusSafely, PressHookProps, useLongPress, usePress} from '@react-aria-nutrient/interactions'; import {getCollectionId, isNonContiguousSelectionModifier} from './utils'; import {isCtrlKeyPressed, mergeProps, openLink, useId, useRouter} from '@react-aria-nutrient/utils'; import {moveVirtualFocus} from '@react-aria-nutrient/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; 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-aria/test-utils/package.json b/packages/@react-aria/test-utils/package.json index 38f03a0a188..606049b90ac 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 bbb965476e9..d7dda1e51de 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/dialog/test/DialogContainer.test.js b/packages/@react-spectrum/dialog/test/DialogContainer.test.js index cbcf5044e74..316e4e3fd34 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-nutrient/overlays'; +import {UNSAFE_PortalProvider} from '@react-aria-nutrient/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 5a6ad7dab8f..f288a0bcb1d 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-nutrient/overlays'; +import {UNSAFE_PortalProvider} from '@react-aria-nutrient/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/list/docs/ListView.mdx b/packages/@react-spectrum/list/docs/ListView.mdx index 1b27bb044cf..cf114a321fd 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/list/test/ListView.test.js b/packages/@react-spectrum/list/test/ListView.test.js index df8a2ea6f5d..5a252f553cb 100644 --- a/packages/@react-spectrum/list/test/ListView.test.js +++ b/packages/@react-spectrum/list/test/ListView.test.js @@ -812,6 +812,24 @@ describe('ListView', function () { expect(announce).toHaveBeenCalledTimes(3); }); + it('should prevent Esc from clearing selection if escapeKeyBehavior is "none"', async function () { + let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', escapeKeyBehavior: 'none'}); + + let rows = tree.getAllByRole('row'); + await user.click(within(rows[1]).getByRole('checkbox')); + checkSelection(onSelectionChange, ['bar']); + + onSelectionChange.mockClear(); + await user.click(within(rows[2]).getByRole('checkbox')); + checkSelection(onSelectionChange, ['bar', 'baz']); + + onSelectionChange.mockClear(); + await user.keyboard('{Escape}'); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(rows[1]).toHaveAttribute('aria-selected', 'true'); + expect(rows[2]).toHaveAttribute('aria-selected', 'true'); + }); + it('should support range selection', async function () { let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple'}); diff --git a/packages/@react-spectrum/listbox/docs/ListBox.mdx b/packages/@react-spectrum/listbox/docs/ListBox.mdx index 188da75b236..13edf558c3e 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/listbox/test/ListBox.test.js b/packages/@react-spectrum/listbox/test/ListBox.test.js index b7315b6077b..25ed6eafe31 100644 --- a/packages/@react-spectrum/listbox/test/ListBox.test.js +++ b/packages/@react-spectrum/listbox/test/ListBox.test.js @@ -499,6 +499,30 @@ describe('ListBox', function () { expect(onSelectionChange).toBeCalledTimes(0); }); + + it('should prevent Esc from clearing selection if escapeKeyBehavior is "none"', async function () { + let user = userEvent.setup({delay: null, pointerMap}); + let tree = renderComponent({onSelectionChange, selectionMode: 'multiple', escapeKeyBehavior: 'none'}); + let listbox = tree.getByRole('listbox'); + + let options = within(listbox).getAllByRole('option'); + let firstItem = options[3]; + await user.click(firstItem); + expect(firstItem).toHaveAttribute('aria-selected', 'true'); + + let secondItem = options[1]; + await user.click(secondItem); + expect(secondItem).toHaveAttribute('aria-selected', 'true'); + + expect(onSelectionChange).toBeCalledTimes(2); + expect(onSelectionChange.mock.calls[0][0].has('Blah')).toBeTruthy(); + expect(onSelectionChange.mock.calls[1][0].has('Bar')).toBeTruthy(); + + await user.keyboard('{Escape}'); + expect(onSelectionChange).toBeCalledTimes(2); + expect(onSelectionChange.mock.calls[0][0].has('Blah')).toBeTruthy(); + expect(onSelectionChange.mock.calls[1][0].has('Bar')).toBeTruthy(); + }); }); describe('supports no selection', function () { diff --git a/packages/@react-spectrum/menu/docs/MenuTrigger.mdx b/packages/@react-spectrum/menu/docs/MenuTrigger.mdx index 8ad1a91745b..11ac7dec2a0 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/menu/test/MenuTrigger.test.js b/packages/@react-spectrum/menu/test/MenuTrigger.test.js index 748a2d8f9f6..6bd96c55646 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-nutrient/overlays'; +import {UNSAFE_PortalProvider} from '@react-aria-nutrient/overlays'; import {User} from '@react-aria-nutrient/test-utils'; import userEvent from '@testing-library/user-event'; @@ -267,6 +267,30 @@ describe('MenuTrigger', function () { expect(onOpenChange).toBeCalledTimes(1); }); + it.each` + Name | Component | props + ${'MenuTrigger'} | ${MenuTrigger} | ${{onOpenChange}} + `('$Name should prevent Esc from clearing selection and close the menu if escapeKeyBehavior is "none"', async function ({Component, props}) { + tree = renderComponent(Component, props, {selectionMode: 'multiple', escapeKeyBehavior: 'none', onSelectionChange}); + let menuTester = testUtilUser.createTester('Menu', {root: tree.container, interactionType: 'keyboard'}); + expect(onOpenChange).toBeCalledTimes(0); + await menuTester.open(); + + expect(onOpenChange).toBeCalledTimes(1); + expect(onSelectionChange).toBeCalledTimes(0); + + await menuTester.selectOption({option: 'Foo', menuSelectionMode: 'multiple', keyboardActivation: 'Space'}); + expect(onSelectionChange).toBeCalledTimes(1); + expect(onSelectionChange.mock.calls[0][0].has('Foo')).toBeTruthy(); + await menuTester.selectOption({option: 'Bar', menuSelectionMode: 'multiple', keyboardActivation: 'Space'}); + expect(onSelectionChange).toBeCalledTimes(2); + expect(onSelectionChange.mock.calls[1][0].has('Bar')).toBeTruthy(); + + await menuTester.close(); + expect(menuTester.menu).not.toBeInTheDocument(); + expect(onOpenChange).toBeCalledTimes(2); + }); + it.each` Name | Component | props | menuProps ${'MenuTrigger multiple'} | ${MenuTrigger} | ${{closeOnSelect: true}} | ${{selectionMode: 'multiple', onClose}} @@ -735,7 +759,7 @@ describe('MenuTrigger', function () { function InfoMenu(props) { return ( - props.container.current}> + props.container.current}> @@ -744,7 +768,7 @@ describe('MenuTrigger', function () { Three - + ); } diff --git a/packages/@react-spectrum/picker/docs/Picker.mdx b/packages/@react-spectrum/picker/docs/Picker.mdx index e5623d336e1..7f97ab69df5 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/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/s2/package.json b/packages/@react-spectrum/s2/package.json index 5884901d121..e1dbd62fd55 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-nutrient/test-utils": "1.0.0-alpha.5", "@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/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/Disclosure.tsx b/packages/@react-spectrum/s2/src/Disclosure.tsx index c7605d1b964..774fa8cc872 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 ( ({ borderStyle: 'none', borderRadius: 'full', margin: 0, + flexGrow: 0, + flexShrink: 0, height: { orientation: { horizontal: { diff --git a/packages/@react-spectrum/s2/src/NotificationBadge.tsx b/packages/@react-spectrum/s2/src/NotificationBadge.tsx index 22e64dad894..2e975800411 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} diff --git a/packages/@react-spectrum/s2/src/index.ts b/packages/@react-spectrum/s2/src/index.ts index 69f8619dcbe..6bf54ab0577 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} 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} from 'react-aria-components'; +export type {AutocompleteProps, FileTriggerProps, TooltipTriggerComponentProps as TooltipTriggerProps, SortDescriptor} from 'react-aria-components'; 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 + +`; + } + } + } } }; diff --git a/packages/@react-spectrum/table/docs/TableView.mdx b/packages/@react-spectrum/table/docs/TableView.mdx index 06bc7033a83..f5e4c7f117b 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/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" } diff --git a/packages/@react-spectrum/table/src/Resizer.tsx b/packages/@react-spectrum/table/src/Resizer.tsx index 27d824da78f..872c9c12728 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-nutrient/i18n'; import {useTableColumnResize} from '@react-aria-nutrient/table'; import {useTableContext, useVirtualizerContext} from './TableViewBase'; -import {useUNSTABLE_PortalContext} from '@react-aria-nutrient/overlays'; +import {useUNSAFE_PortalContext} from '@react-aria-nutrient/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/src/TableViewBase.tsx b/packages/@react-spectrum/table/src/TableViewBase.tsx index 764ff5fc98c..cd54a0c9ea9 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 1848e0610f0..d1c015b0941 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-nutrient/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 acced9e1c85..53de184f870 100644 --- a/packages/@react-spectrum/table/test/Table.test.js +++ b/packages/@react-spectrum/table/test/Table.test.js @@ -10,5040 +10,7 @@ * governing permissions and limitations under the License. */ -jest.mock('@react-aria-nutrient/live-announcer'); -jest.mock('@react-aria-nutrient/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-nutrient/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-nutrient/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-nutrient/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 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(() => 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 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 3de2153c5b8..b66f127b741 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 dd4dbbc4e1b..1ddee124817 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/TableSizing.test.tsx b/packages/@react-spectrum/table/test/TableSizing.test.tsx index e60724dba23..398317a1832 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-nutrient/table/test/tableResizingTests' import {Scale} from '@react-types/provider'; import {setInteractionModality} from '@react-aria-nutrient/interactions'; import {theme} from '@react-spectrum/theme-default'; -import {UNSTABLE_PortalProvider} from '@react-aria-nutrient/overlays'; +import {UNSAFE_PortalProvider} from '@react-aria-nutrient/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/table/test/TableTests.js b/packages/@react-spectrum/table/test/TableTests.js new file mode 100644 index 00000000000..a378363eafa --- /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-nutrient/live-announcer'); +jest.mock('@react-aria-nutrient/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-nutrient/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-nutrient/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-nutrient/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.tsx similarity index 75% rename from packages/@react-spectrum/table/test/TestTableUtils.test.js rename to packages/@react-spectrum/table/test/TestTableUtils.test.tsx index 3da35ff89af..87676bba7d2 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-nutrient/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-nutrient/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}); } @@ -28,10 +28,14 @@ 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 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({}); @@ -128,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'} @@ -179,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'])); @@ -200,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/table/test/TreeGridTable.test.tsx b/packages/@react-spectrum/table/test/TreeGridTable.test.tsx index b3ee10f1cc4..773fe89a4e0 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(); }); diff --git a/packages/@react-spectrum/tabs/docs/Tabs.mdx b/packages/@react-spectrum/tabs/docs/Tabs.mdx index cdec15145b8..42e41167ab5 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 bb274df8732..eb64eb6d36f 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/toast/src/Toaster.tsx b/packages/@react-spectrum/toast/src/Toaster.tsx index d5de54545ee..9dd9909929f 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-nutrient/overlays'; +import {useUNSAFE_PortalContext} from '@react-aria-nutrient/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 bae92d2b570..9331c0c3e2c 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-nutrient/overlays'; import {UNSTABLE_createLandmarkController, useLandmark} from '@react-aria-nutrient/landmark'; -import {UNSTABLE_PortalProvider} from '@react-aria-nutrient/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 80baa942059..1a7de753012 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-nutrient/overlays'; +import {UNSAFE_PortalProvider} from '@react-aria-nutrient/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-spectrum/tree/docs/TreeView.mdx b/packages/@react-spectrum/tree/docs/TreeView.mdx index 4ddabbe30a2..8e0eb3c4723 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/@react-spectrum/tree/test/TreeView.test.tsx b/packages/@react-spectrum/tree/test/TreeView.test.tsx index 2ca12ed5e7e..9d2ad4744eb 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-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-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/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.