diff --git a/packages/@react-aria/dnd/src/DragPreview.tsx b/packages/@react-aria/dnd/src/DragPreview.tsx index 8b49156cbb5..7684602e547 100644 --- a/packages/@react-aria/dnd/src/DragPreview.tsx +++ b/packages/@react-aria/dnd/src/DragPreview.tsx @@ -74,7 +74,7 @@ React.forwardRef(function DragPreview(props: DragPreviewProps, ref: ForwardedRef } return ( -
+
{children}
); diff --git a/packages/@react-aria/dnd/test/dnd.test.js b/packages/@react-aria/dnd/test/dnd.test.js index e470f91ae33..36f984c5179 100644 --- a/packages/@react-aria/dnd/test/dnd.test.js +++ b/packages/@react-aria/dnd/test/dnd.test.js @@ -1337,8 +1337,8 @@ describe('useDrag and useDrop', function () { top: 0, x: 0, y: 0, - width: this.style.position === 'absolute' ? 20 : 100, - height: this.style.position === 'absolute' ? 20 : 50 + width: this.style.position === 'fixed' ? 20 : 100, + height: this.style.position === 'fixed' ? 20 : 50 }; }); @@ -1364,8 +1364,8 @@ describe('useDrag and useDrop', function () { top: 0, x: 0, y: 0, - width: this.style.position === 'absolute' ? 20 : 100, - height: this.style.position === 'absolute' ? 20 : 50 + width: this.style.position === 'fixed' ? 20 : 100, + height: this.style.position === 'fixed' ? 20 : 50 }; }); @@ -1391,8 +1391,8 @@ describe('useDrag and useDrop', function () { top: 0, x: 0, y: 0, - width: this.style.position === 'absolute' ? 20 : 100, - height: this.style.position === 'absolute' ? 20 : 50 + width: this.style.position === 'fixed' ? 20 : 100, + height: this.style.position === 'fixed' ? 20 : 50 }; }); diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index b7af9870fc1..38540ac0b9e 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -10,27 +10,21 @@ * 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, @@ -50,16 +44,11 @@ import { useTableOptions, Virtualizer } from 'react-aria-components'; -import {ButtonGroup} from './ButtonGroup'; -import {centerPadding, colorScheme, controlFont, getAllowedOverrides, StylesPropWithHeight, UnsafeStyles} from './style-utils' with {type: 'macro'}; +import {centerPadding, 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 {CustomDialog, DialogContainer} from '..'; import {DOMRef, DOMRefValue, forwardRefType, GlobalDOMAttributes, LoadingState, Node} from '@react-types/shared'; -import {getActiveElement, getOwnerDocument, useLayoutEffect, useObjectRef} from '@react-aria/utils'; import {GridNode} from '@react-types/grid'; import {IconContext} from './Icon'; // @ts-ignore @@ -69,12 +58,11 @@ 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, CSSProperties, FormEvent, FormHTMLAttributes, ForwardedRef, forwardRef, ReactElement, ReactNode, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import React, {createContext, forwardRef, ReactElement, ReactNode, 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 {Button as SpectrumButton} from './Button'; import {useActionBarContainer} from './ActionBar'; -import {useDOMRef, useMediaQuery} from '@react-spectrum/utils'; +import {useDOMRef} from '@react-spectrum/utils'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; @@ -1059,308 +1047,6 @@ export const Cell = forwardRef(function Cell(props: CellProps, ref: DOMRef({ - ...commonCellStyles, - color: { - default: baseColor('neutral'), - isSaving: baseColor('neutral-subdued') - }, - paddingY: centerPadding(), - boxSizing: 'border-box', - height: 'calc(100% - 1px)', // so we don't overlap the border of the next cell - width: 'full', - fontSize: controlFont(), - alignItems: 'center', - display: 'flex', - borderStyle: { - default: 'none', - isDivider: 'solid' - }, - borderEndWidth: { - default: 0, - isDivider: 1 - }, - borderColor: { - default: 'gray-300', - forcedColors: 'ButtonBorder' - } -}); - -let editPopover = style({ - ...colorScheme(), - '--s2-container-bg': { - type: 'backgroundColor', - value: 'layer-2' - }, - backgroundColor: '--s2-container-bg', - borderBottomRadius: 'default', - // Use box-shadow instead of filter when an arrow is not shown. - // This fixes the shadow stacking problem with submenus. - boxShadow: 'elevated', - borderStyle: 'solid', - borderWidth: 1, - borderColor: { - default: 'gray-200', - forcedColors: 'ButtonBorder' - }, - boxSizing: 'content-box', - isolation: 'isolate', - pointerEvents: { - isExiting: 'none' - }, - outlineStyle: 'none', - minWidth: '--trigger-width', - padding: 8, - display: 'flex', - alignItems: 'center' -}, getAllowedOverrides()); - -interface EditableCellProps extends Omit { - /** The component which will handle editing the cell. For example, a `TextField` or a `Picker`. */ - renderEditing: () => ReactNode, - /** Whether the cell is currently being saved. */ - isSaving?: boolean, - /** Handler that is called when the value has been changed and is ready to be saved. */ - onSubmit?: (e: FormEvent) => void, - /** Handler that is called when the user cancels the edit. */ - onCancel?: () => void, - /** The action to submit the form to. Only available in React 19+. */ - action?: string | FormHTMLAttributes['action'] -} - -/** - * An editable cell within a table row. - */ -export const EditableCell = forwardRef(function EditableCell(props: EditableCellProps, ref: ForwardedRef) { - let {children, showDivider = false, textValue, isSaving, ...otherProps} = props; - let tableVisualOptions = useContext(InternalTableContext); - let domRef = useObjectRef(ref); - textValue ||= typeof children === 'string' ? children : undefined; - - return ( - editableCell({ - ...renderProps, - ...tableVisualOptions, - isDivider: showDivider, - isSaving - })} - textValue={textValue} - {...otherProps}> - {({isFocusVisible}) => ( - } /> - )} - - ); -}); - -const nonTextInputTypes = new Set([ - 'checkbox', - 'radio', - 'range', - 'color', - 'file', - 'image', - 'button', - 'submit', - 'reset' -]); - -function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, cellRef: RefObject}) { - let {children, align, renderEditing, isSaving, onSubmit, isFocusVisible, cellRef, action, onCancel} = 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 dialogRef = useRef>(null); - - 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]); - - // Auto select the entire text range of the autofocused input on overlay opening - // Maybe replace with FocusScope or one of those utilities - useEffect(() => { - if (isOpen) { - let activeElement = getActiveElement(getOwnerDocument(formRef.current)); - if (activeElement - && formRef.current?.contains(activeElement) - // not going to handle contenteditable https://stackoverflow.com/questions/6139107/programmatically-select-text-in-a-contenteditable-html-element - // seems like an edge case anyways - && ( - (activeElement instanceof HTMLInputElement && !nonTextInputTypes.has(activeElement.type)) - || activeElement instanceof HTMLTextAreaElement) - && typeof activeElement.select === 'function') { - activeElement.select(); - } - } - }, [isOpen]); - - let cancel = useCallback(() => { - setIsOpen(false); - onCancel?.(); - }, [onCancel]); - - let isMobile = !useMediaQuery('(hover: hover) and (pointer: fine)'); - // Can't differentiate between Dialog click outside dismissal and Escape key dismissal - let prevIsOpen = useRef(isOpen); - useEffect(() => { - let dialog = dialogRef.current?.UNSAFE_getDOMNode(); - if (isOpen && dialog && !prevIsOpen.current) { - let handler = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - cancel(); - e.stopPropagation(); - e.preventDefault(); - } - }; - dialog.addEventListener('keydown', handler); - prevIsOpen.current = isOpen; - return () => { - dialog.removeEventListener('keydown', handler); - }; - } - prevIsOpen.current = isOpen; - }, [isOpen, cancel]); - - 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 - // Could use `hidden` attribute instead of css, but I don't have access to much of this state at the moment - visibility: { - default: 'hidden', - isForcedVisible: 'visible', - ':is([role="row"]:hover *)': 'visible', - ':is([role="row"][data-focus-visible-within] *)': 'visible', - '@media not ((hover: hover) and (pointer: fine))': 'visible' - } - })({isForcedVisible: isOpen || !!isSaving}) - } - } - }] - ]}> - {children} - {isFocusVisible && } - - - {!isMobile && ( - { - if (!popoverRef.current?.contains(document.activeElement)) { - return false; - } - formRef.current?.requestSubmit(); - return false; - }} - triggerRef={cellRef} - aria-label={props['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}> - -
{ - onSubmit?.(e); - setIsOpen(false); - }} - className={style({width: 'full', display: 'flex', alignItems: 'start', gap: 16})} - style={{'--input-width': `calc(${triggerWidth}px - 32px)`} as CSSProperties}> - {renderEditing()} -
- - -
-
-
-
- )} - {isMobile && ( - formRef.current?.requestSubmit()}> - {isOpen && ( - -
{ - onSubmit?.(e); - setIsOpen(false); - }} - className={style({width: 'full', display: 'flex', flexDirection: 'column', alignItems: 'start', gap: 16})}> - {renderEditing()} - - Cancel - Save - -
-
- )} -
- )} -
-
- ); -}; - // 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 25629e375f2..6fbe68b7e52 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, EditableCell} from './TableView'; +export {TableView, TableHeader, TableBody, Row, Cell, Column, TableContext} 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 c637a16981f..891eeceaa42 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -15,37 +15,27 @@ import { ActionButton, Cell, Column, - ColumnProps, Content, - EditableCell, Heading, IllustratedMessage, Link, MenuItem, MenuSection, - Picker, - PickerItem, Row, - StatusLight, TableBody, TableHeader, TableView, TableViewProps, - Text, - TextField + Text } 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 React, {ReactElement, useCallback, useEffect, useRef, useState} from 'react'; +import React, {ReactElement, useState} from 'react'; import {SortDescriptor} from 'react-aria-components'; import {style} from '../style/spectrum-theme' with {type: 'macro'}; -import {useAsyncList, useListData} from '@react-stately/data'; -import {useEffectEvent} from '@react-aria/utils'; -import User from '../s2wf-icons/S2_Icon_User_20_N.svg'; +import {useAsyncList} from '@react-stately/data'; let onActionFunc = action('onAction'); let noOnAction = null; @@ -1395,305 +1385,3 @@ 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 data = useListData({initialItems: defaultItems}); - - let onChange = useCallback((id: Key, columnId: Key, values: any) => { - let value = values[columnId]; - if (value === null) { - return; - } - data.update(id, (prevItem) => ({...prevItem, [columnId]: value})); - }, [data]); - - return ( -
- - - {(column) => ( - {column.name} - )} - - - {item => ( - - {(column) => { - if (column.id === 'fruits') { - return ( - { - e.preventDefault(); - let formData = new FormData(e.target as HTMLFormElement); - let values = Object.fromEntries(formData.entries()); - onChange(item.id, column.id!, values); - }} - 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!]} /> - )}> -
- {item[column.id]} - - -
-
- ); - } - if (column.id === 'farmer') { - return ( - { - e.preventDefault(); - let formData = new FormData(e.target as HTMLFormElement); - let values = Object.fromEntries(formData.entries()); - onChange(item.id, column.id!, values); - }} - isSaving={item.isSaving[column.id!]} - renderEditing={() => ( - - 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!]}; - }} -
- )} -
-
-
- ); - } -}; - -export const EditableTableWithAsyncSaving: StoryObj = { - render: function EditableTable(props) { - let delay = 5000; - let columns = editableColumns; - let data = useListData({initialItems: defaultItems}); - - let saveItem = useEffectEvent((id: Key, columnId: Key) => { - let prevItem = data.getItem(id)!; - data.update(id, {...prevItem, isSaving: {...prevItem.isSaving, [columnId]: false}}); - currentRequests.current.delete(id); - }); - let currentRequests = useRef}>>(new Map()); - let onChange = useCallback((id: Key, columnId: Key, values: any) => { - let value = values[columnId]; - if (value === null) { - return; - } - let alreadySaving = currentRequests.current.get(id); - if (alreadySaving) { - // remove and cancel the previous request - currentRequests.current.delete(id); - clearTimeout(alreadySaving.request); - } - let prevItem = data.getItem(id)!; - data.update(id, {...prevItem, [columnId]: value, isSaving: {...prevItem.isSaving, [columnId]: true}}); - }, [data]); - - useEffect(() => { - // if any item is saving and we don't have a request for it, start a timer to commit it - for (const item of data.items) { - for (const columnId in item.isSaving) { - if (item.isSaving[columnId] && !currentRequests.current.has(item.id)) { - let timeout = setTimeout(() => { - saveItem(item.id, columnId); - }, delay); - currentRequests.current.set(item.id, {request: timeout}); - } - } - } - }, [data, delay]); - - return ( -
- - - {(column) => ( - {column.name} - )} - - - {item => ( - - {(column) => { - if (column.id === 'fruits') { - return ( - { - e.preventDefault(); - let formData = new FormData(e.target as HTMLFormElement); - let values = Object.fromEntries(formData.entries()); - onChange(item.id, column.id!, values); - }} - 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!]} - name={column.id! as string} /> - )}> -
{item[column.id]}
-
- ); - } - if (column.id === 'farmer') { - return ( - { - e.preventDefault(); - let formData = new FormData(e.target as HTMLFormElement); - let values = Object.fromEntries(formData.entries()); - onChange(item.id, column.id!, values); - }} - isSaving={item.isSaving[column.id!]} - renderEditing={() => ( - - 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 deleted file mode 100644 index 560fdd4a338..00000000000 --- a/packages/@react-spectrum/s2/test/EditableTableView.test.tsx +++ /dev/null @@ -1,868 +0,0 @@ -/* - * Copyright 2025 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -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, useEffect, useRef} from 'react'; -import {useEffectEvent} from '@react-aria/utils'; -import {useListData} from '@react-stately/data'; -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: {} - }, - {id: 2, - fruits: 'Oranges', task: 'Collect', status: 'Pending', farmer: 'Steven', - isSaving: {} - }, - {id: 3, - fruits: 'Pears', task: 'Collect', status: 'Pending', farmer: 'Michael', - isSaving: {} - }, - {id: 4, - fruits: 'Cherries', task: 'Collect', status: 'Pending', farmer: 'Sara', - isSaving: {} - }, - {id: 5, - fruits: 'Dates', task: 'Collect', status: 'Pending', farmer: 'Karina', - isSaving: {} - }, - {id: 6, - fruits: 'Bananas', task: 'Collect', status: 'Pending', farmer: 'Otto', - isSaving: {} - }, - {id: 7, - fruits: 'Melons', task: 'Collect', status: 'Pending', farmer: 'Matt', - isSaving: {} - }, - {id: 8, - fruits: 'Figs', task: 'Collect', status: 'Pending', farmer: 'Emily', - isSaving: {} - }, - {id: 9, - fruits: 'Blueberries', task: 'Collect', status: 'Pending', farmer: 'Amelia', - isSaving: {} - }, - {id: 10, - fruits: 'Blackberries', task: 'Collect', status: 'Pending', farmer: 'Isla', - isSaving: {} - } - ]; - - 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, onCancel?: () => void}) { - let {delay = 0, onCancel} = props; - let columns = editableColumns; - let data = useListData({initialItems: defaultItems}); - - let saveItem = useEffectEvent((id: Key, columnId: Key) => { - data.update(id, (prevItem) => ({...prevItem, isSaving: {...prevItem.isSaving, [columnId]: false}})); - currentRequests.current.delete(id); - }); - let currentRequests = useRef}>>(new Map()); - let onChange = useCallback((id: Key, columnId: Key, values: any) => { - let value = values[columnId]; - if (value === null) { - return; - } - let alreadySaving = currentRequests.current.get(id); - if (alreadySaving) { - // remove and cancel the previous request - currentRequests.current.delete(id); - clearTimeout(alreadySaving.request); - } - data.update(id, (prevItem) => ({...prevItem, [columnId]: value, isSaving: {...prevItem.isSaving, [columnId]: true}})); - }, [data]); - - useEffect(() => { - // if any item is saving and we don't have a request for it, start a timer to commit it - for (const item of data.items) { - for (const columnId in item.isSaving) { - if (item.isSaving[columnId] && !currentRequests.current.has(item.id)) { - let timeout = setTimeout(() => { - saveItem(item.id, columnId); - }, delay); - currentRequests.current.set(item.id, {request: timeout}); - } - } - } - }, [data, delay]); - - return ( -
- - - {(column) => ( - {column.name} - )} - - - {item => ( - - {(column) => { - if (column.id === 'fruits') { - return ( - { - e.preventDefault(); - let formData = new FormData(e.target as HTMLFormElement); - let values = Object.fromEntries(formData.entries()); - onChange(item.id, column.id!, values); - }} - onCancel={onCancel} - isSaving={item.isSaving[column.id!]} - renderEditing={() => ( - value.length > 0 ? null : 'Fruit name is required'} - defaultValue={item[column.id!]} - name={column.id! as string} /> - )}> -
{item[column.id]}
-
- ); - } - if (column.id === 'farmer') { - return ( - { - e.preventDefault(); - let formData = new FormData(e.target as HTMLFormElement); - let values = Object.fromEntries(formData.entries()); - onChange(item.id, column.id!, values); - }} - onCancel={onCancel} - isSaving={item.isSaving[column.id!]} - renderEditing={() => ( - - 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}'); - let dialogTrigger = document.activeElement! as HTMLElement; - let dialogTester = testUtilUser.createTester('Dialog', {root: dialogTrigger, interactionType: 'keyboard', overlayType: 'modal'}); - await dialogTester.open(); - let dialog = dialogTester.dialog; - expect(dialog).toBeVisible(); - - let input = within(dialog!).getByRole('textbox'); - expect(input).toHaveFocus(); - - await user.keyboard('Apples 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}'); - dialogTrigger = document.activeElement! as HTMLElement; - dialogTester = testUtilUser.createTester('Dialog', {root: dialogTrigger, interactionType: 'keyboard', overlayType: 'modal'}); - await dialogTester.open(); - dialog = dialogTester.dialog; - // TODO: also weird that it is dialog.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 onCancel = jest.fn(); - 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(); - expect(onCancel).toHaveBeenCalled(); - }); - - it('should be cancellable through Escape key', async () => { - let onCancel = jest.fn(); - 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(); - expect(onCancel).toHaveBeenCalled(); - }); - }); - - 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('Apples 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(); - }); - - it('should allow tabbing off a pending button', 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('Apples 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(); - - await user.tab(); - expect(getByRole('button', {name: 'After'})).toHaveFocus(); - - act(() => {jest.runAllTimers();}); - - expect(button).not.toHaveAttribute('aria-disabled'); - }); - }); - - if (parseInt(React.version, 10) >= 19) { - describe('using action instead of onSubmit', () => { - function ActionEditableTable(props: EditableTableProps & {delay?: number, onCancel?: () => void}) { - let {delay = 0, onCancel} = props; - let columns = editableColumns; - let data = useListData({initialItems: defaultItems}); - - let saveItem = useEffectEvent((id: Key, columnId: Key) => { - data.update(id, (prevItem) => ({...prevItem, isSaving: {...prevItem.isSaving, [columnId]: false}})); - currentRequests.current.delete(id); - }); - let currentRequests = useRef}>>(new Map()); - let onChange = useCallback((id: Key, columnId: Key, values: any) => { - let value = values.get(columnId); - if (value === null) { - return; - } - let alreadySaving = currentRequests.current.get(id); - if (alreadySaving) { - // remove and cancel the previous request - currentRequests.current.delete(id); - clearTimeout(alreadySaving.request); - } - data.update(id, (prevItem) => ({...prevItem, [columnId]: value, isSaving: {...prevItem.isSaving, [columnId]: true}})); - }, [data]); - - useEffect(() => { - // if any item is saving and we don't have a request for it, start a timer to commit it - for (const item of data.items) { - for (const columnId in item.isSaving) { - if (item.isSaving[columnId] && !currentRequests.current.has(item.id)) { - let timeout = setTimeout(() => { - saveItem(item.id, columnId); - }, delay); - currentRequests.current.set(item.id, {request: timeout}); - } - } - } - }, [data, delay]); - - return ( -
- - - {(column) => ( - {column.name} - )} - - - {item => ( - - {(column) => { - if (column.id === 'fruits') { - return ( - { - onChange(item.id, column.id!, e); - }} - onCancel={onCancel} - isSaving={item.isSaving[column.id!]} - renderEditing={() => ( - value.length > 0 ? null : 'Fruit name is required'} - defaultValue={item[column.id!]} - name={column.id! as string} /> - )}> -
{item[column.id]}
-
- ); - } - if (column.id === 'farmer') { - return ( - { - onChange(item.id, column.id!, e); - }} - onCancel={onCancel} - isSaving={item.isSaving[column.id!]} - renderEditing={() => ( - - 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}'); - let dialogTrigger = document.activeElement! as HTMLElement; - let dialogTester = testUtilUser.createTester('Dialog', {root: dialogTrigger, interactionType: 'keyboard', overlayType: 'modal'}); - await dialogTester.open(); - let dialog = dialogTester.dialog; - expect(dialog).toBeVisible(); - - let input = within(dialog!).getByRole('textbox'); - expect(input).toHaveFocus(); - - await user.keyboard('Apples 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}'); - dialogTrigger = document.activeElement! as HTMLElement; - dialogTester = testUtilUser.createTester('Dialog', {root: dialogTrigger, interactionType: 'keyboard', overlayType: 'modal'}); - await dialogTester.open(); - dialog = dialogTester.dialog; - // TODO: also weird that it is dialog.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 onCancel = jest.fn(); - 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(); - expect(onCancel).toHaveBeenCalled(); - }); - - it('should be cancellable through Escape key', async () => { - let onCancel = jest.fn(); - 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(); - expect(onCancel).toHaveBeenCalled(); - }); - }); - - 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('Apples 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(); - }); - - it('should allow tabbing off a pending button', 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('Apples 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(); - - await user.tab(); - expect(getByRole('button', {name: 'After'})).toHaveFocus(); - - act(() => {jest.runAllTimers();}); - - expect(button).not.toHaveAttribute('aria-disabled'); - }); - }); - }); - } -}); diff --git a/packages/dev/s2-docs/pages/react-aria/index.mdx b/packages/dev/s2-docs/pages/react-aria/index.mdx index 991f6941cc4..1d8ea951a54 100644 --- a/packages/dev/s2-docs/pages/react-aria/index.mdx +++ b/packages/dev/s2-docs/pages/react-aria/index.mdx @@ -76,10 +76,10 @@ export const hideFromSearch = true;
- Blog - Releases - React Spectrum - Internationalized + page.name.includes('react-aria') && page.name.includes('blog') && page.name.includes('index.html'))[0].url} className="no-underline hidden md:inline transition text-zinc-900/80 dark:text-white/70 hover:text-zinc-900 dark:hover:text-white/90 rounded-md focus-ring dark:outline-white">Blog + page.name.includes('react-aria') && page.name.includes('releases') && page.name.includes('index.html'))[0].url}className="no-underline hidden md:inline transition text-zinc-900/80 dark:text-white/70 hover:text-zinc-900 dark:hover:text-white/90 rounded-md focus-ring dark:outline-white">Releases + page.name.includes('s2') && !page.name.includes('releases') && page.name.includes('index.html'))[0].url}className="no-underline hidden md:inline transition text-zinc-900/80 dark:text-white/70 hover:text-zinc-900 dark:hover:text-white/90 rounded-md focus-ring dark:outline-white">React Spectrum + page.name.includes('internationalized') && page.name.includes('date') && page.name.includes('index.html'))[0].url} className="no-underline hidden md:inline transition text-zinc-900/80 dark:text-white/70 hover:text-zinc-900 dark:hover:text-white/90 rounded-md focus-ring dark:outline-white">Internationalized

Over 50 components with built-in behavior, adaptive interactions, top-tier accessibility, and internationalization out of the box, ready for your styles.

diff --git a/packages/dev/s2-docs/pages/s2/TableView.mdx b/packages/dev/s2-docs/pages/s2/TableView.mdx index cc0f8b6d0cb..75e0abe4566 100644 --- a/packages/dev/s2-docs/pages/s2/TableView.mdx +++ b/packages/dev/s2-docs/pages/s2/TableView.mdx @@ -686,178 +686,6 @@ function subscribe(fn) { } ``` -## Editable Table - -`EditableCell` represents an editable value in a single cell. It opens a popover that can contain any editable input or combination of inputs when the end user clicks the user provided `ActionButton` . - -An `ActionButton` with `slot="edit"` must be provided as a child of the `EditableCell` to open the popover. - -```tsx render type="s2" -"use client"; -import {TableView, TableHeader, Column, TableBody, Row, Cell, EditableCell, TextField, ActionButton, Picker, PickerItem, Text, type Key} from '@react-spectrum/s2'; -import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; -import User from '@react-spectrum/s2/icons/User'; -import Edit from '@react-spectrum/s2/icons/Edit'; -import {useCallback} from 'react'; -import {useListData} from '@react-stately/data'; - -///- begin collapse -/// -let defaultItems = [ - {id: 1, fruits: 'Apples', task: 'Collect', farmer: 'Eva'}, - {id: 2, fruits: 'Oranges', task: 'Collect', farmer: 'Steven'}, - {id: 3, fruits: 'Pears', task: 'Collect', farmer: 'Michael'}, - {id: 4, fruits: 'Cherries', task: 'Collect', farmer: 'Sara'}, - {id: 5, fruits: 'Dates', task: 'Collect', farmer: 'Karina'}, - {id: 6, fruits: 'Bananas', task: 'Collect', farmer: 'Otto'}, - {id: 7, fruits: 'Melons', task: 'Collect', farmer: 'Matt'}, - {id: 8, fruits: 'Figs', task: 'Collect', farmer: 'Emily'}, - {id: 9, fruits: 'Blueberries', task: 'Collect', farmer: 'Amelia'}, - {id: 10, fruits: 'Blackberries', task: 'Collect', farmer: 'Isla'} -]; -///- end collapse -/// - -///- begin collapse -/// -let editableColumns: Array & {name: string}> = [ - {name: 'Fruits', id: 'fruits', isRowHeader: true, width: '6fr', minWidth: 300}, - {name: 'Task', id: 'task', width: '2fr', minWidth: 100}, - {name: 'Farmer', id: 'farmer', width: '2fr', minWidth: 150} -]; -///- end collapse -/// - -export default function EditableTable(props) { - let columns = editableColumns; - let data = useListData({initialItems: defaultItems}); - - let onChange = useCallback((id: Key, columnId: Key, values: any) => { - let value = values[columnId]; - if (value == null) { - return; - } - data.update(id, (prevItem) => ({...prevItem, [columnId]: value})); - }, [data]); - - return ( - - - {(column) => ( - {column.name} - )} - - - {item => ( - - {(column) => { - if (column.id === 'fruits') { - ///- begin highlight -/// - return ( - { - e.preventDefault(); - let formData = new FormData(e.target as HTMLFormElement); - let values = Object.fromEntries(formData.entries()); - onChange(item.id, column.id!, values); - }} - renderEditing={() => ( - value.length > 0 ? null : 'Fruit name is required'} - styles={style({flexGrow: 1, flexShrink: 1, minWidth: 0})} - defaultValue={item[column.id!]} - name={column.id! as string} /> - )}> -
- {item[column.id]} - - - -
-
- ); - ///- end highlight -/// - } - if (column.id === 'farmer') { - ///- begin highlight -/// - return ( - { - e.preventDefault(); - let formData = new FormData(e.target as HTMLFormElement); - let values = Object.fromEntries(formData.entries()); - onChange(item.id, column.id!, values); - }} - renderEditing={() => ( - - - - Eva - - - - Steven - - - - Michael - - - - Sara - - - - Karina - - - - Otto - - - - Matt - - - - Emily - - - - Amelia - - - - Isla - - - )}> -
- {item[column.id]} - -
-
- ); - ///- end highlight -/// - } - return {item[column.id!]}; - }} -
- )} -
-
- ); -} -``` - ## API ```tsx links={{TableView: '#tableview', TableHeader: '#tableheader', Column: '#column', TableBody: '#tablebody', Row: '#row', Cell: '#cell'}} @@ -896,7 +724,3 @@ export default function EditableTable(props) { ### Cell - -### EditableCell - - \ No newline at end of file diff --git a/packages/dev/s2-docs/src/CodePlatter.tsx b/packages/dev/s2-docs/src/CodePlatter.tsx index cfbf7304ab3..d9bf921bfe7 100644 --- a/packages/dev/s2-docs/src/CodePlatter.tsx +++ b/packages/dev/s2-docs/src/CodePlatter.tsx @@ -179,7 +179,7 @@ export function CodePlatter({children, type, showCoachMark}: CodePlatterProps) { } {registryUrl && diff --git a/packages/dev/s2-docs/src/Header.tsx b/packages/dev/s2-docs/src/Header.tsx index 92435f1612d..2e17a36d7fb 100644 --- a/packages/dev/s2-docs/src/Header.tsx +++ b/packages/dev/s2-docs/src/Header.tsx @@ -153,10 +153,8 @@ export default function Header(props: PageProps) { aria-controls={searchOpen ? searchMenuId : undefined} href={homepage} onKeyDown={handleActionButtonKeyDown} - // @ts-ignore - // onHoverStart={() => preloadSearchMenu()} ref={ref} - style={pressScale(ref)} + style={pressScale(ref, {visibility: searchOpen ? 'hidden' : 'visible'})} className={renderProps => libraryStyles({...renderProps})}>
diff --git a/packages/dev/s2-docs/src/Nav.tsx b/packages/dev/s2-docs/src/Nav.tsx index 0049dee13c2..13493bf4091 100644 --- a/packages/dev/s2-docs/src/Nav.tsx +++ b/packages/dev/s2-docs/src/Nav.tsx @@ -192,7 +192,7 @@ export function Nav({pages, currentPage}: PageProps) { ); } return ( - + {name}
{nav}
diff --git a/packages/dev/s2-docs/src/SearchMenu.tsx b/packages/dev/s2-docs/src/SearchMenu.tsx index 2827e0f874c..6302ec374fa 100644 --- a/packages/dev/s2-docs/src/SearchMenu.tsx +++ b/packages/dev/s2-docs/src/SearchMenu.tsx @@ -56,7 +56,8 @@ interface SearchMenuProps { currentPage: Page, onClose: () => void, overlayId?: string, - initialSearchValue: string + initialSearchValue: string, + isSearchOpen: boolean } function CloseButton({onClose}: {onClose: () => void}) { @@ -72,7 +73,7 @@ function CloseButton({onClose}: {onClose: () => void}) { } export function SearchMenu(props: SearchMenuProps) { - let {pages, currentPage, onClose, overlayId} = props; + let {pages, currentPage, onClose, overlayId, isSearchOpen} = props; const currentLibrary = getLibraryFromPage(currentPage); let [selectedLibrary, setSelectedLibrary] = useState(currentLibrary); @@ -258,11 +259,11 @@ export function SearchMenu(props: SearchMenuProps) { {orderedTabs.map((tab, i) => (
-
+
{tab.icon}
- + {tab.label}
{tab.description}
@@ -289,7 +290,7 @@ export function SearchMenu(props: SearchMenuProps) { size="L" aria-label={`Search ${tab.label}`} placeholder={placeholderText} - UNSAFE_style={{marginInlineEnd: 296, viewTransitionName: i === 0 ? 'search-menu-search-field' : 'none'} as CSSProperties} + UNSAFE_style={{marginInlineEnd: 296, viewTransitionName: (i === 0 && isSearchOpen) ? 'search-menu-search-field' : 'none'} as CSSProperties} styles={style({width: 500})} />
diff --git a/packages/dev/s2-docs/src/SearchMenuTrigger.tsx b/packages/dev/s2-docs/src/SearchMenuTrigger.tsx index f5396d847af..67d0f8b6f11 100644 --- a/packages/dev/s2-docs/src/SearchMenuTrigger.tsx +++ b/packages/dev/s2-docs/src/SearchMenuTrigger.tsx @@ -172,7 +172,7 @@ export default function SearchMenuTrigger({onOpen, onClose, isSearchOpen, overla isFocusVisible: 2 } })({isHovered, isFocusVisible})} - style={{viewTransitionName: !isSearchOpen ? 'search-menu-search-field' : 'none'} as CSSProperties}> + style={{viewTransitionName: !isSearchOpen ? 'search-menu-search-field' : 'none', visibility: isSearchOpen ? 'hidden' : 'visible'} as CSSProperties}> + initialSearchValue={initialSearchValue} + isSearchOpen={isSearchOpen} />
diff --git a/packages/dev/s2-docs/src/SearchMenuWrapper.tsx b/packages/dev/s2-docs/src/SearchMenuWrapper.tsx index e4416809c40..527b4866c00 100644 --- a/packages/dev/s2-docs/src/SearchMenuWrapper.tsx +++ b/packages/dev/s2-docs/src/SearchMenuWrapper.tsx @@ -1,13 +1,16 @@ 'use client'; import '../tailwind/home.global.css'; -import {DialogTrigger, Modal, ModalOverlay, Pressable} from 'react-aria-components'; +import {Button, DialogTrigger, Modal, ModalOverlay} from 'react-aria-components'; import {flushSync} from 'react-dom'; import {preloadSearchMenu} from './SearchMenuTrigger'; -import React, {useState} from 'react'; +import React, {lazy, useState} from 'react'; +import {Modal as S2Modal} from '../../../@react-spectrum/s2/src/Modal'; import {SearchMenu} from './SearchMenu'; import {style} from '@react-spectrum/s2/style' with { type: 'macro' }; +const MobileSearchMenu = lazy(() => import('./SearchMenu').then(({MobileSearchMenu}) => ({default: MobileSearchMenu}))); + let underlayStyle = style({ position: 'absolute', top: 0, @@ -81,20 +84,35 @@ export default function SearchMenuWrapper({pages, currentPage}) { }; return ( - {openSearchMenu();}} > - Explore Components - - - - - - + <> +
+ {openSearchMenu();}} > + + + + + + + +
+
+ + + + + + +
+ ); } + + diff --git a/packages/dev/s2-docs/tailwind/home.global.css b/packages/dev/s2-docs/tailwind/home.global.css index 14eb88978b3..530880fc0bb 100644 --- a/packages/dev/s2-docs/tailwind/home.global.css +++ b/packages/dev/s2-docs/tailwind/home.global.css @@ -32,3 +32,9 @@ body { --text-3xl: calc(1.875rem * 1.125); } } + +@layer utilities { + .font-spectrum { + font-family: "adobe-clean-spectrum-vf", system-ui, sans-serif; + } +} \ No newline at end of file