diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 38540ac0b9e..b7af9870fc1 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,11 +50,16 @@ import { useTableOptions, Virtualizer } from 'react-aria-components'; -import {centerPadding, controlFont, getAllowedOverrides, StylesPropWithHeight, UnsafeStyles} from './style-utils' with {type: 'macro'}; +import {ButtonGroup} from './ButtonGroup'; +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 {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 @@ -58,11 +69,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, FormEvent, FormHTMLAttributes, ForwardedRef, forwardRef, ReactElement, ReactNode, RefObject, useCallback, useContext, useEffect, 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} from '@react-spectrum/utils'; +import {useDOMRef, useMediaQuery} from '@react-spectrum/utils'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; @@ -1047,6 +1059,308 @@ 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 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 891eeceaa42..c637a16981f 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -15,27 +15,37 @@ 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 React, {ReactElement, useState} from 'react'; +import React, {ReactElement, useCallback, useEffect, 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 {useAsyncList, useListData} from '@react-stately/data'; +import {useEffectEvent} from '@react-aria/utils'; +import User from '../s2wf-icons/S2_Icon_User_20_N.svg'; let onActionFunc = action('onAction'); let noOnAction = null; @@ -1385,3 +1395,305 @@ 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 new file mode 100644 index 00000000000..560fdd4a338 --- /dev/null +++ b/packages/@react-spectrum/s2/test/EditableTableView.test.tsx @@ -0,0 +1,868 @@ +/* + * 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/Breadcrumbs.mdx b/packages/dev/s2-docs/pages/react-aria/Breadcrumbs.mdx index db4c779ced7..28d18faa40e 100644 --- a/packages/dev/s2-docs/pages/react-aria/Breadcrumbs.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Breadcrumbs.mdx @@ -7,6 +7,7 @@ import Anatomy from '@react-aria/breadcrumbs/docs/anatomy.svg'; import {InlineAlert, Heading, Content} from '@react-spectrum/s2'; export const tags = ['navigation']; +export const relatedPages = [{'title': 'useBreadcrumbs', 'url': 'https://react-spectrum.adobe.com/react-aria/useBreadcrumbs.html'}]; # Breadcrumbs diff --git a/packages/dev/s2-docs/pages/react-aria/Button.mdx b/packages/dev/s2-docs/pages/react-aria/Button.mdx index cae4699180f..adfb0be0c48 100644 --- a/packages/dev/s2-docs/pages/react-aria/Button.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Button.mdx @@ -12,6 +12,7 @@ import '../../tailwind/tailwind.css'; import typesDocs from 'docs:@react-types/shared/src/events.d.ts'; export const tags = ['btn']; +export const relatedPages = [{'title': 'useButton', 'url': 'https://react-spectrum.adobe.com/react-aria/useButton.html'}]; # Button diff --git a/packages/dev/s2-docs/pages/react-aria/Calendar.mdx b/packages/dev/s2-docs/pages/react-aria/Calendar.mdx index 37de09881a0..8ff28724973 100644 --- a/packages/dev/s2-docs/pages/react-aria/Calendar.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Calendar.mdx @@ -9,6 +9,7 @@ import '../../tailwind/tailwind.css'; import Anatomy from '@react-aria/calendar/docs/calendar-anatomy.svg'; export const tags = ['date']; +export const relatedPages = [{'title': 'useCalendar', 'url': 'https://react-spectrum.adobe.com/react-aria/useCalendar.html'}]; # Calendar @@ -45,7 +46,7 @@ import {useState} from 'react'; function Example() { let [date, setDate] = useState(parseDate('2020-02-03')); let formatter = useDateFormatter({ dateStyle: 'full' }); - + return ( <> (
- {i === 0 && + {i === 0 && } {monthFormatter.format(state.visibleRange.start.add({months: i}).toDate(state.timeZone))} - {i === props.visibleDuration.months - 1 && + {i === props.visibleDuration.months - 1 && diff --git a/packages/dev/s2-docs/pages/react-aria/Checkbox.mdx b/packages/dev/s2-docs/pages/react-aria/Checkbox.mdx index 42195a062d2..b6b324bd59b 100644 --- a/packages/dev/s2-docs/pages/react-aria/Checkbox.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Checkbox.mdx @@ -8,6 +8,7 @@ import '../../tailwind/tailwind.css'; import Anatomy from '@react-aria/checkbox/docs/checkbox-anatomy.svg'; export const tags = ['input']; +export const relatedPages = [{'title': 'useCheckbox', 'url': 'https://react-spectrum.adobe.com/react-aria/useCheckbox.html'}]; # Checkbox @@ -46,7 +47,7 @@ function Example(props) { return ( <> - - ( ['text/plain', 'image/jpeg', 'image/png', 'image.gif'].some(t => types.has(t)) - ? 'copy' + ? 'copy' : 'cancel' )} onDrop={async (event) => { diff --git a/packages/dev/s2-docs/pages/react-aria/GridList.mdx b/packages/dev/s2-docs/pages/react-aria/GridList.mdx index 5f86d9bf1dd..b6b262272e9 100644 --- a/packages/dev/s2-docs/pages/react-aria/GridList.mdx +++ b/packages/dev/s2-docs/pages/react-aria/GridList.mdx @@ -8,6 +8,7 @@ import '../../tailwind/tailwind.css'; import Anatomy from 'react-aria-components/docs/GridListAnatomy.svg'; export const tags = ['list view']; +export const relatedPages = [{'title': 'useGridList', 'url': 'https://react-spectrum.adobe.com/react-aria/useGridList.html'}]; # GridList diff --git a/packages/dev/s2-docs/pages/react-aria/Link.mdx b/packages/dev/s2-docs/pages/react-aria/Link.mdx index e0e725efa10..2e9c5308a2c 100644 --- a/packages/dev/s2-docs/pages/react-aria/Link.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Link.mdx @@ -8,6 +8,7 @@ import tailwindDocs from 'docs:tailwind-starter/Link'; import '../../tailwind/tailwind.css'; export const tags = ['anchor', 'hyperlink', 'href']; +export const relatedPages = [{'title': 'useLink', 'url': 'https://react-spectrum.adobe.com/react-aria/useLink.html'}]; # Link diff --git a/packages/dev/s2-docs/pages/react-aria/ListBox.mdx b/packages/dev/s2-docs/pages/react-aria/ListBox.mdx index 93fbcd78ccf..19a488f91be 100644 --- a/packages/dev/s2-docs/pages/react-aria/ListBox.mdx +++ b/packages/dev/s2-docs/pages/react-aria/ListBox.mdx @@ -9,6 +9,7 @@ import Anatomy from 'react-aria-components/docs/ListBoxAnatomy.svg'; import {InlineAlert, Heading, Content} from '@react-spectrum/s2' export const tags = ['options']; +export const relatedPages = [{'title': 'useListBox', 'url': 'https://react-spectrum.adobe.com/react-aria/useListBox.html'}]; # ListBox diff --git a/packages/dev/s2-docs/pages/react-aria/Menu.mdx b/packages/dev/s2-docs/pages/react-aria/Menu.mdx index d20963fb7a2..9208e444b51 100644 --- a/packages/dev/s2-docs/pages/react-aria/Menu.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Menu.mdx @@ -7,6 +7,7 @@ import Anatomy from 'react-aria-components/docs/MenuAnatomy.svg'; import {InlineAlert, Heading, Content} from '@react-spectrum/s2' export const tags = ['dropdown']; +export const relatedPages = [{'title': 'useMenu', 'url': 'https://react-spectrum.adobe.com/react-aria/useMenu.html'}]; # Menu diff --git a/packages/dev/s2-docs/pages/react-aria/Meter.mdx b/packages/dev/s2-docs/pages/react-aria/Meter.mdx index b63416e706e..249980dbe37 100644 --- a/packages/dev/s2-docs/pages/react-aria/Meter.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Meter.mdx @@ -9,6 +9,7 @@ import '../../tailwind/tailwind.css'; import Anatomy from '@react-aria/meter/docs/anatomy.svg'; export const tags = ['gauge', 'progress', 'level']; +export const relatedPages = [{'title': 'useMeter', 'url': 'https://react-spectrum.adobe.com/react-aria/useMeter.html'}]; # Meter diff --git a/packages/dev/s2-docs/pages/react-aria/Modal.mdx b/packages/dev/s2-docs/pages/react-aria/Modal.mdx index 4d661b6f9c7..14e1876af64 100644 --- a/packages/dev/s2-docs/pages/react-aria/Modal.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Modal.mdx @@ -8,6 +8,7 @@ import Anatomy from '@react-aria/overlays/docs/modal-anatomy.svg'; import {InlineAlert, Heading, Content} from '@react-spectrum/s2' export const tags = ['dialog', 'popup', 'overlay']; +export const relatedPages = [{'title': 'useModalOverlay', 'url': 'https://react-spectrum.adobe.com/react-aria/useModalOverlay.html'}]; # Modal diff --git a/packages/dev/s2-docs/pages/react-aria/NumberField.mdx b/packages/dev/s2-docs/pages/react-aria/NumberField.mdx index 33e2dba7671..3331df35bcf 100644 --- a/packages/dev/s2-docs/pages/react-aria/NumberField.mdx +++ b/packages/dev/s2-docs/pages/react-aria/NumberField.mdx @@ -9,6 +9,7 @@ import '../../tailwind/tailwind.css'; import Anatomy from '@react-aria/numberfield/docs/anatomy.svg'; export const tags = ['input']; +export const relatedPages = [{'title': 'useNumberField', 'url': 'https://react-spectrum.adobe.com/react-aria/useNumberField.html'}]; # NumberField diff --git a/packages/dev/s2-docs/pages/react-aria/Popover.mdx b/packages/dev/s2-docs/pages/react-aria/Popover.mdx index fba0b924482..17bc2912a0a 100644 --- a/packages/dev/s2-docs/pages/react-aria/Popover.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Popover.mdx @@ -8,6 +8,7 @@ import Anatomy from '@react-aria/overlays/docs/popover-anatomy.svg'; import {InlineAlert, Heading, Content} from '@react-spectrum/s2' export const tags = ['popup', 'overlay']; +export const relatedPages = [{'title': 'usePopover', 'url': 'https://react-spectrum.adobe.com/react-aria/usePopover.html'}]; # Popover diff --git a/packages/dev/s2-docs/pages/react-aria/ProgressBar.mdx b/packages/dev/s2-docs/pages/react-aria/ProgressBar.mdx index 5acb1cbbfe0..f6f8756b30d 100644 --- a/packages/dev/s2-docs/pages/react-aria/ProgressBar.mdx +++ b/packages/dev/s2-docs/pages/react-aria/ProgressBar.mdx @@ -11,6 +11,7 @@ import '../../tailwind/tailwind.css'; import Anatomy from '@react-aria/progress/docs/anatomy.svg'; export const tags = ['loading', 'progress']; +export const relatedPages = [{'title': 'useProgressBar', 'url': 'https://react-spectrum.adobe.com/react-aria/useProgressBar.html'}]; # ProgressBar diff --git a/packages/dev/s2-docs/pages/react-aria/RadioGroup.mdx b/packages/dev/s2-docs/pages/react-aria/RadioGroup.mdx index c412c6dd29a..7483ae74df8 100644 --- a/packages/dev/s2-docs/pages/react-aria/RadioGroup.mdx +++ b/packages/dev/s2-docs/pages/react-aria/RadioGroup.mdx @@ -7,6 +7,7 @@ import '../../tailwind/tailwind.css'; import Anatomy from '@react-aria/radio/docs/anatomy.svg'; export const tags = ['input']; +export const relatedPages = [{'title': 'useRadioGroup', 'url': 'https://react-spectrum.adobe.com/react-aria/useRadioGroup.html'}]; # RadioGroup diff --git a/packages/dev/s2-docs/pages/react-aria/RangeCalendar.mdx b/packages/dev/s2-docs/pages/react-aria/RangeCalendar.mdx index b189c1b42d1..c9407ede315 100644 --- a/packages/dev/s2-docs/pages/react-aria/RangeCalendar.mdx +++ b/packages/dev/s2-docs/pages/react-aria/RangeCalendar.mdx @@ -9,6 +9,7 @@ import '../../tailwind/tailwind.css'; import Anatomy from '@react-aria/calendar/docs/rangecalendar-anatomy.svg'; export const tags = ['calendar']; +export const relatedPages = [{'title': 'useRangeCalendar', 'url': 'https://react-spectrum.adobe.com/react-aria/useRangeCalendar.html'}]; # RangeCalendar @@ -48,7 +49,7 @@ function Example() { end: parseDate('2025-02-12') }); let formatter = useDateFormatter({ dateStyle: 'long' }); - + return ( <> (
- {i === 0 && + {i === 0 && } {monthFormatter.format(state.visibleRange.start.add({months: i}).toDate(state.timeZone))} - {i === props.visibleDuration.months - 1 && + {i === props.visibleDuration.months - 1 && diff --git a/packages/dev/s2-docs/pages/react-aria/SearchField.mdx b/packages/dev/s2-docs/pages/react-aria/SearchField.mdx index b04f33820e0..325f2a2383a 100644 --- a/packages/dev/s2-docs/pages/react-aria/SearchField.mdx +++ b/packages/dev/s2-docs/pages/react-aria/SearchField.mdx @@ -9,6 +9,7 @@ import '../../tailwind/tailwind.css'; import Anatomy from '@react-aria/searchfield/docs/anatomy.svg'; export const tags = ['input']; +export const relatedPages = [{'title': 'useSearchField', 'url': 'https://react-spectrum.adobe.com/react-aria/useSearchField.html'}]; # SearchField @@ -45,7 +46,7 @@ import {useState} from 'react'; function Example() { let [search, setSearch] = useState(''); let [submittedSearch, setSubmittedSearch] = useState(''); - + return (
- -
- & {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'}} @@ -724,3 +896,7 @@ function subscribe(fn) { ### Cell + +### EditableCell + + \ No newline at end of file diff --git a/packages/dev/s2-docs/src/Layout.tsx b/packages/dev/s2-docs/src/Layout.tsx index a466d175b84..f4221173737 100644 --- a/packages/dev/s2-docs/src/Layout.tsx +++ b/packages/dev/s2-docs/src/Layout.tsx @@ -359,8 +359,8 @@ function MobileRelatedPages({pages}: {pages: Array<{title: string, url: string}> lg: 'none' } })}> -

Related pages

-
    + +
      {pages.map((page, i) => (
    • @@ -374,6 +374,7 @@ function MobileRelatedPages({pages}: {pages: Array<{title: string, url: string}>

); } + export function Time({date}: {date: string}) { let dateObj = new Date(date); return ( diff --git a/packages/dev/s2-docs/src/OptimisticToc.tsx b/packages/dev/s2-docs/src/OptimisticToc.tsx index 340c18845bd..812611a1f95 100644 --- a/packages/dev/s2-docs/src/OptimisticToc.tsx +++ b/packages/dev/s2-docs/src/OptimisticToc.tsx @@ -46,12 +46,15 @@ function renderMobileToc(toc: TocNode[], seen = new Map()) { export function OptimisticToc({currentPage, pages}: {currentPage: Page, pages: Page[]}) { let pendingPage = usePendingPage(pages); let displayPage = pendingPage ?? currentPage; - + return ( <>
On this page
+ {currentPage.exports?.relatedPages && ( + + )}
@@ -61,17 +64,42 @@ export function OptimisticToc({currentPage, pages}: {currentPage: Page, pages: P ); } +function RelatedPages({pages}: {pages: Array<{title: string, url: string}>}) { + return ( +
+
Related pages
+ + + {pages.map((page, i) => ( + + {page.title} + + ))} + + +
+ ); +} + export function OptimisticMobileToc({currentPage, pages}: {currentPage: Page, pages: Page[]}) { let pendingPage = usePendingPage(pages); let displayPage = pendingPage ?? currentPage; - + if ((displayPage.tableOfContents?.[0]?.children?.length ?? 0) <= 1) { return null; } - + + let withRelatedPages = displayPage.exports?.relatedPages ? [ + ...(displayPage.tableOfContents ?? []), + { + level: 2, + title: 'Related pages', + children: [] + }] : displayPage.tableOfContents!; + return ( - {renderMobileToc(displayPage.tableOfContents ?? [])} + {renderMobileToc(withRelatedPages)} ); } diff --git a/packages/dev/s2-docs/src/ScrollableToc.tsx b/packages/dev/s2-docs/src/ScrollableToc.tsx index 514c0d0a278..0f447b5bd6f 100644 --- a/packages/dev/s2-docs/src/ScrollableToc.tsx +++ b/packages/dev/s2-docs/src/ScrollableToc.tsx @@ -13,7 +13,7 @@ export function ScrollableToc({children}) { let scrollHeight = element.scrollHeight; let clientHeight = element.clientHeight; let distanceFromBottom = scrollHeight - scrollTop - clientHeight; - + setTopMaskSize(Math.min(scrollTop, 32)); setBottomMaskSize(Math.min(distanceFromBottom, 32)); }; @@ -51,8 +51,9 @@ export function ScrollableToc({children}) { }} className={style({ overflowY: 'auto', - flex: 1, - minHeight: 0 + flexShrink: 1, + minHeight: 0, + maxHeight: '100%' })}> {children}