diff --git a/packages/@react-aria/overlays/src/calculatePosition.ts b/packages/@react-aria/overlays/src/calculatePosition.ts index 160de612c72..ad872c7d7ea 100644 --- a/packages/@react-aria/overlays/src/calculatePosition.ts +++ b/packages/@react-aria/overlays/src/calculatePosition.ts @@ -536,7 +536,7 @@ export function calculatePosition(opts: PositionOpts): PositionResult { export function getRect(node: Element, ignoreScale: boolean) { let {top, left, width, height} = node.getBoundingClientRect(); - // Use offsetWidth and offsetHeight if this is an HTML element, so that + // Use offsetWidth and offsetHeight if this is an HTML element, so that // the size is not affected by scale transforms. if (ignoreScale && node instanceof node.ownerDocument.defaultView!.HTMLElement) { width = node.offsetWidth; diff --git a/packages/@react-spectrum/s2/intl/ar-AE.json b/packages/@react-spectrum/s2/intl/ar-AE.json index da0c74bfa40..977a05e0096 100644 --- a/packages/@react-spectrum/s2/intl/ar-AE.json +++ b/packages/@react-spectrum/s2/intl/ar-AE.json @@ -29,9 +29,12 @@ "picker.selectedCount": "{count, plural, =0 {لم يتم تحديد عناصر} one {# عنصر محدد} other {# عنصر محدد}}", "slider.maximum": "أقصى", "slider.minimum": "أدنى", + "table.cancel": "إلغاء", + "table.editCell": "تعديل الخلية", "table.loading": "جارٍ التحميل...", "table.loadingMore": "جارٍ تحميل المزيد...", "table.resizeColumn": "تغيير حجم العمود", + "table.save": "حفظ", "table.sortAscending": "فرز بترتيب تصاعدي", "table.sortDescending": "فرز بترتيب تنازلي", "tag.actions": "الإجراءات", diff --git a/packages/@react-spectrum/s2/intl/en-US.json b/packages/@react-spectrum/s2/intl/en-US.json index b90516f839a..01be6111c20 100644 --- a/packages/@react-spectrum/s2/intl/en-US.json +++ b/packages/@react-spectrum/s2/intl/en-US.json @@ -29,9 +29,12 @@ "picker.selectedCount": "{count, plural, =0 {No items selected} one {# item selected} other {# items selected}}", "slider.maximum": "Maximum", "slider.minimum": "Minimum", + "table.cancel": "Cancel", + "table.editCell": "Edit cell", "table.loading": "Loading…", "table.loadingMore": "Loading more…", "table.resizeColumn": "Resize column", + "table.save": "Save", "table.sortAscending": "Sort Ascending", "table.sortDescending": "Sort Descending", "tag.actions": "Actions", diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index b33f8164199..c6441c762fd 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -10,21 +10,27 @@ * governing permissions and limitations under the License. */ +import {ActionButton, ActionButtonContext} from './ActionButton'; import {baseColor, colorMix, focusRing, fontRelative, lightDark, space, style} from '../style' with {type: 'macro'}; import { Button, + ButtonContext, CellRenderProps, Collection, ColumnRenderProps, ColumnResizer, ContextValue, + DEFAULT_SLOT, + Form, Key, + OverlayTriggerStateContext, Provider, Cell as RACCell, CellProps as RACCellProps, CheckboxContext as RACCheckboxContext, Column as RACColumn, ColumnProps as RACColumnProps, + Popover as RACPopover, Row as RACRow, RowProps as RACRowProps, Table as RACTable, @@ -44,9 +50,11 @@ import { useTableOptions, Virtualizer } from 'react-aria-components'; -import {centerPadding, controlFont, getAllowedOverrides, StylesPropWithHeight, UnsafeStyles} from './style-utils' with {type: 'macro'}; +import {centerPadding, colorScheme, controlFont, getAllowedOverrides, StylesPropWithHeight, UnsafeStyles} from './style-utils' with {type: 'macro'}; import {Checkbox} from './Checkbox'; +import Checkmark from '../s2wf-icons/S2_Icon_Checkmark_20_N.svg'; import Chevron from '../ui-icons/Chevron'; +import Close from '../s2wf-icons/S2_Icon_Close_20_N.svg'; import {ColumnSize} from '@react-types/table'; import {DOMRef, DOMRefValue, forwardRefType, GlobalDOMAttributes, LoadingState, Node} from '@react-types/shared'; import {GridNode} from '@react-types/grid'; @@ -58,11 +66,12 @@ import {Menu, MenuItem, MenuSection, MenuTrigger} from './Menu'; import Nubbin from '../ui-icons/S2_MoveHorizontalTableWidget.svg'; import {ProgressCircle} from './ProgressCircle'; import {raw} from '../style/style-macro' with {type: 'macro'}; -import React, {createContext, forwardRef, ReactElement, ReactNode, useCallback, useContext, useMemo, useRef, useState} from 'react'; +import React, {createContext, CSSProperties, ForwardedRef, forwardRef, ReactElement, ReactNode, RefObject, useCallback, useContext, useMemo, useRef, useState} from 'react'; import SortDownArrow from '../s2wf-icons/S2_Icon_SortDown_20_N.svg'; import SortUpArrow from '../s2wf-icons/S2_Icon_SortUp_20_N.svg'; import {useActionBarContainer} from './ActionBar'; import {useDOMRef} from '@react-spectrum/utils'; +import {useLayoutEffect, useObjectRef} from '@react-aria/utils'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; @@ -1044,6 +1053,193 @@ export const Cell = forwardRef(function Cell(props: CellProps, ref: DOMRef { + renderEditing: () => ReactNode, + isSaving?: boolean, + onSubmit: () => void, + onCancel: () => void +} + +/** + * An exditable cell within a table row. + */ +export const EditableCell = forwardRef(function EditableCell(props: EditableCellProps, ref: ForwardedRef) { + let {children, showDivider = false, textValue, ...otherProps} = props; + let tableVisualOptions = useContext(InternalTableContext); + let domRef = useObjectRef(ref); + textValue ||= typeof children === 'string' ? children : undefined; + + return ( + cell({ + ...renderProps, + ...tableVisualOptions, + isDivider: showDivider + })} + textValue={textValue} + {...otherProps}> + {({isFocusVisible}) => ( + } /> + )} + + ); +}); + +function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, cellRef: RefObject}) { + let {children, align, renderEditing, isSaving, onSubmit, onCancel, isFocusVisible, cellRef} = props; + let [isOpen, setIsOpen] = useState(false); + let popoverRef = useRef(null); + let formRef = useRef(null); + let [triggerWidth, setTriggerWidth] = useState(0); + let [tableWidth, setTableWidth] = useState(0); + let [verticalOffset, setVerticalOffset] = useState(0); + let tableVisualOptions = useContext(InternalTableContext); + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); + + let {density} = useContext(InternalTableContext); + let size: 'XS' | 'S' | 'M' | 'L' | 'XL' | undefined = 'M'; + if (density === 'compact') { + size = 'S'; + } else if (density === 'spacious') { + size = 'L'; + } + + + // Popover positioning + useLayoutEffect(() => { + if (!isOpen) { + return; + } + let width = cellRef.current?.clientWidth || 0; + let cell = cellRef.current; + let boundingRect = cell?.parentElement?.getBoundingClientRect(); + let verticalOffset = (boundingRect?.top ?? 0) - (boundingRect?.bottom ?? 0); + + let tableWidth = cellRef.current?.closest('[role="grid"]')?.clientWidth || 0; + setTriggerWidth(width); + setVerticalOffset(verticalOffset); + setTableWidth(tableWidth); + }, [cellRef, density, isOpen]); + + // Cancel, don't save the value + let cancel = () => { + setIsOpen(false); + onCancel(); + }; + + return ( + setIsOpen(true), + isPending: isSaving, + isQuiet: !isSaving, + size, + excludeFromTabOrder: true, + styles: style({ + // TODO: really need access to display here instead, but not possible right now + // will be addressable with displayOuter + visibility: { + default: 'hidden', + isForcedVisible: 'visible', + ':is([role="row"]:hover *)': 'visible', + ':is([role="row"][data-focus-visible-within] *)': 'visible', + '@media not (any-pointer: fine)': 'visible' + } + })({isForcedVisible: isOpen || !!isSaving}) + } + } + }] + ]}> + {children} + {isFocusVisible && } + + + { + if (!popoverRef.current?.contains(document.activeElement)) { + return false; + } + formRef.current?.requestSubmit(); + return false; + }} + triggerRef={cellRef} + aria-label={stringFormatter.format('table.editCell')} + offset={verticalOffset} + placement="bottom start" + style={{ + minWidth: `min(${triggerWidth}px, ${tableWidth}px)`, + maxWidth: `${tableWidth}px`, + // Override default z-index from useOverlayPosition. We use isolation: isolate instead. + zIndex: undefined + }} + className={editPopover}> + +
{ + e.preventDefault(); + onSubmit(); + setIsOpen(false); + }} + className={style({width: 'full', display: 'flex', alignItems: 'baseline', gap: 16})} + style={{'--input-width': `calc(${triggerWidth}px - 32px)`} as CSSProperties}> + {renderEditing()} +
+ + +
+
+
+
+
+
+ ); +} + // Use color-mix instead of transparency so sticky cells work correctly. const selectedBackground = lightDark(colorMix('gray-25', 'informative-900', 10), colorMix('gray-25', 'informative-700', 10)); const selectedActiveBackground = lightDark(colorMix('gray-25', 'informative-900', 15), colorMix('gray-25', 'informative-700', 15)); diff --git a/packages/@react-spectrum/s2/src/index.ts b/packages/@react-spectrum/s2/src/index.ts index 6fbe68b7e52..25629e375f2 100644 --- a/packages/@react-spectrum/s2/src/index.ts +++ b/packages/@react-spectrum/s2/src/index.ts @@ -78,7 +78,7 @@ export {Skeleton, useIsSkeleton} from './Skeleton'; export {SkeletonCollection} from './SkeletonCollection'; export {StatusLight, StatusLightContext} from './StatusLight'; export {Switch, SwitchContext} from './Switch'; -export {TableView, TableHeader, TableBody, Row, Cell, Column, TableContext} from './TableView'; +export {TableView, TableHeader, TableBody, Row, Cell, Column, TableContext, EditableCell} from './TableView'; export {Tabs, TabList, Tab, TabPanel, TabsContext} from './Tabs'; export {TagGroup, Tag, TagGroupContext} from './TagGroup'; export {TextArea, TextField, TextAreaContext, TextFieldContext} from './TextField'; diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index 402cdda5e52..c2367d0c302 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -15,27 +15,36 @@ import { ActionButton, Cell, Column, + ColumnProps, Content, + EditableCell, Heading, IllustratedMessage, Link, MenuItem, MenuSection, + Picker, + PickerItem, Row, + StatusLight, TableBody, TableHeader, TableView, TableViewProps, - Text + Text, + TextField } from '../src'; import {categorizeArgTypes, getActionArgs} from './utils'; +import Edit from '../s2wf-icons/S2_Icon_Edit_20_N.svg'; import Filter from '../s2wf-icons/S2_Icon_Filter_20_N.svg'; import FolderOpen from '../spectrum-illustrations/linear/FolderOpen'; +import {Key} from '@react-types/shared'; import type {Meta, StoryObj} from '@storybook/react'; -import {ReactElement, useState} from 'react'; +import {ReactElement, useCallback, useRef, useState} from 'react'; import {SortDescriptor} from 'react-aria-components'; import {style} from '../style/spectrum-theme' with {type: 'macro'}; import {useAsyncList} from '@react-stately/data'; +import User from '../s2wf-icons/S2_Icon_User_20_N.svg'; let onActionFunc = action('onAction'); let noOnAction = null; @@ -1385,3 +1394,192 @@ const ResizableTable = () => { } } }; + +let defaultItems = [ + {id: 1, + fruits: 'Apples', task: 'Collect', status: 'Pending', farmer: 'Eva', + isSaving: {}, + intermediateValue: {} + }, + {id: 2, + fruits: 'Oranges', task: 'Collect', status: 'Pending', farmer: 'Steven', + isSaving: {}, + intermediateValue: {} + }, + {id: 3, + fruits: 'Pears', task: 'Collect', status: 'Pending', farmer: 'Michael', + isSaving: {}, + intermediateValue: {} + }, + {id: 4, + fruits: 'Cherries', task: 'Collect', status: 'Pending', farmer: 'Sara', + isSaving: {}, + intermediateValue: {} + }, + {id: 5, + fruits: 'Dates', task: 'Collect', status: 'Pending', farmer: 'Karina', + isSaving: {}, + intermediateValue: {} + }, + {id: 6, + fruits: 'Bananas', task: 'Collect', status: 'Pending', farmer: 'Otto', + isSaving: {}, + intermediateValue: {} + }, + {id: 7, + fruits: 'Melons', task: 'Collect', status: 'Pending', farmer: 'Matt', + isSaving: {}, + intermediateValue: {} + }, + {id: 8, + fruits: 'Figs', task: 'Collect', status: 'Pending', farmer: 'Emily', + isSaving: {}, + intermediateValue: {} + }, + {id: 9, + fruits: 'Blueberries', task: 'Collect', status: 'Pending', farmer: 'Amelia', + isSaving: {}, + intermediateValue: {} + }, + {id: 10, + fruits: 'Blackberries', task: 'Collect', status: 'Pending', farmer: 'Isla', + isSaving: {}, + intermediateValue: {} + } +]; + +let editableColumns: Array & {name: string}> = [ + {name: 'Fruits', id: 'fruits', isRowHeader: true, width: '6fr', minWidth: 300}, + {name: 'Task', id: 'task', width: '2fr', minWidth: 100}, + {name: 'Status', id: 'status', width: '2fr', showDivider: true, minWidth: 100}, + {name: 'Farmer', id: 'farmer', width: '2fr', minWidth: 150} +]; + +interface EditableTableProps extends TableViewProps {} + +export const EditableTable: StoryObj = { + render: function EditableTable(props) { + let columns = editableColumns; + let [editableItems, setEditableItems] = useState(defaultItems); + let intermediateValue = useRef(null); + + // Replace all of this with real API calls, this is purely demonstrative. + let saveItem = useCallback((id: Key, columnId: Key, prevValue: any) => { + let succeeds = Math.random() > 0.5; + if (succeeds) { + setEditableItems(prev => prev.map(i => i.id === id ? {...i, isSaving: {...i.isSaving, [columnId]: false}} : i)); + } else { + setEditableItems(prev => prev.map(i => i.id === id ? {...i, [columnId]: prevValue, isSaving: {...i.isSaving, [columnId]: false}} : i)); + } + currentRequests.current.delete(id); + }, []); + let currentRequests = useRef, prevValue: any}>>(new Map()); + let onChange = useCallback((id: Key, columnId: Key) => { + let value = intermediateValue.current; + if (value === null) { + return; + } + intermediateValue.current = null; + let alreadySaving = currentRequests.current.get(id); + if (alreadySaving) { + // remove and cancel the previous request + currentRequests.current.delete(id); + clearTimeout(alreadySaving.request); + } + setEditableItems(prev => { + let prevValue = prev.find(i => i.id === id)?.[columnId]; + let newItems = prev.map(i => i.id === id && i[columnId] !== value ? {...i, [columnId]: value, isSaving: {...i.isSaving, [columnId]: true}} : i); + // set a timeout between 0 and 10s + let timeout = setTimeout(() => { + saveItem(id, columnId, alreadySaving?.prevValue ?? prevValue); + }, Math.random() * 10000); + currentRequests.current.set(id, {request: timeout, prevValue}); + return newItems; + }); + }, [saveItem]); + + let onIntermediateChange = useCallback((value: any) => { + intermediateValue.current = value; + }, []); + + return ( +
+ + + {(column) => ( + {column.name} + )} + + + {item => ( + + {(column) => { + if (column.id === 'fruits') { + return ( + onChange(item.id, column.id!)} + onCancel={() => {}} + isSaving={item.isSaving[column.id!]} + renderEditing={() => ( + value.length > 0 ? null : 'Fruit name is required'} + styles={style({flexGrow: 1, flexShrink: 1, minWidth: 0})} + defaultValue={item[column.id!]} + onChange={value => onIntermediateChange(value)} /> + )}> +
{item[column.id]}
+
+ ); + } + if (column.id === 'farmer') { + return ( + onChange(item.id, column.id!)} + onCancel={() => {}} + isSaving={item.isSaving[column.id!]} + renderEditing={() => ( + onIntermediateChange(value)}> + Eva + Steven + Michael + Sara + Karina + Otto + Matt + Emily + Amelia + Isla + + )}> +
{item[column.id]}
+
+ ); + } + if (column.id === 'status') { + return ( + + {item[column.id]} + + ); + } + return {item[column.id!]}; + }} +
+ )} +
+
+
+ ); + } +}; diff --git a/packages/@react-spectrum/s2/test/EditableTableView.test.tsx b/packages/@react-spectrum/s2/test/EditableTableView.test.tsx new file mode 100644 index 00000000000..a36b23e1559 --- /dev/null +++ b/packages/@react-spectrum/s2/test/EditableTableView.test.tsx @@ -0,0 +1,449 @@ +/* + * 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. + */ + +jest.mock('@react-aria/live-announcer'); +jest.mock('@react-aria/utils/src/scrollIntoView'); +import {act, render, within} from '@react-spectrum/test-utils-internal'; +import { + ActionButton, + Cell, + Column, + ColumnProps, + EditableCell, + Picker, + PickerItem, + Row, + StatusLight, + TableBody, + TableHeader, + TableView, + TableViewProps, + Text, + TextField +} from '../src'; +import Edit from '../s2wf-icons/S2_Icon_Edit_20_N.svg'; +import {installPointerEvent, pointerMap, User} from '@react-aria/test-utils'; +import {Key} from '@react-types/shared'; +import React, {useCallback, useRef, useState} from 'react'; +import userEvent from '@testing-library/user-event'; + +// @ts-ignore +window.getComputedStyle = (el) => el.style; + +describe('TableView', () => { + let user; + let offsetWidth, offsetHeight; + let testUtilUser = new User({advanceTimer: jest.advanceTimersByTime}); + beforeAll(function () { + offsetWidth = jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 400); + offsetHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 200); + jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50); + jest.useFakeTimers(); + }); + + beforeEach(function () { + user = userEvent.setup({delay: null, pointerMap}); + }); + + afterAll(function () { + offsetWidth.mockReset(); + offsetHeight.mockReset(); + }); + + afterEach(() => { + act(() => {jest.runAllTimers();}); + }); + let defaultItems = [ + {id: 1, + fruits: 'Apples', task: 'Collect', status: 'Pending', farmer: 'Eva', + isSaving: {}, + intermediateValue: {} + }, + {id: 2, + fruits: 'Oranges', task: 'Collect', status: 'Pending', farmer: 'Steven', + isSaving: {}, + intermediateValue: {} + }, + {id: 3, + fruits: 'Pears', task: 'Collect', status: 'Pending', farmer: 'Michael', + isSaving: {}, + intermediateValue: {} + }, + {id: 4, + fruits: 'Cherries', task: 'Collect', status: 'Pending', farmer: 'Sara', + isSaving: {}, + intermediateValue: {} + }, + {id: 5, + fruits: 'Dates', task: 'Collect', status: 'Pending', farmer: 'Karina', + isSaving: {}, + intermediateValue: {} + }, + {id: 6, + fruits: 'Bananas', task: 'Collect', status: 'Pending', farmer: 'Otto', + isSaving: {}, + intermediateValue: {} + }, + {id: 7, + fruits: 'Melons', task: 'Collect', status: 'Pending', farmer: 'Matt', + isSaving: {}, + intermediateValue: {} + }, + {id: 8, + fruits: 'Figs', task: 'Collect', status: 'Pending', farmer: 'Emily', + isSaving: {}, + intermediateValue: {} + }, + {id: 9, + fruits: 'Blueberries', task: 'Collect', status: 'Pending', farmer: 'Amelia', + isSaving: {}, + intermediateValue: {} + }, + {id: 10, + fruits: 'Blackberries', task: 'Collect', status: 'Pending', farmer: 'Isla', + isSaving: {}, + intermediateValue: {} + } + ]; + + let editableColumns: Array & {name: string}> = [ + {name: 'Fruits', id: 'fruits', isRowHeader: true, width: '6fr', minWidth: 300}, + {name: 'Task', id: 'task', width: '2fr', minWidth: 100}, + {name: 'Status', id: 'status', width: '2fr', showDivider: true, minWidth: 100}, + {name: 'Farmer', id: 'farmer', width: '2fr', minWidth: 150} + ]; + + interface EditableTableProps extends TableViewProps {} + + function EditableTable(props: EditableTableProps & {delay?: number}) { + let {delay = 0} = props; + let columns = editableColumns; + let [editableItems, setEditableItems] = useState(defaultItems); + let intermediateValue = useRef(null); + + let saveItem = useCallback((id: Key, columnId: Key) => { + setEditableItems(prev => prev.map(i => i.id === id ? {...i, isSaving: {...i.isSaving, [columnId]: false}} : i)); + currentRequests.current.delete(id); + }, []); + let currentRequests = useRef}>>(new Map()); + let onChange = useCallback((id: Key, columnId: Key) => { + let value = intermediateValue.current; + if (value === null) { + return; + } + intermediateValue.current = null; + let alreadySaving = currentRequests.current.get(id); + if (alreadySaving) { + // remove and cancel the previous request + currentRequests.current.delete(id); + clearTimeout(alreadySaving.request); + } + setEditableItems(prev => { + let newItems = prev.map(i => i.id === id && i[columnId] !== value ? {...i, [columnId]: value, isSaving: {...i.isSaving, [columnId]: true}} : i); + let timeout = setTimeout(() => { + saveItem(id, columnId); + }, delay); + currentRequests.current.set(id, {request: timeout}); + return newItems; + }); + }, [saveItem, delay]); + + let onIntermediateChange = useCallback((value: any) => { + intermediateValue.current = value; + }, []); + + return ( +
+ + + {(column) => ( + {column.name} + )} + + + {item => ( + + {(column) => { + if (column.id === 'fruits') { + return ( + onChange(item.id, column.id!)} + onCancel={() => {}} + isSaving={item.isSaving[column.id!]} + renderEditing={() => ( + value.length > 0 ? null : 'Fruit name is required'} + defaultValue={item[column.id!]} + onChange={value => onIntermediateChange(value)} /> + )}> +
{item[column.id]}
+
+ ); + } + if (column.id === 'farmer') { + return ( + onChange(item.id, column.id!)} + onCancel={() => {}} + isSaving={item.isSaving[column.id!]} + renderEditing={() => ( + onIntermediateChange(value)}> + Eva + Steven + Michael + Sara + Karina + Otto + Matt + Emily + Amelia + Isla + + )}> +
{item[column.id]}
+
+ ); + } + if (column.id === 'status') { + return ( + + {item[column.id]} + + ); + } + return {item[column.id!]}; + }} +
+ )} +
+
+ +
+ ); + } + + describe('keyboard', () => { + it('should edit text in a cell either through a TextField or a Picker', async () => { + let {getByRole} = render( + + ); + + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + + let input = within(dialog).getByRole('textbox'); + expect(input).toHaveFocus(); + + await user.keyboard(' Crisp'); + await user.keyboard('{Enter}'); // implicitly submit through form + + act(() => {jest.runAllTimers();}); + + expect(dialog).not.toBeInTheDocument(); + + expect(tableTester.findRow({rowIndexOrText: 'Apples Crisp'})).toBeInTheDocument(); + + // navigate to Farmer column + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + + dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + + let selectTester = testUtilUser.createTester('Select', {root: dialog}); + expect(selectTester.trigger).toHaveFocus(); + await selectTester.selectOption({option: 'Steven'}); + act(() => {jest.runAllTimers();}); + await user.tab(); + await user.tab(); + expect(within(dialog).getByRole('button', {name: 'Save'})).toHaveFocus(); + await user.keyboard('{Enter}'); + + act(() => {jest.runAllTimers();}); + + expect(dialog).not.toBeInTheDocument(); + expect(within(tableTester.findRow({rowIndexOrText: 'Apples Crisp'})).getByText('Steven')).toBeInTheDocument(); + + await user.tab(); + expect(getByRole('button', {name: 'After'})).toHaveFocus(); + + await user.tab({shift: true}); + expect(within(tableTester.findRow({rowIndexOrText: 'Apples Crisp'})).getByRole('button', {name: 'Edit farmer'})).toHaveFocus(); + }); + + it('should perform validation when editing text in a cell', async () => { + let {getByRole} = render( + + ); + + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + + let input = within(dialog).getByRole('textbox'); + expect(input).toHaveFocus(); + + await user.clear(input); + await user.keyboard('{Enter}'); + + act(() => {jest.runAllTimers();}); + + expect(dialog).toBeInTheDocument(); + expect(input).toHaveFocus(); + expect(document.getElementById(input.getAttribute('aria-describedby')!)).toHaveTextContent('Fruit name is required'); + + await user.keyboard('Peaches'); + await user.tab(); + await user.tab(); + await user.keyboard('{Enter}'); + + act(() => {jest.runAllTimers();}); + + expect(dialog).not.toBeInTheDocument(); + + expect(tableTester.findRow({rowIndexOrText: 'Peaches'})).toBeInTheDocument(); + }); + + it('should be cancellable through the buttons in the dialog', async () => { + let {getByRole} = render( + + ); + + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + + let input = within(dialog).getByRole('textbox'); + expect(input).toHaveFocus(); + + await user.keyboard(' Crisp'); + await user.tab(); + await user.keyboard('{Enter}'); + + act(() => {jest.runAllTimers();}); + + expect(dialog).not.toBeInTheDocument(); + + expect(tableTester.findRow({rowIndexOrText: 'Apples'})).toBeInTheDocument(); + }); + + it('should be cancellable through Escape key', async () => { + let {getByRole} = render( + + ); + + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + + let input = within(dialog).getByRole('textbox'); + expect(input).toHaveFocus(); + + await user.keyboard(' Crisp'); + await user.keyboard('{Escape}'); + + act(() => {jest.runAllTimers();}); + + expect(dialog).not.toBeInTheDocument(); + expect(tableTester.findRow({rowIndexOrText: 'Apples'})).toBeInTheDocument(); + }); + }); + + describe('pointer', () => { + installPointerEvent(); + + it('should edit text in a cell', async () => { + let {getByRole} = render( + + ); + + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + await user.click(within(tableTester.findCell({text: 'Apples'})).getByRole('button')); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + + await user.click(within(dialog).getByRole('textbox')); + await user.keyboard(' Crisp'); + await user.click(document.body); + + act(() => {jest.runAllTimers();}); + + expect(dialog).not.toBeInTheDocument(); + expect(tableTester.findRow({rowIndexOrText: 'Apples Crisp'})).toBeInTheDocument(); + }); + }); + + describe('pending', () => { + it('should display a pending state when editing a cell', async () => { + let {getByRole} = render( + + ); + + let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + + let input = within(dialog).getByRole('textbox'); + expect(input).toHaveFocus(); + + await user.keyboard(' Crisp'); + await user.keyboard('{Enter}'); // implicitly submit through form + + act(() => {jest.advanceTimersByTime(5000);}); + + expect(dialog).not.toBeInTheDocument(); + expect(tableTester.findRow({rowIndexOrText: 'Apples Crisp'})).toBeInTheDocument(); + let button = within(tableTester.findCell({text: 'Apples Crisp'})).getByRole('button'); + expect(button).toHaveAttribute('aria-disabled', 'true'); + expect(button).toHaveFocus(); + + act(() => {jest.runAllTimers();}); + + expect(button).not.toHaveAttribute('aria-disabled'); + expect(button).toHaveFocus(); + }); + }); +}); diff --git a/patches/@parcel+transformer-css+2.0.0-dev.1601.patch b/patches/@parcel+transformer-css+2.0.0-dev.1601.patch index b4994ddae4e..67a46d99bc7 100644 --- a/patches/@parcel+transformer-css+2.0.0-dev.1601.patch +++ b/patches/@parcel+transformer-css+2.0.0-dev.1601.patch @@ -6,7 +6,7 @@ index b8dd9f2..0bad8ab 100644 } else if (Array.isArray(contents === null || contents === void 0 || (_contents$cssModules4 = contents.cssModules) === null || _contents$cssModules4 === void 0 ? void 0 : _contents$cssModules4.exclude)) { contents.cssModules.exclude = contents.cssModules.exclude.map(exclude => typeof exclude === 'string' ? (0, _utils().globToRegex)(exclude) : exclude); } -+ if (config.searchPath.endsWith('home.global.css')) { ++ if (config.searchPath.endsWith('home.global.css') || /packages.@react-spectrum.s2/.test(config.searchPath)) { + contents = {...contents, pseudoClasses: null}; + } return contents;