diff --git a/.changeset/core-internal-reorder-lab-listinput.md b/.changeset/core-internal-reorder-lab-listinput.md new file mode 100644 index 0000000000000..1c0bbc95f6015 --- /dev/null +++ b/.changeset/core-internal-reorder-lab-listinput.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the undocumented `@astryxdesign/core/internal/reorder` entry also gives Lab's `ListInput` List's reorder session for rows that are not ListItems: `useListReorderScope` and `ListReorderScope`. `List` runs its own rows' session through the same session hooks, so its callers see no change. A ListInput string joins Core's message catalog. Internal entries are outside the public API and its compatibility promise. + +@vjeux diff --git a/apps/storybook/stories/ListInput.stories.tsx b/apps/storybook/stories/ListInput.stories.tsx index d36310a2b7246..9dfc4966bdf34 100644 --- a/apps/storybook/stories/ListInput.stories.tsx +++ b/apps/storybook/stories/ListInput.stories.tsx @@ -9,11 +9,18 @@ import * as stylex from '@stylexjs/stylex'; import type {Meta, StoryObj} from '@storybook/react'; -import {useCallback, useMemo, useState} from 'react'; +import { + useCallback, + useMemo, + useState, + type Dispatch, + type SetStateAction, +} from 'react'; import type {ISODateString} from '@astryxdesign/core/Calendar'; import {DateInput} from '@astryxdesign/core/DateInput'; import {NumberInput} from '@astryxdesign/core/NumberInput'; import {Selector} from '@astryxdesign/core/Selector'; +import {createReorderable} from '@astryxdesign/core'; import {pixel, proportional} from '@astryxdesign/core/Table'; import {Text} from '@astryxdesign/core/Text'; import {TextInput} from '@astryxdesign/core/TextInput'; @@ -189,8 +196,33 @@ function createTagColumns( ]; } +/** + * The owner's reorder: each move ListInput reports lands in the records, + * found by `getItemKey`'s key. + */ +function useRecordReorder( + setRecords: Dispatch>, +) { + return useMemo( + () => + createReorderable({ + onReorder: ({key, to}: {key: string | number; to: number}) => + setRecords(current => { + const moved = current.find(record => record.id === key); + if (moved == null) { + return current; + } + const rest = current.filter(record => record !== moved); + return [...rest.slice(0, to), moved, ...rest.slice(to)]; + }), + }), + [setRecords], + ); +} + function TagOptionsExample({isReorderable}: {isReorderable?: boolean}) { const [tags, setTags] = useState(INITIAL_TAGS); + const reorderable = useRecordReorder(setTags); const {isFieldTouched, markFieldTouched} = useTouchedFields(); const columns = useMemo( () => createTagColumns(markFieldTouched), @@ -235,7 +267,7 @@ function TagOptionsExample({isReorderable}: {isReorderable?: boolean}) { ? {type: 'error', message: 'Enter a label.'} : undefined } - isReorderable={isReorderable || undefined} + reorderable={isReorderable ? reorderable : undefined} /> ); } @@ -800,6 +832,7 @@ const INITIAL_TRIP_LEGS: TripLeg[] = [ function ResponsiveItineraryExample() { const [legs, setLegs] = useState(INITIAL_TRIP_LEGS); + const reorderable = useRecordReorder(setLegs); const {isFieldTouched, markFieldTouched} = useTouchedFields(); const columns = useMemo[]>( () => [ @@ -906,7 +939,7 @@ function ResponsiveItineraryExample() { } return undefined; }} - isReorderable + reorderable={reorderable} isRequired /> @@ -940,6 +973,7 @@ function StaticTagsExample({ status?: React.ComponentProps>['status']; }) { const [tags, setTags] = useState(INITIAL_TAGS.slice(0, 3)); + const reorderable = useRecordReorder(setTags); const columns = useMemo(() => createTagColumns(() => {}), []); return ( @@ -955,7 +989,7 @@ function StaticTagsExample({ isDisabled={isDisabled} isLoading={isLoading} status={status} - isReorderable + reorderable={reorderable} /> ); } @@ -1025,6 +1059,7 @@ const STRESS_TAGS: TagOption[] = Array.from({length: 25}, (_, index) => ({ function StressExample() { const [tags, setTags] = useState(STRESS_TAGS); + const reorderable = useRecordReorder(setTags); const columns = useMemo(() => createTagColumns(() => {}), []); return ( @@ -1037,7 +1072,7 @@ function StressExample() { createItem={() => ({id: crypto.randomUUID(), color: 'blue', label: ''})} columns={columns} itemName="tag" - isReorderable + reorderable={reorderable} /> ); } diff --git a/apps/storybook/stories/ListInputA11y.stories.tsx b/apps/storybook/stories/ListInputA11y.stories.tsx new file mode 100644 index 0000000000000..627fdcd1b2e77 --- /dev/null +++ b/apps/storybook/stories/ListInputA11y.stories.tsx @@ -0,0 +1,84 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file ListInputA11y.stories.tsx + * @input ListInput with reordering, where the owner keeps the first record in + * its place + * @output A fixture for the Chromium check that a record that stays put keeps + * its Remove in the column of every other record's Remove. It is excluded + * from the visual matrix + * @position Browser fixture for ListInput's record columns + */ + +import {useMemo, useState} from 'react'; +import type {Meta, StoryObj} from '@storybook/react'; +import {createReorderable} from '@astryxdesign/core'; +import {TextInput} from '@astryxdesign/core/TextInput'; +import {ListInput, type ListInputColumn} from '@astryxdesign/lab'; + +const meta: Meta = { + title: 'A11y/ListInput', + tags: ['no-visual'], +}; + +export default meta; + +interface Guest { + id: string; + name: string; +} + +const COLUMNS: ListInputColumn[] = [ + { + key: 'name', + header: 'Name', + renderInput: ({item, label, isLabelHidden, isDisabled, updateItem}) => ( + updateItem({...item, name}, 'name')} + isDisabled={isDisabled} + /> + ), + }, +]; + +export const FixedRecord: StoryObj = { + render: function FixedRecordStory() { + const [guests, setGuests] = useState([ + {id: 'host', name: 'Host'}, + {id: 'ada', name: 'Ada'}, + {id: 'grace', name: 'Grace'}, + ]); + const reorderable = useMemo( + () => + createReorderable({ + // The host stays first. + isReorderable: (key: string | number) => key !== 'host', + onReorder: ({key, to}: {key: string | number; to: number}) => + setGuests(current => { + const moved = current.find(guest => guest.id === key); + if (moved == null) { + return current; + } + const rest = current.filter(guest => guest !== moved); + return [...rest.slice(0, to), moved, ...rest.slice(to)]; + }), + }), + [], + ); + return ( + + label="Guests" + value={guests} + onChange={setGuests} + getItemKey={guest => guest.id} + createItem={() => ({id: crypto.randomUUID(), name: ''})} + columns={COLUMNS} + itemName="guest" + reorderable={reorderable} + /> + ); + }, +}; diff --git a/docs/design/ordered-collection-reordering.md b/docs/design/ordered-collection-reordering.md index 81a8591a7ea7d..c3bdfbd5c0d82 100644 --- a/docs/design/ordered-collection-reordering.md +++ b/docs/design/ordered-collection-reordering.md @@ -24,11 +24,7 @@ deciding_specs: [spec:AST-103] ```json { - "paths": [ - "packages/lab/src/reorderStyles.ts", - "packages/lab/src/ListInput/**", - "packages/lab/src/TransferList/**" - ] + "paths": ["packages/lab/src/ListInput/**", "packages/lab/src/TransferList/**"] } ``` @@ -119,11 +115,6 @@ No normative assets are included. Pointer and keyboard sequences belong under `spec:AST-103` applies this treatment to every reorderable collection, its keyboard reorder mode included (DR7). -Implementation gap: Lab's ListInput and TransferList draw no insertion line -when reordering by keyboard (DR7); ListInput's keyboard grab mode moves the -rows before the commit (DR3); and both build their own drag preview -(`architecture:component-style-authoring` INV7). - ## Decision log ### DEC-1 — Reordering follows this design, not a slide-aside diff --git a/packages/core/locales/en.json b/packages/core/locales/en.json index ed8ad5ebd4073..96937bc86c1b9 100644 --- a/packages/core/locales/en.json +++ b/packages/core/locales/en.json @@ -695,6 +695,10 @@ "defaultMessage": "Reorder {itemName} {position, number}", "description": "Accessible label on the drag-handle button that reorders one record in a lab ListInput. `{itemName}` is the consumer's singular noun for one record; `{position}` is its 1-based row number (e.g. \"Reorder guest 2\")." }, + "@astryx.listInput.itemName": { + "defaultMessage": "{itemName} {position, number}", + "description": "The name of one record in a lab ListInput, which its reorder handle (\"Reorder guest 2\") and the reorder announcements use. `{itemName}` is the consumer's singular noun for one record; `{position}` is its 1-based row number (e.g. \"guest 2\")." + }, "@astryx.listInput.fieldLabelWithPosition": { "defaultMessage": "{header}, {itemName} {position, number} of {total, number}", "description": "Accessible name for a field inside a lab ListInput row after the first row, disambiguating repeated column labels. `{header}` is the column's own label (e.g. \"Name\"); `{itemName}` is the consumer's singular noun for one record; `{position}`/`{total}` are the row's 1-based index and the total row count (e.g. \"Name, guest 2 of 3\")." diff --git a/packages/core/src/List/List.tsx b/packages/core/src/List/List.tsx index 7c6e79368cdf3..764d2dd5a272e 100644 --- a/packages/core/src/List/List.tsx +++ b/packages/core/src/List/List.tsx @@ -11,10 +11,11 @@ * types * @position Core implementation. Consumed by index.ts, tested by List.test.tsx * - * `animatedLayout` runs the layout updates (List/listLayoutUpdates.ts); + * `animatedLayout` runs the layout updates (List/listLayoutUpdates.ts). * `reorderable` runs the reorder session after them. List imports only the - * reorderable object's type and its key (List/listReorderContext.ts). It - * hands the reorder to its rows through ListContext. The session, its handle + * reorderable object's type and List/listReorderContext.ts, which holds the + * object's key and the session hooks. It hands the reorder to its rows + * through ListContext. The session, its handle * and its listeners come with the object (reorderable/createReorderable.ts). * So a List without it loads no reorder code. * @@ -44,11 +45,10 @@ import {LayoutChangeSnapshot} from '../motion/LayoutChangeSnapshot'; import type {AnimatedLayout} from '../motion/animatedLayout'; import {themeProps} from '../utils/themeProps'; import {useDevWarning} from '../hooks/useDevWarning'; -import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; import { - REORDER_ENGINE, + useReorderCommit, + useReorderSession, type ListReorderOptions, - type ReorderableInternals, } from './listReorderContext'; export { @@ -346,16 +346,12 @@ export function List({ }: ListProps) { const headerId = useId(); const listRef = useRef(null); - // The reorder's machinery comes with the object. The - // object may be made on every render: the session lasts as long as its - // machinery, and reads the object's latest callbacks. - const reorder = reorderable as ReorderableInternals | undefined; - const reorderEngine = reorder?.[REORDER_ENGINE]; - const reorderOptions = reorder?.options; - // The handles' instructions take the header's id with a suffix. - const reorderSession = useMemo( - () => reorderEngine?.createSession(() => listRef.current, `${headerId}h`), - [reorderEngine, headerId], + // The reorder's machinery comes with the object. The handles' + // instructions take the header's id with a suffix. + const {session: reorderSession, options: reorderOptions} = useReorderSession( + reorderable, + () => listRef.current, + `${headerId}h`, ); const measureRows = useListLayoutUpdates(listRef, animatedLayout); // A drag's or a reorder-mode move's commits are the reorder's to move, so @@ -365,11 +361,8 @@ export function List({ measureRows(); } }, [measureRows, reorderSession]); - // Syncs the reorder session, which lives outside React, after each commit. - // It runs after the layout updates, so a drop's commit is the reorder's. - useIsomorphicLayoutEffect(() => { - reorderSession?.commit(); - }); + // After the layout updates, so a drop's commit is the reorder's. + useReorderCommit(reorderSession); const mergedRef = useMergedRefs( listRef, ref as React.Ref, diff --git a/packages/core/src/List/ListReorderScope.tsx b/packages/core/src/List/ListReorderScope.tsx new file mode 100644 index 0000000000000..822e5dcea4525 --- /dev/null +++ b/packages/core/src/List/ListReorderScope.tsx @@ -0,0 +1,86 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file ListReorderScope.tsx + * @input A reorderable object made by createReorderable, and the element + * whose children are the rows + * @output `useListReorderScope`, a reorder session over rows that are not + * ListItems. `ListReorderScope` gives those rows the session as a + * reorderable List gives its own: `ListReorderHandle` inside a row finds it + * @position Internal to core; Lab's ListInput reaches it through + * `@astryxdesign/core/internal/reorder`. It runs the same engine a + * reorderable List runs, List/listReorderEngine.tsx, which ships with the + * object. So the rows drag, take keys and announce their moves alike. + * + * SYNC: When modified, update List/List.tsx, which runs the same session for + * its own rows through the hooks in List/listReorderContext.ts. + */ + +import {useMemo, type ReactNode} from 'react'; +import type {Reorderable} from '../reorderable/createReorderable'; +import {ListContext, type ListContextValue} from './ListContext'; +import { + useReorderCommit, + useReorderSession, + type ListReorderContextValue, +} from './listReorderContext'; + +/** + * The reorder session over the children of the element `getList` returns, + * run by `reorderable`'s engine, or null without an object. `hintId` names + * the handles' instructions, which `ListReorderScope` renders. The object may + * be made on every render: the session lasts as long as its engine and reads + * the object's latest callbacks. + */ +export function useListReorderScope( + reorderable: Reorderable | undefined, + getList: () => HTMLElement | null, + hintId: string, +): ListReorderContextValue | null { + const {session, options} = useReorderSession(reorderable, getList, hintId); + useReorderCommit(session); + return useMemo( + () => (session == null || options == null ? null : {session, options}), + [session, options], + ); +} + +/** + * Gives the rows inside it the reorder session, so `ListReorderHandle` in a + * row drags that row, and renders the session's instructions and reorder-mode + * hint. Without a session it renders its children alone. + */ +export function ListReorderScope({ + reorder, + children, +}: { + reorder: ListReorderContextValue | null; + children: ReactNode; +}): ReactNode { + // Only `reorder` is read inside a reorder scope; the rest is a List's + // defaults for any row that reads it. + const value = useMemo( + () => + reorder == null + ? null + : { + density: 'balanced', + hasDividers: false, + edge: 'padded', + listStyle: 'none', + reorder, + }, + [reorder], + ); + if (reorder == null || value == null) { + return children; + } + return ( + + {children} + + + ); +} diff --git a/packages/core/src/List/listReorderContext.ts b/packages/core/src/List/listReorderContext.ts index 64195d8e5504f..ed37a33d82538 100644 --- a/packages/core/src/List/listReorderContext.ts +++ b/packages/core/src/List/listReorderContext.ts @@ -4,10 +4,12 @@ /** * @file listReorderContext.ts - * @input Nothing at run time but a symbol + * @input React's hooks and a symbol * @output `REORDER_ENGINE`, the key a reorderable object carries its * machinery under. Also the internal types between List, ListItem and that - * machinery: what a List hands its rows through ListContext's `reorder` + * machinery: what a List hands its rows through ListContext's `reorder`. + * `useReorderSession` and `useReorderCommit` run the object's session for a + * List and for List/ListReorderScope.tsx * @position Internal to List. List imports only this module and the * reorderable object's type, and ListItem only types. So a List without * `reorderable` loads no reorder code. The machinery @@ -20,7 +22,16 @@ */ import type {ItemAlign} from '../Item/Item'; -import type {ComponentType, Key, ReactNode, RefObject} from 'react'; +import { + useMemo, + useRef, + type ComponentType, + type Key, + type ReactNode, + type RefObject, +} from 'react'; +import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; +import type {Reorderable} from '../reorderable/createReorderable'; import type {StyleXStyles} from '@stylexjs/stylex'; import type {reorderListStyle} from './ListReorderHandle'; @@ -141,3 +152,43 @@ export interface ListReorderContextValue { readonly session: ListReorderSession; readonly options: ListReorderOptions; } + +/** A reorder session and the options of the object that runs it. */ +export interface ReorderSessionOf { + readonly session: ListReorderSession | null; + readonly options: ListReorderOptions | null; +} + +/** + * The reorder session over the children of the element `getList` returns, + * run by `reorderable`'s engine. Both fields are null without an object. + * `hintId` names the handles' instructions. The object may be made on every + * render: the session lasts as long as its engine and reads the object's + * latest callbacks. + */ +export function useReorderSession( + reorderable: Reorderable | undefined, + getList: () => HTMLElement | null, + hintId: string, +): ReorderSessionOf { + const reorder = reorderable as ReorderableInternals | undefined; + const engine = reorder?.[REORDER_ENGINE]; + const getListRef = useRef(getList); + getListRef.current = getList; + const session = useMemo( + () => engine?.createSession(() => getListRef.current(), hintId) ?? null, + [engine, hintId], + ); + return {session, options: reorder?.options ?? null}; +} + +/** + * Applies a drop's new order after each commit. A caller with layout updates + * calls it after them, so a drop's commit is the reorder's. + */ +export function useReorderCommit(session: ListReorderSession | null): void { + // Syncs the reorder session, which lives outside React, after each commit. + useIsomorphicLayoutEffect(() => { + session?.commit(); + }); +} diff --git a/packages/core/src/internal/reorder.test.tsx b/packages/core/src/internal/reorder.test.tsx new file mode 100644 index 0000000000000..60325785c841d --- /dev/null +++ b/packages/core/src/internal/reorder.test.tsx @@ -0,0 +1,146 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file reorder.test.tsx + * @input Core's internal reorder entry: a reorderable object's options, and + * List's reorder session run over rows that are not ListItems + * @output Proof that the session reorders a list whose rows hold their own + * content: rows of any tag, with the handle anywhere inside each. It reports + * each move to the object and keeps a rejected row in place. The options + * read back as the object was made with them. The entry loads no reorder + * code: the handle comes with the object + * @position Testing for packages/core/src/internal/reorder.ts as Lab's + * ListInput and TransferList use it + */ + +import {readFileSync} from 'node:fs'; +import {useId, useRef, useState, type Key, type ReactNode} from 'react'; +import {describe, expect, it, vi} from 'vitest'; +import {render, screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {createReorderable} from '../reorderable/createReorderable'; +import { + ListReorderScope, + reorderableOptions, + useListReorderScope, +} from './reorder'; + +describe("Core's internal reorder entry", () => { + it('reads back the options a reorderable object was made with', () => { + const onReorder = vi.fn(); + const isReorderable = (key: Key) => key !== 'pinned'; + const options = reorderableOptions( + createReorderable({onReorder, isReorderable}), + ); + expect(options.onReorder).toBe(onReorder); + expect(options.isReorderable).toBe(isReorderable); + }); + + it('reorders rows that hold their own content, and keeps a rejected row in place', async () => { + const onReorder = vi.fn(); + function Records(): ReactNode { + const listRef = useRef(null); + const hintId = useId(); + const [items, setItems] = useState(['Alpha', 'Beta', 'Gamma']); + const [reorderable] = useState(() => + createReorderable({ + onReorder: move => { + onReorder(move); + setItems(current => { + const rest = current.filter(item => item !== move.key); + return [ + ...rest.slice(0, move.to), + String(move.key), + ...rest.slice(move.to), + ]; + }); + }, + isReorderable: key => key !== 'Gamma', + }), + ); + const scope = useListReorderScope( + reorderable, + () => listRef.current, + hintId, + ); + const Handle = scope?.session.Handle; + return ( + +
    + {items.map(item => ( +
  1. + + {item === 'Gamma' || Handle == null ? null : ( + + + + )} +
  2. + ))} +
+
+ ); + } + const user = userEvent.setup(); + render(); + const handle = screen.getByRole('button', {name: 'Reorder Alpha'}); + // The scope renders the handles' instructions under the id it was given. + expect(handle).toHaveAccessibleDescription(/Drag to move this item/); + expect(screen.queryByRole('button', {name: 'Reorder Gamma'})).toBeNull(); + handle.focus(); + await user.keyboard('{Enter}{ArrowDown}'); + expect(onReorder).toHaveBeenLastCalledWith({key: 'Alpha', to: 1}); + // The rejected row bounds the others: Alpha cannot pass Gamma. + await user.keyboard('{ArrowDown}'); + expect(onReorder).toHaveBeenCalledTimes(1); + expect(screen.getByRole('button', {name: 'Reorder Alpha'})).toHaveFocus(); + }); + + it('renders its children alone without a reorderable object', () => { + function Plain(): ReactNode { + const listRef = useRef(null); + const scope = useListReorderScope( + undefined, + () => listRef.current, + 'hint', + ); + const Handle = scope?.session.Handle; + return ( + +
    +
  • + Alpha + {Handle == null ? null : } +
  • +
+
+ ); + } + render(); + expect(screen.getByText('Alpha')).toBeInTheDocument(); + expect(screen.queryByRole('button')).toBeNull(); + }); + + it('loads no reorder code: the entry and the scope import the context and types only', () => { + for (const file of ['./reorder.ts', '../List/ListReorderScope.tsx']) { + const source = readFileSync(new URL(file, import.meta.url), 'utf8'); + const imports = Array.from( + source.matchAll( + /^(?:import|export)\s+(type\s+)?[^;]*?from\s+'([^']+)';/gms, + ), + match => ({isType: match[1] != null, from: match[2]}), + ); + for (const {isType, from} of imports) { + if (/reorder/i.test(from)) { + expect( + isType || + from.endsWith('/listReorderContext') || + from === './listReorderContext' || + from.endsWith('/ListReorderScope'), + `${file} imports ${from}`, + ).toBe(true); + } + } + } + }); +}); diff --git a/packages/core/src/internal/reorder.ts b/packages/core/src/internal/reorder.ts index 72725fc387e50..ef63e30db8379 100644 --- a/packages/core/src/internal/reorder.ts +++ b/packages/core/src/internal/reorder.ts @@ -8,7 +8,11 @@ * other options. A Lab collection uses it to adapt the owner's moves, such * as a staged selector that aims them at its draft. The owner's `start` * still finds its list. Neither loads the reorder engine: it comes with the - * owner's object + * owner's object. + * It also gives List's reorder session for rows that are not ListItems, + * for Lab's ListInput: `useListReorderScope` and `ListReorderScope`. The + * handle each row ends with is the session's `Handle`: it comes with the + * owner's object, so this entry loads no reorder code * @position `@astryxdesign/core/internal/reorder`: an internal entry reserved * for Core and Lab. It is undocumented, outside the public API and its * compatibility promise, and versioned in lockstep with Core. @@ -53,3 +57,9 @@ export function adaptReorderable( }; return adapted as unknown as Reorderable; } + +export {ListReorderScope, useListReorderScope} from '../List/ListReorderScope'; +export type { + ListReorderContextValue, + ListReorderHandleProps, +} from '../List/listReorderContext'; diff --git a/packages/lab/blocks/ListInputShowcase.tsx b/packages/lab/blocks/ListInputShowcase.tsx index 72f115ea21012..895ea136ca12f 100644 --- a/packages/lab/blocks/ListInputShowcase.tsx +++ b/packages/lab/blocks/ListInputShowcase.tsx @@ -11,6 +11,7 @@ import {useMemo, useState} from 'react'; import {VStack} from '@astryxdesign/core/Layout'; +import {createReorderable} from '@astryxdesign/core'; import {Selector} from '@astryxdesign/core/Selector'; import {proportional} from '@astryxdesign/core/Table'; import {TextInput} from '@astryxdesign/core/TextInput'; @@ -104,6 +105,23 @@ export default function ListInputShowcase() { [], ); + // The tags' reorder: each move lands where the list reports it. + const reorderable = useMemo( + () => + createReorderable({ + onReorder: ({key, to}) => + setTags(current => { + const moved = current.find(tag => tag.id === key); + if (moved == null) { + return current; + } + const rest = current.filter(tag => tag.id !== key); + return [...rest.slice(0, to), moved, ...rest.slice(to)]; + }), + }), + [], + ); + return ( @@ -115,7 +133,7 @@ export default function ListInputShowcase() { createItem={() => ({id: crypto.randomUUID(), color: 'blue', label: ''})} columns={columns} itemName="tag" - isReorderable + reorderable={reorderable} status={ tags.length < 5 ? {type: 'error', message: 'Add at least five tag options.'} diff --git a/packages/lab/src/ListInput/ListInput.doc.mjs b/packages/lab/src/ListInput/ListInput.doc.mjs index 6ed88a2d11eea..4e035ebebd78f 100644 --- a/packages/lab/src/ListInput/ListInput.doc.mjs +++ b/packages/lab/src/ListInput/ListInput.doc.mjs @@ -37,7 +37,7 @@ export const docs = { name: 'onChange', type: '(next: T[], change: ListInputChange) => void', description: - 'Called with the next array after an add, update, remove, or reorder action. The change argument identifies the action and affected record.', + 'Called with the next array after an add, update, or remove action. The change argument identifies the action and affected record. Moves go to the reorderable object instead.', required: true, }, { @@ -92,11 +92,10 @@ export const docs = { 'Returns validation status for one field. The renderer receives the complete status plus statusVariant="tooltip" and should forward both to its Astryx input so the native status affordance owns styling, description, keyboard access, and tooltip behavior without changing row height.', }, { - name: 'isReorderable', - type: 'boolean', + name: 'reorderable', + type: 'Reorderable', description: - 'Shows a vertical grip beside the first field of each record. The grip is a bare six-dot glyph with no button fill. It hangs in the inline-start gutter, outside the fields, so the fields and their labels never move, and it appears while the record is hovered or holds keyboard focus and stays visible on touch screens. Grip and clearance take 16px, so the grip sits inside the standard 16px container padding; give the list that much free inline-start space in a clipping or edge-to-edge container. During pointer reordering, list rows stay stationary while the source row and a free-moving duplicate that follows the pointer on both axes appear at 50% opacity as a temporary drag-only state; full opacity returns immediately on drop or cancel. Vertical pointer position determines the accent insertion line and final list placement. The controlled order commits and affected rows animate with Astryx motion tokens only on release. When the grip is focused, Arrow Up or Arrow Down immediately moves the record one position. Space or Enter starts lift mode for Arrow, Home, or End previewing; Space or Enter drops and Escape cancels. Focus and live announcements are preserved.', - default: 'false', + "Makes the records reorderable: pass the object createReorderable makes, keyed by getItemKey. Each record ends with core List's reorder handle, after Remove, as UIKit's reorder control ends an editing row. The handle appears while the record is hovered or holds keyboard focus and stays visible on touch screens. On a touch screen, hold the handle for a moment, then drag: the record stays in its place, dimmed, while a copy of it follows the finger and an accent line marks where it would land; the other records stay still, the list autoscrolls near its edges, and on release the records rearrange. A flick on the handle scrolls and a pinch zooms the page. A mouse drags at once the same way, and the records take their places without motion. Activating the handle (a tap, a click, Space or Enter) turns on reorder mode for its record: the line marks the record's place, each Arrow Up or Arrow Down moves it a place, a hint shows the moves and Done as buttons, and Escape or Done ends the mode, keeping the moves. The object's isReorderable(key) says which records move: one it rejects has no handle. Each move goes to the object's onReorder({key, to}), to being the record's index in value after the move. In a disabled or loading list the handles stay focusable but move nothing. Without it, the records keep their order.", }, { name: 'isDisabled', @@ -167,7 +166,7 @@ export const docs = { { guidance: true, description: - 'Let the floating pointer preview follow both axes while using vertical position alone for insertion. Keep list rows stationary and commit the controlled value once on release. Focused handles move immediately with Arrow Up or Arrow Down while preserving lift, move, drop, and cancel behavior.', + 'Let records reorder through the handle: a drag reports one move on release, each key move in reorder mode reports one at once, and focus stays on the handle.', }, { guidance: true, @@ -219,7 +218,13 @@ export const docs = { name: 'Reorder handle', required: false, description: - "A bare six-dot grip beside the first field of the record, with an accessible label and no hover tooltip or button fill. Its target is 8px wide and as tall as the field, with 8px of clearance to the field, which keeps it within WCAG 2.5.8 through the spacing exception; the glyph uses the primary icon color and shows the standard focus ring. It hangs in the inline-start gutter, outside the fields, so the fields and their labels keep their positions, and grip plus clearance fit the standard 16px container padding. It is revealed while the record is hovered or contains keyboard focus and stays visible on touch screens. At rest it is transparent but stays in the tab order. While a record is being reordered its grip stays visible, and a pointer drag keeps the other records' grips hidden. In the stacked layout, the grip hangs beside the first field and Remove sits on the other side of that field at the top of the record. Pointer dragging keeps the list stationary, fades both source and duplicate preview to 50%, lets the duplicate follow freely on both axes, and uses vertical position for the insertion line before committing and animating on release. A focused grip moves one position with Arrow Up or Arrow Down; Space or Enter also enables lift mode with Arrow, Home, End, drop, and Escape controls.", + "Core List's reorder handle, three lines at the end of the record after Remove, in Remove's cell, with an accessible label naming the record and its position and no hover tooltip. Where the primary pointer is coarse, its target grows to at least 44 by 44 px. It is revealed while the record is hovered or contains keyboard focus and stays visible on touch screens; at rest it is transparent but stays in the tab order. In the stacked layout it sits at the top of the record beside Remove. A finger holds it a moment, then drags; a mouse drags at once; activating it (Space or Enter) turns on reorder mode, where Arrow Up and Arrow Down move the record a place and Escape ends the mode.", + }, + { + name: 'Reorder-mode hint', + required: false, + description: + "While a record is in reorder mode, core List's hint anchored to its handle shows the moves and Done as buttons, so taps alone can reorder. Each does what its key does and leaves the focus on the handle.", }, { name: 'Empty state', @@ -252,7 +257,8 @@ export const docs = { examples: [ { label: 'Tag options', - code: `import {Selector} from '@astryxdesign/core/Selector'; + code: `import {createReorderable} from '@astryxdesign/core'; +import {Selector} from '@astryxdesign/core/Selector'; import {proportional} from '@astryxdesign/core/Table'; import {TextInput} from '@astryxdesign/core/TextInput'; @@ -294,6 +300,19 @@ const columns = [ }, ] satisfies ListInputColumn[]; +// Each move lands where ListInput reports it: the record's key and index. +const reorderable = useMemo( + () => + createReorderable({ + onReorder: ({key, to}) => + setTags(tags => { + const rest = tags.filter(tag => tag.id !== key); + return [...rest.slice(0, to), ...tags.filter(tag => tag.id === key), ...rest.slice(to)]; + }), + }), + [], +); + tag.id} createItem={() => ({id: crypto.randomUUID(), color: 'blue', label: ''})} columns={columns} - isReorderable + reorderable={reorderable} status={ tags.length < 5 ? {type: 'error', message: 'Add at least five tag options.'} @@ -317,7 +336,8 @@ const columns = [ }, { label: 'Controlled guest list', - code: `import {proportional} from '@astryxdesign/core/Table'; + code: `import {createReorderable} from '@astryxdesign/core'; +import {proportional} from '@astryxdesign/core/Table'; type Guest = {id: string; name: string; email: string}; @@ -359,6 +379,19 @@ const columns = [ }, ] satisfies ListInputColumn[]; +// Each move lands where ListInput reports it: the record's key and index. +const reorderable = useMemo( + () => + createReorderable({ + onReorder: ({key, to}) => + setGuests(guests => { + const rest = guests.filter(guest => guest.id !== key); + return [...rest.slice(0, to), ...guests.filter(guest => guest.id === key), ...rest.slice(to)]; + }), + }), + [], +); + guest.id} createItem={() => ({id: crypto.randomUUID(), name: '', email: ''})} columns={columns} - isReorderable + reorderable={reorderable} maxItems={6} getFieldStatus={(guest, key) => key === 'email' && guest.email === '' @@ -571,7 +604,7 @@ export const docsDense = { propDescriptions: { label: 'Collection label and accessible name.', value: 'Controlled ordered records.', - onChange: 'Emits next records plus add/update/remove/reorder metadata.', + onChange: 'Emits next records plus add/update/remove metadata.', getItemKey: 'Required stable unique record identity.', createItem: 'Creates a new keyed record for Add.', columns: @@ -582,8 +615,8 @@ export const docsDense = { getItemStatus: 'Per-record InputStatus resolver.', getFieldStatus: 'Per-field resolver; complete status plus tooltip variant are forwarded to the rendered input.', - isReorderable: - 'Adds a bare six-dot grip that hangs in the inline-start gutter beside the first field (fields never move; grip plus clearance take 16px, inside standard container padding), revealed on record hover or focus and always shown on touch, without a tooltip. During the temporary drag state, source and free-moving preview use 50% opacity while the list stays stationary; full opacity returns on drop/cancel and tokenized motion animates the committed order. Vertical position controls insertion. Focused Arrow Up/Down moves immediately, and keyboard lift/move/drop/cancel is also supported.', + reorderable: + "createReorderable object keyed by getItemKey: core List's reorder handle at each record's end (after Remove), revealed on hover/focus, shown on touch: hold then drag (touch), drag (mouse), activate for reorder mode then arrows (keys), with a hint showing the moves and Done; the record dims while a copy follows and a line marks the drop, the other records stay still; each move goes to the object's onReorder({key, to}); its isReorderable keeps a record in place; handles stay focusable but inert while disabled or loading.", isDisabled: 'Disables collection, actions, and rendered inputs.', isLoading: 'Marks busy and prevents changes.', maxItems: 'Makes Add unavailable at the limit.', @@ -607,7 +640,7 @@ export const docsDense = { { guidance: true, description: - 'Follow pointer X and Y in the floating preview, use Y for insertion, and commit once on release; focused Arrow Up/Down moves immediately, with lift mode available for extended keyboard control.', + 'Reorder through the handle; one onReorder call per drop or key move, and no onChange; focus stays on the handle.', }, { guidance: true, diff --git a/packages/lab/src/ListInput/ListInput.test.tsx b/packages/lab/src/ListInput/ListInput.test.tsx index 96a9fc9197a5a..bad344a65b3e1 100644 --- a/packages/lab/src/ListInput/ListInput.test.tsx +++ b/packages/lab/src/ListInput/ListInput.test.tsx @@ -3,14 +3,23 @@ /** * @file ListInput.test.tsx * @input Uses Vitest, Testing Library, and the controlled ListInput API - * @output Behavioral coverage for list editing, pointer-stable adding, tokenized mutation motion, validation, focus, the hover-revealed reorder grip that hangs beside the first field, and free-floating stationary-list reordering + * @output Behavioral coverage for list editing, pointer-stable adding, + * tokenized mutation motion, validation, focus, and reordering. Reordering + * goes through core List's reorder handle at each record's end, with keys + * here. The handle is the one element it adds to a record, and it comes + * with the owner's object. The a11y Chromium specs cover it in a real + * browser * @position Lab tests; validates ListInput.tsx * * SYNC: When ListInput.tsx behavior changes, update these tests. */ +import {readFileSync} from 'node:fs'; +import path from 'node:path'; import {afterEach, describe, expect, it, vi} from 'vitest'; -import {useState} from 'react'; +import {useMemo, useState} from 'react'; +import userEvent from '@testing-library/user-event'; +import {createReorderable} from '@astryxdesign/core'; import { fireEvent, render, @@ -116,6 +125,72 @@ function renderListInput(overrides: Partial> = {}) { return render( {...props} />); } +/** A reorderable object whose moves go to `onReorder` and nowhere else. */ +function reorderableFor( + onReorder: (move: {key: string | number; to: number}) => void = () => {}, +) { + return createReorderable({onReorder}); +} + +/** + * A reorderable ListInput whose owner applies each move it is handed to its + * value, as an app does. + */ +function OwnedList({ + onReorder, + isReorderable, + ...props +}: Partial> & { + onReorder?: (move: {key: string | number; to: number}) => void; + isReorderable?: (key: string | number) => boolean; +}) { + const [value, setValue] = useState(props.value ?? guests); + const reorderable = useMemo( + () => + createReorderable({ + onReorder: move => { + onReorder?.(move); + setValue(current => { + const moved = current.find(guest => guest.id === move.key); + if (moved == null) { + return current; + } + const rest = current.filter(guest => guest !== moved); + return [...rest.slice(0, move.to), moved, ...rest.slice(move.to)]; + }); + }, + isReorderable, + }), + [onReorder, isReorderable], + ); + return ( + + label="Guests" + itemName="guest" + getItemKey={guest => guest.id} + createItem={() => createdGuest} + columns={columns} + reorderable={reorderable} + {...props} + value={value} + onChange={(nextValue, change) => { + props.onChange?.(nextValue, change); + setValue(nextValue); + }} + /> + ); +} + +const isNotAda = (key: string | number) => key !== 'ada'; + +const HINT = + 'Drag to move this item, or press Space or Enter, then the arrow keys to move it one place at a time. Press Escape when you are done.'; + +const shownNames = () => + screen + .getAllByRole('textbox') + .map(input => (input as HTMLInputElement).value); + function classTokens(element: Element | null): string[] { return element?.getAttribute('class')?.split(/\s+/).filter(Boolean) ?? []; } @@ -740,31 +815,28 @@ describe('ListInput', () => { ).not.toBeInTheDocument(); }); - it('hangs the grip beside the first field and reveals it on hover or focus', () => { + it("ends each record with core List's reorder handle, revealed on hover or focus", () => { + // A fine pointer (jsdom models a mouse): hover reveals the handle. + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); const reveal = revealClasses(); - const {container} = renderListInput({isReorderable: true}); + const {container} = renderListInput({reorderable: reorderableFor()}); const row = container.querySelector('[data-list-input-row]'); const handle = screen.getByRole('button', {name: 'Reorder guest 1'}); const remove = screen.getByRole('button', {name: 'Remove guest 1'}); - const [firstField] = within(row!).getAllByRole('textbox'); - - // The grip is the record's first control in reading and tab order. It sits - // in the first field's cell but hangs in the inline-start gutter, out of the - // flow, so the fields keep their columns. - expect(within(row!).getAllByRole('button')).toEqual([handle, remove]); - expect(row!.querySelector('[data-list-input-cell="0"]')).toContainElement( - handle, + + // The handle is the record's last control, after Remove in Remove's + // cell, as UIKit's reorder control ends an editing row. + expect(within(row!).getAllByRole('button')).toEqual([remove, handle]); + expect(handle).toHaveAttribute('data-astryx-list-reorder'); + expect(row!.querySelector('[data-list-input-cell]')?.contains(handle)).toBe( + false, ); - expect( - handle.compareDocumentPosition(firstField) & - Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy(); - // The row is the reveal container; the grip fades with opacity alone, so - // at rest it stays mounted, named, and focusable. - expect(reveal.container.length).toBeGreaterThan(0); + // The row is the reveal container; the controls' cell fades with opacity + // alone, so at rest the handle stays mounted, named, and focusable. + const handleCell = [...row!.children].find(child => child.contains(handle)); expect(classTokens(row)).toEqual(expect.arrayContaining(reveal.container)); - expect(classTokens(handle.parentElement)).toEqual( + expect(classTokens(handleCell ?? null)).toEqual( expect.arrayContaining(reveal.grip), ); expect(handle).toBeEnabled(); @@ -773,8 +845,8 @@ describe('ListInput', () => { it('reveals Remove on hover or focus, with or without reordering', () => { const reveal = revealClasses(); - for (const isReorderable of [false, true]) { - const {container, unmount} = renderListInput({isReorderable}); + for (const reorderable of [undefined, reorderableFor()]) { + const {container, unmount} = renderListInput({reorderable}); const rows = container.querySelectorAll( '[data-list-input-row]', ); @@ -801,85 +873,8 @@ describe('ListInput', () => { } }); - it('keeps the moving grip shown and quiets other rows during a pointer drag', () => { - const reveal = revealClasses(); - expect(reveal.onlyShown.length).toBeGreaterThan(0); - expect(reveal.onlyInactive.length).toBeGreaterThan(0); - const {container} = renderListInput({isReorderable: true}); - const rows = container.querySelectorAll( - '[data-list-input-row]', - ); - const handle = screen.getByRole('button', {name: 'Reorder guest 1'}); - const hasAll = (element: Element | null, tokens: string[]) => - tokens.every(token => classTokens(element).includes(token)); - const hasAny = (element: Element | null, tokens: string[]) => - tokens.some(token => classTokens(element).includes(token)); - - fireEvent.pointerDown(handle, { - button: 0, - pointerId: 4, - clientX: 10, - clientY: 10, - }); - - expect(hasAll(handle.parentElement, reveal.onlyShown)).toBe(true); - expect(hasAny(rows[0], reveal.onlyInactive)).toBe(false); - expect(hasAll(rows[1], reveal.onlyInactive)).toBe(true); - // The duplicate under the pointer shows its grip too. - const previewGrip = document.querySelector( - '[data-list-input-drag-preview] [data-list-input-cell="0"] button', - ); - expect(hasAll(previewGrip?.parentElement ?? null, reveal.onlyShown)).toBe( - true, - ); - - fireEvent.pointerUp(handle, {pointerId: 4, clientX: 10, clientY: 10}); - - expect(hasAny(handle.parentElement, reveal.onlyShown)).toBe(false); - expect(hasAny(rows[1], reveal.onlyInactive)).toBe(false); - }); - - it('keeps a lifted grip shown without quieting rows during keyboard reorder', () => { - const reveal = revealClasses(); - const {container} = renderListInput({isReorderable: true}); - const handle = screen.getByRole('button', {name: 'Reorder guest 1'}); - const otherRow = container.querySelectorAll('[data-list-input-row]')[1]; - - handle.focus(); - fireEvent.keyDown(handle, {key: ' ', code: 'Space'}); - expect(classTokens(handle.parentElement)).toEqual( - expect.arrayContaining(reveal.onlyShown), - ); - expect( - classTokens(otherRow).some(token => reveal.onlyInactive.includes(token)), - ).toBe(false); - - fireEvent.keyDown(handle, {key: 'Escape'}); - expect( - classTokens(handle.parentElement).some(token => - reveal.onlyShown.includes(token), - ), - ).toBe(false); - }); - it('focuses the first field of an added record, not its grip', () => { - function ReorderableList() { - const [value, setValue] = useState(guests); - return ( - - label="Guests" - itemName="guest" - value={value} - onChange={setValue} - getItemKey={guest => guest.id} - createItem={() => createdGuest} - columns={columns} - isReorderable - /> - ); - } - - render(); + render(); fireEvent.click(screen.getByRole('button', {name: 'Add guest'})); expect(screen.getByDisplayValue('Linus')).toHaveFocus(); @@ -988,367 +983,129 @@ describe('ListInput', () => { }); }); - it('moves a focused handle with arrow keys without showing a tooltip', () => { + it("moves a record in reorder mode with the arrow keys, through the owner's onReorder", async () => { + const user = userEvent.setup(); + const onReorder = vi.fn(); const onChange = vi.fn(); - - function ArrowReorderList() { - const [value, setValue] = useState(guests); - return ( - - label="Guests" - itemName="guest" - value={value} - onChange={(nextValue, change) => { - onChange(nextValue, change); - setValue(nextValue); - }} - getItemKey={guest => guest.id} - createItem={() => createdGuest} - columns={columns} - isReorderable - /> - ); - } - - render(); + render(); const handle = screen.getByRole('button', {name: 'Reorder guest 1'}); expect(handle).toHaveAttribute('type', 'button'); + expect(handle).toHaveAccessibleDescription(HINT); expect( screen .queryAllByRole('tooltip', {hidden: true}) .some(tooltip => tooltip.textContent === 'Reorder guest 1'), ).toBe(false); - expect(handle).toHaveAccessibleDescription( - 'Use Arrow Up or Arrow Down to move this item one position. Press Space or Enter to pick it up for extended keyboard reordering.', - ); + // Enter turns on reorder mode. Each arrow then moves the record a place. + // The move is reported in the value's terms: its key and its new index. handle.focus(); - fireEvent.keyDown(handle, {key: 'ArrowDown', altKey: true}); + await user.keyboard('{Enter}'); + await user.keyboard('{ArrowUp}'); + expect(onReorder).not.toHaveBeenCalled(); + await user.keyboard('{ArrowDown}'); + expect(onReorder).toHaveBeenCalledTimes(1); + expect(onReorder).toHaveBeenCalledWith({key: 'ada', to: 1}); + expect(shownNames()).toEqual(['Grace', 'Ada']); + // A move is the owner's, never a ListInput change. expect(onChange).not.toHaveBeenCalled(); - - fireEvent.keyDown(handle, {key: 'ArrowUp'}); - expect(onChange).not.toHaveBeenCalled(); - - fireEvent.keyDown(handle, {key: 'ArrowDown'}); - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenCalledWith([guests[1], guests[0]], { - type: 'reorder', - item: guests[0], - fromIndex: 0, - toIndex: 1, - }); - expect( - screen - .getAllByRole('textbox') - .map(input => (input as HTMLInputElement).value), - ).toEqual(['Grace', 'Ada']); expect(handle).toHaveFocus(); expect(handle).toHaveAccessibleName('Reorder guest 2'); - expect( - document.querySelector('[data-list-input-reorder-source]'), - ).not.toBeInTheDocument(); }); - it('commits a keyboard reorder with change metadata', () => { - const onChange = vi.fn(); - renderListInput({isReorderable: true, onChange}); + it('keeps reorder mode through a move that renames the record, and announces it by its new name', async () => { + const user = userEvent.setup(); + const onReorder = vi.fn(); + render(); const handle = screen.getByRole('button', {name: 'Reorder guest 1'}); - expect(handle.querySelectorAll('circle')).toHaveLength(6); handle.focus(); - fireEvent.keyDown(handle, {key: ' ', code: 'Space'}); - let source = document.querySelector('[data-list-input-reorder-source]'); - expect(source?.closest('li')).toHaveAttribute('aria-posinset', '1'); - expect(source?.querySelector('input')).toHaveValue('Ada'); - - fireEvent.keyDown(handle, {key: 'ArrowDown'}); - source = document.querySelector('[data-list-input-reorder-source]'); - expect(source?.closest('li')).toHaveAttribute('aria-posinset', '2'); - expect(source?.querySelector('input')).toHaveValue('Ada'); - - fireEvent.keyDown(handle, {key: 'Enter'}); - - expect(onChange).toHaveBeenCalledWith([guests[1], guests[0]], { - type: 'reorder', - item: guests[0], - fromIndex: 0, - toIndex: 1, - }); - expect( - document.querySelector('[data-list-input-reorder-source]'), - ).not.toBeInTheDocument(); - }); - - it('keeps pointer order stationary, follows both pointer axes, and commits once', () => { - const onChange = vi.fn(); - const {container} = renderListInput({ - isReorderable: true, - onChange, - value: [...guests, createdGuest], - }); - const rows = container.querySelectorAll( - '[data-list-input-row]', - ); - vi.spyOn(rows[0], 'getBoundingClientRect').mockReturnValue({ - x: 0, - y: 0, - top: 0, - right: 400, - bottom: 40, - left: 0, - width: 400, - height: 40, - toJSON: () => {}, - }); - vi.spyOn(rows[1], 'getBoundingClientRect').mockReturnValue({ - x: 0, - y: 40, - top: 40, - right: 400, - bottom: 80, - left: 0, - width: 400, - height: 40, - toJSON: () => {}, - }); - vi.spyOn(rows[2], 'getBoundingClientRect').mockReturnValue({ - x: 0, - y: 80, - top: 80, - right: 400, - bottom: 120, - left: 0, - width: 400, - height: 40, - toJSON: () => {}, - }); - const remove = screen.getByRole('button', {name: 'Remove guest 2'}); - const handle = screen.getByRole('button', {name: 'Reorder guest 2'}); - expect(within(rows[1]).getAllByRole('button')).toEqual([handle, remove]); - - fireEvent.pointerDown(handle, { - button: 0, - pointerId: 7, - clientX: 300, - clientY: 50, - }); - - const source = container.querySelector( - '[data-list-input-reorder-source]', - ); - const preview = document.querySelector( - '[data-list-input-drag-preview]', - ); - const previewLayer = preview?.closest('[popover="manual"]'); - expect(source).toBe(rows[1]); - expect(getComputedStyle(source!).opacity).toBe('0.5'); - expect(container).toContainElement(preview); - expect(preview).toBeInTheDocument(); - expect(previewLayer).toBeInTheDocument(); - expect(getComputedStyle(previewLayer!).opacity).toBe('0.5'); - expect(preview).toHaveAttribute('aria-hidden', 'true'); - expect(preview?.querySelector('input')).toHaveValue('Grace'); - expect(previewLayer!.style.getPropertyValue('--x-transform')).toBe( - 'translate3d(0px, 40px, 0)', - ); - expect( - container.querySelector('[data-list-input-drop-target]'), - ).not.toBeInTheDocument(); - - fireEvent.pointerMove(handle, {pointerId: 7, clientX: 345, clientY: 5}); - expect(previewLayer!.style.getPropertyValue('--x-transform')).toBe( - 'translate3d(45px, -5px, 0)', - ); - expect([ - ...container.querySelectorAll('[data-list-input-row]'), - ]).toEqual([...rows]); - expect( - screen - .getAllByRole('textbox') - .map(input => (input as HTMLInputElement).value), - ).toEqual(['Ada', 'Grace', 'Linus']); - expect(onChange).not.toHaveBeenCalled(); - expect(rows[0].closest('li')).toHaveAttribute( - 'data-list-input-drop-target', - 'before', - ); - - fireEvent.pointerMove(handle, {pointerId: 7, clientX: 245, clientY: 115}); - expect(previewLayer!.style.getPropertyValue('--x-transform')).toBe( - 'translate3d(-55px, 105px, 0)', - ); - expect(rows[0].closest('li')).not.toHaveAttribute( - 'data-list-input-drop-target', - ); - expect(rows[2].closest('li')).toHaveAttribute( - 'data-list-input-drop-target', - 'after', + await user.keyboard('{Enter}{ArrowDown}'); + // Records are named by their place, so the move renames this one. + expect(handle).toHaveAccessibleName('Reorder guest 2'); + expect(handle).toHaveAttribute('aria-pressed', 'true'); + await waitFor(() => + expect(document.body).toHaveTextContent( + 'guest 2, position 2 of 2, after guest 1, at the end', + ), ); - expect([ - ...container.querySelectorAll('[data-list-input-row]'), - ]).toEqual([...rows]); - expect(onChange).not.toHaveBeenCalled(); - - fireEvent.pointerUp(handle, {pointerId: 7, clientX: 245, clientY: 115}); - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenCalledWith( - [guests[0], createdGuest, guests[1]], - { - type: 'reorder', - item: guests[1], - fromIndex: 1, - toIndex: 2, - }, + // Still in the mode: the next arrow moves it back. + await user.keyboard('{ArrowUp}'); + expect(onReorder).toHaveBeenCalledTimes(2); + expect(onReorder).toHaveBeenLastCalledWith({key: 'ada', to: 0}); + expect(handle).toHaveAttribute('aria-pressed', 'true'); + await waitFor(() => + expect(document.body).toHaveTextContent( + 'guest 1, position 1 of 2, at the top, before guest 2', + ), ); - expect( - document.querySelector('[data-list-input-drop-target]'), - ).not.toBeInTheDocument(); - expect( - document.querySelector('[data-list-input-drag-preview]'), - ).not.toBeInTheDocument(); }); - it('cancels a pointer reorder when a second pointer lands, as a pinch does', () => { - const onChange = vi.fn(); - const {container} = renderListInput({isReorderable: true, onChange}); - const handle = screen.getByRole('button', {name: 'Reorder guest 1'}); - - fireEvent.pointerDown(handle, { - button: 0, - pointerId: 8, - clientX: 10, - clientY: 10, - }); - fireEvent.pointerMove(handle, {pointerId: 8, clientX: 10, clientY: 34}); - expect( - container.querySelector('[data-list-input-reorder-source]'), - ).toBeInTheDocument(); - - // The second finger of a pinch lands elsewhere on the page. - fireEvent.pointerDown(document.body, {pointerId: 9, clientX: 200}); - expect( - container.querySelector('[data-list-input-reorder-source]'), - ).not.toBeInTheDocument(); - - fireEvent.pointerUp(handle, {pointerId: 8, clientX: 10, clientY: 90}); - expect(onChange).not.toHaveBeenCalled(); - }); - - it('clears a sub-threshold pointer drag without reordering', () => { - const onChange = vi.fn(); - const {container} = renderListInput({isReorderable: true, onChange}); - const handle = screen.getByRole('button', {name: 'Reorder guest 1'}); - - fireEvent.pointerDown(handle, { - button: 0, - pointerId: 8, - clientX: 10, - clientY: 10, - }); - expect( - fireEvent.pointerMove(handle, { - pointerId: 8, - clientX: 14, - clientY: 10, - }), - ).toBe(true); - - expect( - container.querySelector('[data-list-input-reorder-source]'), - ).toBeInTheDocument(); - expect( - document.querySelector('[data-list-input-drag-preview]'), - ).toBeInTheDocument(); - expect( - container.querySelector('[data-list-input-drop-target]'), - ).not.toBeInTheDocument(); - - fireEvent.pointerUp(handle, {pointerId: 8, clientX: 14, clientY: 10}); - - expect(onChange).not.toHaveBeenCalled(); - expect( - container.querySelector('[data-list-input-reorder-source]'), - ).not.toBeInTheDocument(); - expect( - document.querySelector('[data-list-input-drag-preview]'), - ).not.toBeInTheDocument(); + it('adds one element to each record for reordering: the handle', () => { + const plain = renderListInput(); + const plainCount = plain.container + .querySelector('[data-list-input-row]')! + .querySelectorAll('*').length; + plain.unmount(); + const {container} = renderListInput({reorderable: reorderableFor()}); + const row = container.querySelector('[data-list-input-row]'); + expect(row).toContainElement( + screen.getByRole('button', {name: 'Reorder guest 1'}), + ); + expect(row!.querySelectorAll('*').length - plainCount).toBe(1); }); - it('activates a pointer drag after horizontal threshold movement', async () => { - const onChange = vi.fn(); - renderListInput({isReorderable: true, onChange}); - const rows = document.querySelectorAll( - '[data-list-input-row]', + it('loads no reorder code without reorderable: the handle comes with the object', () => { + const source = readFileSync( + path.join(import.meta.dirname, 'ListInput.tsx'), + 'utf8', ); - vi.spyOn(rows[1], 'getBoundingClientRect').mockReturnValue({ - x: 0, - y: 40, - top: 40, - right: 400, - bottom: 80, - left: 0, - width: 400, - height: 40, - toJSON: () => {}, - }); - - const handle = screen.getByRole('button', {name: 'Reorder guest 1'}); - - fireEvent.pointerDown(handle, { - button: 0, - pointerId: 9, - clientX: 10, - clientY: 10, - }); - - expect( - fireEvent.pointerMove(handle, { - pointerId: 9, - clientX: 15, - clientY: 10, - }), - ).toBe(false); - - fireEvent.pointerUp(handle, {pointerId: 9, clientX: 15, clientY: 10}); - - expect(onChange).not.toHaveBeenCalled(); - // The grab and the return land in the same frame, so they are spoken - // together, in order (spec:AST-086 FR9). - await waitFor(() => { - expect( - Array.from(document.querySelectorAll('[aria-live="polite"]')).some( - node => - node.textContent === - 'guest 1 grabbed. Use arrow keys to move, Space or Enter to drop, and Escape to cancel. guest returned to position 1.', - ), - ).toBe(true); - }); + const names = + /import\s*\{([^}]*)\}\s*from\s*'@astryxdesign\/core\/internal\/reorder';/ + .exec(source)?.[1] + .split(',') + .map(name => name.trim()) + .filter(name => name !== ''); + expect(names?.sort()).toEqual(['ListReorderScope', 'useListReorderScope']); + expect(source).not.toMatch(/ListReorderHandle/); }); - it('cancels a keyboard reorder with Escape', () => { - const onChange = vi.fn(); - renderListInput({isReorderable: true, onChange}); + it('ends reorder mode with Escape, keeping the moves', async () => { + const user = userEvent.setup(); + const onReorder = vi.fn(); + render(); const handle = screen.getByRole('button', {name: 'Reorder guest 1'}); handle.focus(); - fireEvent.keyDown(handle, {key: 'Enter'}); - fireEvent.keyDown(handle, {key: 'ArrowDown'}); - fireEvent.keyDown(handle, {key: 'Escape'}); + await user.keyboard('{Enter}{ArrowDown}{Escape}'); + expect(onReorder).toHaveBeenCalledTimes(1); + expect(shownNames()).toEqual(['Grace', 'Ada']); + // Out of the mode, an arrow moves nothing. + await user.keyboard('{ArrowUp}'); + expect(onReorder).toHaveBeenCalledTimes(1); + expect(handle).toHaveFocus(); + }); - expect(onChange).not.toHaveBeenCalled(); - expect( - document.querySelector('[data-list-input-reorder-source]'), - ).not.toBeInTheDocument(); - expect( - screen - .getAllByRole('textbox') - .map(input => (input as HTMLInputElement).value), - ).toEqual(['Ada', 'Grace']); + it("gives no handle to a record the owner's isReorderable rejects, and moves no record past it at either end", async () => { + const user = userEvent.setup(); + const onReorder = vi.fn(); + render(); + expect(screen.queryByRole('button', {name: 'Reorder guest 1'})).toBeNull(); + const handle = screen.getByRole('button', {name: 'Reorder guest 2'}); + handle.focus(); + await user.keyboard('{Enter}{ArrowUp}{Escape}'); + expect(onReorder).not.toHaveBeenCalled(); + expect(shownNames()).toEqual(['Ada', 'Grace']); }); it('locks every field and mutation control when isDisabled is set', () => { const onChange = vi.fn(); - renderListInput({isDisabled: true, isReorderable: true, onChange}); + const onReorder = vi.fn(); + renderListInput({ + isDisabled: true, + reorderable: reorderableFor(onReorder), + onChange, + }); const group = screen.getByRole('group'); expect(group).toHaveAttribute('aria-disabled', 'true'); @@ -1361,9 +1118,15 @@ describe('ListInput', () => { 'aria-disabled', 'true', ); - expect( - screen.getByRole('button', {name: 'Reorder guest 1'}), - ).toBeDisabled(); + // The handle stays focusable but moves nothing (spec:AST-085 FR1, FR2). + const handle = screen.getByRole('button', {name: 'Reorder guest 1'}); + expect(handle).toHaveAttribute('aria-disabled', 'true'); + expect(handle).not.toHaveAttribute('disabled'); + handle.focus(); + fireEvent.keyDown(handle, {key: 'ArrowDown'}); + fireEvent.keyDown(handle, {key: ' '}); + fireEvent.keyDown(handle, {key: 'ArrowDown'}); + expect(handle).toHaveFocus(); // Remove carries a tooltip, so it is disabled via aria and stays focusable // rather than dropping out of the tab order with its explanation. expect( @@ -1373,10 +1136,11 @@ describe('ListInput', () => { // The values stay readable — a disabled list still communicates content. expect(screen.getAllByRole('textbox')[0]).toHaveValue('Ada'); expect(onChange).not.toHaveBeenCalled(); + expect(onReorder).not.toHaveBeenCalled(); }); it('marks the list busy and locks the same controls when isLoading is set', () => { - renderListInput({isLoading: true, isReorderable: true}); + renderListInput({isLoading: true, reorderable: reorderableFor()}); const group = screen.getByRole('group'); expect(group).toHaveAttribute('aria-busy', 'true'); @@ -1398,53 +1162,39 @@ describe('ListInput', () => { expect(addButton).toHaveAttribute('aria-busy', 'true'); expect( screen.getByRole('button', {name: 'Reorder guest 1'}), - ).toBeDisabled(); + ).toHaveAttribute('aria-disabled', 'true'); expect( screen.getByRole('button', {name: 'Remove guest 1'}), ).toHaveAttribute('aria-disabled', 'true'); }); - it('cancels an in-flight keyboard reorder when the list becomes busy', () => { - const onChange = vi.fn(); - const {rerender} = render( - - label="Guests" - itemName="guest" - value={guests} - onChange={onChange} - getItemKey={guest => guest.id} - createItem={() => createdGuest} - columns={columns} - isReorderable - />, - ); + it('abandons reorder mode when the list becomes busy', async () => { + const user = userEvent.setup(); + const onReorder = vi.fn(); + const reorderable = reorderableFor(onReorder); + const props: ListInputProps = { + label: 'Guests', + itemName: 'guest', + value: guests, + onChange: () => {}, + getItemKey: guest => guest.id, + createItem: () => createdGuest, + columns, + reorderable, + }; + const {rerender} = render( {...props} />); const handle = screen.getByRole('button', {name: 'Reorder guest 1'}); handle.focus(); - fireEvent.keyDown(handle, {key: ' ', code: 'Space'}); - expect( - document.querySelector('[data-list-input-reorder-source]'), - ).toBeInTheDocument(); - - rerender( - - label="Guests" - itemName="guest" - value={guests} - onChange={onChange} - getItemKey={guest => guest.id} - createItem={() => createdGuest} - columns={columns} - isReorderable - isLoading - />, - ); + await user.keyboard('{Enter}'); + rerender( {...props} isLoading />); - // The pick-up is abandoned rather than committed, so no order change escapes. - expect( - document.querySelector('[data-list-input-reorder-source]'), - ).not.toBeInTheDocument(); - expect(onChange).not.toHaveBeenCalled(); + // The handle keeps focus but is inert now: no key moves the record, so + // no order change escapes (spec:AST-085 FR2, FR4). + expect(handle).toHaveFocus(); + expect(handle).toHaveAttribute('aria-disabled', 'true'); + await user.keyboard('{ArrowDown}{Enter}'); + expect(onReorder).not.toHaveBeenCalled(); }); it('describes warning and success list statuses from the group', () => { @@ -1502,44 +1252,6 @@ describe('ListInput', () => { ); }); - it('moves a picked-up record to the first and last positions with Home and End', () => { - const onChange = vi.fn(); - const threeGuests = [...guests, createdGuest]; - render( - - label="Guests" - itemName="guest" - value={threeGuests} - onChange={onChange} - getItemKey={guest => guest.id} - createItem={() => createdGuest} - columns={columns} - isReorderable - />, - ); - - const handle = screen.getByRole('button', {name: 'Reorder guest 3'}); - handle.focus(); - fireEvent.keyDown(handle, {key: ' ', code: 'Space'}); - - fireEvent.keyDown(handle, {key: 'Home'}); - expect( - document.querySelector('[data-list-input-reorder-source]')?.closest('li'), - ).toHaveAttribute('aria-posinset', '1'); - - fireEvent.keyDown(handle, {key: 'End'}); - expect( - document.querySelector('[data-list-input-reorder-source]')?.closest('li'), - ).toHaveAttribute('aria-posinset', '3'); - - fireEvent.keyDown(handle, {key: 'Home'}); - fireEvent.keyDown(handle, {key: 'Enter'}); - expect(onChange).toHaveBeenCalledWith( - [createdGuest, guests[0], guests[1]], - {type: 'reorder', item: createdGuest, fromIndex: 2, toIndex: 0}, - ); - }); - it('moves focus to the next record after a removal, and to Add when the list empties', async () => { function ControlledList() { const [value, setValue] = useState(guests); diff --git a/packages/lab/src/ListInput/ListInput.tsx b/packages/lab/src/ListInput/ListInput.tsx index fa441ed1c1712..27d9be58c6211 100644 --- a/packages/lab/src/ListInput/ListInput.tsx +++ b/packages/lab/src/ListInput/ListInput.tsx @@ -4,7 +4,11 @@ /** * @file ListInput.tsx - * @input React, StyleX, Astryx field/list/button/icon/layer/empty-state primitives, the container-reveal hook, compact size context, browser scroll geometry, programmatic theme tokens, and shared Lab reorder styles + * @input React, StyleX, Astryx field/list/button/icon/empty-state + * primitives, the container-reveal hook, compact size context, browser + * scroll geometry, and programmatic theme tokens. Also core List's reorder + * session and handle, run by the owner's createReorderable object through + * Core's internal entry `@astryxdesign/core/internal/reorder` * @output Exports ListInput and its controlled data, column, renderer, and change types * @position Lab experiment (RFC meta/astryx#4531) for editing compact repeated records * @@ -21,13 +25,10 @@ import { useLayoutEffect, useMemo, useRef, - useState, type Key, type ReactNode, - type SVGProps, } from 'react'; import * as stylex from '@stylexjs/stylex'; -import {flushSync} from 'react-dom'; import type {BaseProps} from '@astryxdesign/core'; import {Button} from '@astryxdesign/core/Button'; import {EmptyState} from '@astryxdesign/core/EmptyState'; @@ -41,11 +42,14 @@ import { } from '@astryxdesign/core/hooks'; import {IconButton} from '@astryxdesign/core/IconButton'; import {useTranslator} from '@astryxdesign/core/i18n'; -import {useLayer} from '@astryxdesign/core/Layer'; +import { + ListReorderScope, + useListReorderScope, +} from '@astryxdesign/core/internal/reorder'; +import type {Reorderable} from '@astryxdesign/core'; import {SizeProvider} from '@astryxdesign/core/SizeContext'; import type {ColumnWidth} from '@astryxdesign/core/Table'; import {useTheme} from '@astryxdesign/core/theme'; -import {VisuallyHidden} from '@astryxdesign/core/VisuallyHidden'; import { colorVars, fontWeightVars, @@ -53,12 +57,7 @@ import { spacingVars, typeScaleVars, } from '@astryxdesign/core/theme/tokens.stylex'; -import { - focusOutlineStyles, - mergeProps, - themeProps, -} from '@astryxdesign/core/utils'; -import {reorderStyles} from '../reorderStyles'; +import {mergeProps, themeProps} from '@astryxdesign/core/utils'; // Bundlers replace this value at build time. The declaration lets a product // without Node's types type-check this file. @@ -73,8 +72,7 @@ export type ListInputChange = index: number; columnKey?: string; } - | {type: 'remove'; item: T; index: number} - | {type: 'reorder'; item: T; fromIndex: number; toIndex: number}; + | {type: 'remove'; item: T; index: number}; export interface ListInputValueContext { /** Current record. */ @@ -146,8 +144,16 @@ export interface ListInputProps extends Omit< columnKey: string, index: number, ) => InputStatus | undefined; - /** Enables handle-only pointer and keyboard reordering. @default false */ - isReorderable?: boolean; + /** + * Makes the records reorderable: pass the object + * `createReorderable` makes, keyed by `getItemKey`. Each record ends with + * core List's reorder handle. A finger holds it a moment, then drags. A + * mouse drags at once. Its arrow keys move the record. The object's + * `isReorderable(key)` says which records move, and each move goes to its + * `onReorder({key, to})`, `to` being the record's index in `value` after + * the move. Without it, the records keep their order. + */ + reorderable?: Reorderable; /** Disables fields and mutation controls. @default false */ isDisabled?: boolean; /** Marks the list busy and prevents mutations. @default false */ @@ -162,23 +168,6 @@ export interface ListInputProps extends Omit< isRequired?: boolean; } -interface ReorderState { - itemKey: Key; - fromIndex: number; - toIndex: number; - originalValue: T[]; - mode: 'keyboard' | 'pointer'; - pointerId?: number; - pointerStartX?: number; - pointerStartY?: number; - pointerOffsetX?: number; - pointerOffsetY?: number; - pointerClientX?: number; - pointerClientY?: number; - previewWidth?: number; - hasPointerMoved?: boolean; -} - interface PendingFocus { itemKey?: string; target: 'first-field' | 'remove' | 'add'; @@ -203,6 +192,9 @@ interface PendingMutationMotion { matchesValue: (nextValue: T[]) => boolean; } +// Core List's reorder handle is 28 px wide for every pointer. +const REORDER_HANDLE_SIZE = 28; + const styles = stylex.create({ group: { display: 'flex', @@ -239,6 +231,9 @@ const styles = stylex.create({ '@container list-input (max-width: 640px)': spacingVars['--spacing-2'], }, minWidth: 0, + // Core's reorder handle reaches into a ListItem row's inline inset. A + // record has no inset, so the handle and its focus ring end at its edge. + '--_item-inset-inline': '0px', }, rowFieldsOnly: { gridTemplateColumns: 'minmax(0, 1fr)', @@ -246,6 +241,13 @@ const styles = stylex.create({ rowWithRemove: { gridTemplateColumns: `minmax(0, 1fr) ${sizeVars['--size-element-md']}`, }, + // Fields, then Remove and the reorder handle in one cell at the row's end + // (UIKit's reorder control is the trailing control of an editing row). The + // cell's track is fixed, so the Add row, which has no controls, keeps the + // fields' width. + rowWithRemoveAndHandle: { + gridTemplateColumns: `minmax(0, 1fr) calc(${sizeVars['--size-element-md']} + ${spacingVars['--spacing-1']} + ${REORDER_HANDLE_SIZE}px)`, + }, fields: { display: { default: 'grid', @@ -304,15 +306,13 @@ const styles = stylex.create({ }, }, cellContent: {minWidth: 0}, - // The first field's cell anchors the hanging reorder grip. - handleAnchor: { - position: 'relative', - }, controlCell: { display: 'flex', alignItems: 'center', alignSelf: 'end', - justifyContent: 'center', + // Remove keeps its column in a record that has no reorder handle. + justifyContent: 'flex-start', + columnGap: spacingVars['--spacing-1'], minHeight: sizeVars['--size-element-md'], }, removeControlCell: { @@ -325,53 +325,9 @@ const styles = stylex.create({ '@container list-input (max-width: 640px)': '1', }, }, - // The grip hangs in the inline-start gutter beside the first field's control, - // so it never takes width from the fields. Grip plus clearance is 16px, so it - // sits inside a container's standard padding. The padding bridges the gap to - // the field, so the row stays hovered while the pointer crosses to the grip. - reorderHandleHanger: { - position: 'absolute', - insetBlockStart: 0, - insetBlockEnd: 0, - insetInlineEnd: '100%', - display: 'flex', - alignItems: 'stretch', - paddingInlineEnd: spacingVars['--spacing-2'], - }, - // A bare glyph rather than a filled button: an 8px-wide target the height of - // the field. It meets WCAG 2.5.8 through the spacing exception: the 8px - // clearance keeps a 24px circle centered on it clear of the field. - reorderGrip: { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: spacingVars['--spacing-2'], - padding: 0, - margin: 0, - borderStyle: 'none', - backgroundColor: 'transparent', - color: { - default: colorVars['--color-icon-primary'], - ':disabled': colorVars['--color-icon-disabled'], - }, - }, - reorderGripReveal: { - display: 'flex', - }, - reorderGripGlyph: { - display: 'block', - flexShrink: 0, - width: spacingVars['--spacing-1-5'], - aspectRatio: '3 / 5', - }, emptyItem: { width: '100%', }, - dragPreviewContainer: { - containerType: 'inline-size', - containerName: 'list-input', - width: '100%', - }, }); const dynamicStyles = stylex.create({ @@ -381,28 +337,8 @@ const dynamicStyles = stylex.create({ '@container list-input (max-width: 640px)': 'minmax(0, 1fr)', }, }), - dragPreview: (x: number, y: number, width: number) => ({ - width, - transform: `translate3d(${x}px, ${y}px, 0)`, - }), - rowTransition: (name: string) => ({viewTransitionName: name}), }); -// The six-dot grip drawn at its own size, so the narrow reorder target needs no -// square icon box around it. -function GripVerticalIcon(props: SVGProps) { - return ( - - ); -} - function resolveColumnTrack(width?: ColumnWidth): string { if (width?.type === 'pixel') { return `${width.value}px`; @@ -412,24 +348,13 @@ function resolveColumnTrack(width?: ColumnWidth): string { return `minmax(${minWidth}px, ${proportion}fr)`; } -function moveItem(value: T[], fromIndex: number, toIndex: number): T[] { - if (fromIndex === toIndex) { - return value.slice(); - } - const nextValue = value.slice(); - const [item] = nextValue.splice(fromIndex, 1); - nextValue.splice(toIndex, 0, item); - return nextValue; -} - function joinIDs(...ids: Array): string | undefined { const resolved = ids.filter(Boolean); return resolved.length > 0 ? resolved.join(' ') : undefined; } -// The reorder grip shares the first field's cell but is not a field. const focusableFieldSelector = - '[data-list-input-cell] input, [data-list-input-cell] select, [data-list-input-cell] textarea, [data-list-input-cell] button:not([data-list-input-reorder]), [data-list-input-cell] [tabindex]:not([tabindex="-1"]):not([data-list-input-reorder])'; + '[data-list-input-cell] input, [data-list-input-cell] select, [data-list-input-cell] textarea, [data-list-input-cell] button, [data-list-input-cell] [tabindex]:not([tabindex="-1"])'; function getScrollableAncestors(element: HTMLElement): HTMLElement[] { const scrollRoots: HTMLElement[] = []; @@ -514,55 +439,6 @@ function scrollByInstantly( } } -const viewTransitionRootClassNames = - stylex - .props(reorderStyles.viewTransitionRoot) - .className?.split(/\s+/) - .filter(Boolean) ?? []; -let activeViewTransitionCount = 0; - -function addViewTransitionRootStyles(): void { - if (activeViewTransitionCount === 0) { - document.documentElement.classList.add(...viewTransitionRootClassNames); - } - activeViewTransitionCount += 1; -} - -function removeViewTransitionRootStyles(): void { - activeViewTransitionCount = Math.max(0, activeViewTransitionCount - 1); - if (activeViewTransitionCount === 0) { - document.documentElement.classList.remove(...viewTransitionRootClassNames); - } -} - -function animateControlledUpdate(update: () => void): void { - const prefersReducedMotion = window.matchMedia?.( - '(prefers-reduced-motion: reduce)', - ).matches; - if ( - !prefersReducedMotion && - typeof document.startViewTransition === 'function' - ) { - addViewTransitionRootStyles(); - try { - const transition = document.startViewTransition(() => flushSync(update)); - void transition.finished.then( - removeViewTransitionRootStyles, - removeViewTransitionRootStyles, - ); - } catch { - removeViewTransitionRootStyles(); - update(); - } - return; - } - update(); -} - -function transitionName(scope: string, key: Key): string { - return `${scope}-${String(key)}`.replace(/[^a-zA-Z0-9_-]/g, '-'); -} - function parseDuration(value: string): number { const normalizedValue = value.trim(); const duration = Number.parseFloat(normalizedValue); @@ -655,7 +531,7 @@ function startMutationAnimation( * createItem={() => ({id: crypto.randomUUID(), name: '', email: ''})} * columns={columns} * itemName="guest" - * isReorderable + * reorderable={reorderable} * /> * ``` */ @@ -671,7 +547,7 @@ export function ListInput({ status, getItemStatus, getFieldStatus, - isReorderable = false, + reorderable, isDisabled = false, isLoading = false, maxItems, @@ -689,6 +565,7 @@ export function ListInput({ const reorderInstructionsID = `${groupID}-reorder-instructions`; const rowRefs = useRef(new Map()); const groupRef = useRef(null); + const listRef = useRef(null); const addActionRef = useRef(null); const addButtonRef = useRef(null); const addPointerAnchorTopRef = useRef(null); @@ -699,11 +576,6 @@ export function ListInput({ null, ); const activeMutationAnimationsRef = useRef(new Map()); - const dragPreviewRef = useRef(null); - const reorderStateRef = useRef | null>(null); - const [reorderState, setReorderStateState] = useState | null>( - null, - ); const t = useTranslator(); const announce = useAnnounce(); const {tokens: themeTokens} = useTheme(); @@ -715,70 +587,29 @@ export function ListInput({ const prefersReducedMotion = useMediaQuery( '(prefers-reduced-motion: reduce)', ); - const { - render: renderDragPreviewLayer, - show: showDragPreviewLayer, - hide: hideDragPreviewLayer, - } = useLayer({mode: 'fixed'}); // Each record row reveals its row actions (Remove and, when reorderable, the - // grip) on hover or keyboard focus; touch keeps them visible. + // reorder handle) on hover or keyboard focus; touch keeps them visible. const {getContainerProps, getContentRevealProps} = useContainerReveal(); - - const setReorderState = (nextState: ReorderState | null) => { - reorderStateRef.current = nextState; - setReorderStateState(nextState); - }; - - const displayValue = useMemo(() => { - if (reorderState == null) { - return value; - } - if (reorderState.mode === 'pointer') { - return reorderState.originalValue; - } - return moveItem( - reorderState.originalValue, - reorderState.fromIndex, - reorderState.toIndex, - ); - }, [reorderState, value]); - const showReorderHandle = isReorderable; + // Core List's reorder session, run by the owner's object. The records are + // the list's rows, each ending with its handle. Each move goes to the + // object's onReorder. A disabled or loading list keeps its handles, + // focusable but inert. + const reorderScope = useListReorderScope( + reorderable, + () => listRef.current, + reorderInstructionsID, + ); + const canReorder = reorderScope?.options.isReorderable; + const showReorderHandle = reorderScope != null; + // The handle comes with the owner's object: a list without one loads no + // reorder code. + const Handle = reorderScope?.session.Handle; const showRemoveColumn = true; const fieldGridTemplate = useMemo( () => columns.map(column => resolveColumnTrack(column.width)).join(' '), [columns], ); - const pointerPlacement = useMemo(() => { - if ( - reorderState?.mode !== 'pointer' || - !reorderState.hasPointerMoved || - reorderState.toIndex === reorderState.fromIndex - ) { - return null; - } - const remainingItems = reorderState.originalValue.filter( - (_, index) => index !== reorderState.fromIndex, - ); - const beforeItem = remainingItems[reorderState.toIndex]; - if (beforeItem != null) { - return { - position: 'before' as const, - itemKey: String(getItemKey(beforeItem)), - }; - } - const afterItem = remainingItems.at(-1); - return afterItem == null - ? null - : { - position: 'after' as const, - itemKey: String(getItemKey(afterItem)), - }; - }, [getItemKey, reorderState]); - const previewCloneKey = - reorderState?.mode === 'pointer' ? String(reorderState.itemKey) : null; - const isPointerReorderActive = previewCloneKey != null; - useEffect(() => { if (process.env.NODE_ENV === 'production') { return; @@ -806,36 +637,6 @@ export function ListInput({ } }, [columns, getItemKey, value]); - useEffect(() => { - if ((isDisabled || isLoading) && reorderStateRef.current) { - setReorderState(null); - announce(t('@astryx.listInput.announceReorderCancelled')); - } - }, [isDisabled, isLoading, announce, t]); - - // A second pointer that lands anywhere during a pointer reorder (the second - // finger of a pinch) cancels the reorder. A pinch whose first finger slid - // first zooms without a pointercancel, so the drag would otherwise commit on - // release and drop the row at the end of the list. - useEffect(() => { - if (!isPointerReorderActive) { - return; - } - const onPointerDown = (event: PointerEvent) => { - const activeState = reorderStateRef.current; - if ( - activeState?.mode === 'pointer' && - activeState.pointerId !== event.pointerId - ) { - setReorderState(null); - announce(t('@astryx.listInput.announceReorderCancelled')); - } - }; - document.addEventListener('pointerdown', onPointerDown, true); - return () => - document.removeEventListener('pointerdown', onPointerDown, true); - }, [isPointerReorderActive, announce, t]); - useEffect(() => { const activeAnimations = activeMutationAnimationsRef.current; return () => { @@ -1026,63 +827,9 @@ export function ListInput({ } }, [value]); - useLayoutEffect(() => { - const previewHost = dragPreviewRef.current; - if (previewHost == null || previewCloneKey == null) { - return; - } - const sourceRow = rowRefs.current.get(previewCloneKey); - if (sourceRow == null) { - return; - } - const clone = sourceRow.cloneNode(true) as HTMLElement; - previewHost.style.direction = getComputedStyle(sourceRow).direction; - clone.style.opacity = '1'; - clone.removeAttribute('data-list-input-reorder-source'); - clone.removeAttribute('data-list-input-drop-target'); - clone.removeAttribute('data-list-input-reorder'); - clone.removeAttribute('data-list-input-remove'); - clone.removeAttribute('data-list-input-row'); - clone.setAttribute('aria-hidden', 'true'); - const clonedElements = [clone, ...clone.querySelectorAll('*')]; - for (const element of clonedElements) { - element.removeAttribute('id'); - element.removeAttribute('aria-describedby'); - element.removeAttribute('aria-labelledby'); - element.removeAttribute('name'); - element.removeAttribute('data-list-input-reorder-source'); - element.removeAttribute('data-list-input-drop-target'); - element.removeAttribute('data-list-input-reorder'); - element.removeAttribute('data-list-input-remove'); - element.setAttribute('tabindex', '-1'); - element.style.viewTransitionName = 'none'; - } - previewHost.replaceChildren(clone); - showDragPreviewLayer(); - return () => { - hideDragPreviewLayer(); - previewHost.replaceChildren(); - previewHost.style.removeProperty('direction'); - }; - }, [hideDragPreviewLayer, previewCloneKey, showDragPreviewLayer]); - const mutationsDisabled = isDisabled || isLoading; const hasReachedMax = maxItems != null && value.length >= maxItems; - const cancelMutationAnimations = () => { - for (const animation of activeMutationAnimationsRef.current.values()) { - animation.cancel(); - } - activeMutationAnimationsRef.current.clear(); - }; - - const settleReorderBeforeMutation = () => { - if (reorderState == null && reorderStateRef.current == null) { - return; - } - flushSync(() => setReorderState(null)); - }; - const prepareMutationMotion = (nextValue: T[]) => { const root = groupRef.current; if ( @@ -1164,7 +911,6 @@ export function ListInput({ if (mutationsDisabled || hasReachedMax) { return; } - settleReorderBeforeMutation(); const item = createItem(); const nextValue = [...value, item]; const itemKey = String(getItemKey(item)); @@ -1216,7 +962,6 @@ export function ListInput({ if (mutationsDisabled) { return; } - settleReorderBeforeMutation(); const itemKey = String(getItemKey(item)); const index = value.findIndex( candidate => String(getItemKey(candidate)) === itemKey, @@ -1238,287 +983,14 @@ export function ListInput({ ); }; - const startReorder = ( - item: T, - index: number, - mode: 'keyboard' | 'pointer', - pointerId?: number, - pointerGeometry?: Pick< - ReorderState, - | 'pointerStartX' - | 'pointerStartY' - | 'pointerOffsetX' - | 'pointerOffsetY' - | 'pointerClientX' - | 'pointerClientY' - | 'previewWidth' - >, - ) => { - if (mutationsDisabled || !isReorderable) { - return; - } - cancelMutationAnimations(); - setReorderState({ - itemKey: getItemKey(item), - fromIndex: index, - toIndex: index, - originalValue: value.slice(), - mode, - pointerId, - ...pointerGeometry, - hasPointerMoved: false, - }); - announce( - t('@astryx.listInput.announceGrabbed', {itemName, position: index + 1}), - ); - }; - - const previewReorder = (toIndex: number) => { - const currentState = reorderStateRef.current; - if (currentState == null) { - return; - } - const boundedIndex = Math.max( - 0, - Math.min(toIndex, currentState.originalValue.length - 1), - ); - if (boundedIndex === currentState.toIndex) { - return; - } - setReorderState({...currentState, toIndex: boundedIndex}); - announce( - t('@astryx.listInput.announceMovedToPosition', { - itemName, - position: boundedIndex + 1, - total: currentState.originalValue.length, - }), - ); - }; - - const cancelReorder = () => { - if (reorderStateRef.current == null) { - return; - } - setReorderState(null); - announce(t('@astryx.listInput.announceReorderCancelled')); - }; - - const commitReorder = () => { - const currentState = reorderStateRef.current; - if (currentState == null) { - return; - } - if (currentState.mode === 'pointer' && !currentState.hasPointerMoved) { - setReorderState(null); - return; - } - - const nextValue = moveItem( - currentState.originalValue, - currentState.fromIndex, - currentState.toIndex, - ); - const item = currentState.originalValue[currentState.fromIndex]; - const hasChanged = currentState.fromIndex !== currentState.toIndex; - if (currentState.mode === 'pointer') { - flushSync(() => setReorderState(null)); - } else { - setReorderState(null); - } - if (!hasChanged) { - announce( - t('@astryx.listInput.announceReturnedToPosition', { - itemName, - position: currentState.fromIndex + 1, - }), - ); - return; - } - const commit = () => - onChange(nextValue, { - type: 'reorder', - item, - fromIndex: currentState.fromIndex, - toIndex: currentState.toIndex, - }); - if (currentState.mode === 'pointer') { - animateControlledUpdate(commit); - } else { - commit(); - } - announce( - t('@astryx.listInput.announceDropped', { - itemName, - position: currentState.toIndex + 1, - total: currentState.originalValue.length, - }), - ); - }; - const moveWithArrowKey = (item: T, fromIndex: number, offset: -1 | 1) => { - cancelMutationAnimations(); - const toIndex = Math.max(0, Math.min(fromIndex + offset, value.length - 1)); - if (toIndex === fromIndex) { - announce( - t('@astryx.listInput.announceAlreadyAtBoundary', { - itemName, - boundary: offset < 0 ? 'first' : 'last', - }), - ); - return; - } - const nextValue = moveItem(value, fromIndex, toIndex); - animateControlledUpdate(() => - onChange(nextValue, { - type: 'reorder', - item, - fromIndex, - toIndex, - }), - ); - announce( - t('@astryx.listInput.announceMovedToPosition', { - itemName, - position: toIndex + 1, - total: value.length, - }), - ); - }; - - const handleReorderKeyDown = ( - event: React.KeyboardEvent, - item: T, - index: number, - ) => { - if (event.altKey || event.ctrlKey || event.metaKey) { - return; - } - const activeState = reorderStateRef.current; - const isActivationKey = event.key === ' ' || event.key === 'Enter'; - if (activeState == null) { - if (event.key === 'ArrowUp') { - event.preventDefault(); - moveWithArrowKey(item, index, -1); - return; - } - if (event.key === 'ArrowDown') { - event.preventDefault(); - moveWithArrowKey(item, index, 1); - return; - } - if (isActivationKey) { - event.preventDefault(); - startReorder(item, index, 'keyboard'); - } - return; - } - if (String(activeState.itemKey) !== String(getItemKey(item))) { - return; - } - if (isActivationKey) { - event.preventDefault(); - commitReorder(); - } else if (event.key === 'Escape') { - event.preventDefault(); - cancelReorder(); - } else if (event.key === 'ArrowUp') { - event.preventDefault(); - previewReorder(activeState.toIndex - 1); - } else if (event.key === 'ArrowDown') { - event.preventDefault(); - previewReorder(activeState.toIndex + 1); - } else if (event.key === 'Home') { - event.preventDefault(); - previewReorder(0); - } else if (event.key === 'End') { - event.preventDefault(); - previewReorder(activeState.originalValue.length - 1); - } - }; - - const handlePointerMove = (event: React.PointerEvent) => { - const activeState = reorderStateRef.current; - if ( - activeState == null || - activeState.mode !== 'pointer' || - activeState.pointerId !== event.pointerId - ) { - return; - } - const horizontalDistance = - event.clientX - (activeState.pointerStartX ?? event.clientX); - const verticalDistance = - event.clientY - (activeState.pointerStartY ?? event.clientY); - const hasCrossedThreshold = - activeState.hasPointerMoved || - Math.hypot(horizontalDistance, verticalDistance) >= 5; - const remainingItems = activeState.originalValue.filter( - (_, index) => index !== activeState.fromIndex, - ); - let targetIndex = activeState.fromIndex; - if (hasCrossedThreshold) { - event.preventDefault(); - targetIndex = remainingItems.length; - for (let index = 0; index < remainingItems.length; index++) { - const row = rowRefs.current.get( - String(getItemKey(remainingItems[index])), - ); - if (row == null) { - continue; - } - const bounds = row.getBoundingClientRect(); - if (event.clientY < bounds.top + bounds.height / 2) { - targetIndex = index; - break; - } - } - } - const nextState = { - ...activeState, - pointerClientX: event.clientX, - pointerClientY: event.clientY, - hasPointerMoved: hasCrossedThreshold, - toIndex: targetIndex, - }; - if ( - activeState.pointerClientX === nextState.pointerClientX && - activeState.pointerClientY === nextState.pointerClientY && - activeState.hasPointerMoved === nextState.hasPointerMoved && - activeState.toIndex === nextState.toIndex - ) { - return; - } - setReorderState(nextState); - if (hasCrossedThreshold && activeState.toIndex !== targetIndex) { - announce( - t('@astryx.listInput.announceMovedToPosition', { - itemName, - position: targetIndex + 1, - total: activeState.originalValue.length, - }), - ); - } - }; - - const rowLayoutStyle = showRemoveColumn - ? styles.rowWithRemove - : styles.rowFieldsOnly; + const rowLayoutStyle = showReorderHandle + ? styles.rowWithRemoveAndHandle + : showRemoveColumn + ? styles.rowWithRemove + : styles.rowFieldsOnly; const contentGridStyle = showRemoveColumn ? styles.contentBeforeOneControl : styles.contentOnly; - const dragPreviewPosition = - reorderState?.mode === 'pointer' && - reorderState.pointerClientX != null && - reorderState.pointerClientY != null && - reorderState.pointerOffsetX != null && - reorderState.pointerOffsetY != null && - reorderState.previewWidth != null - ? { - x: reorderState.pointerClientX - reorderState.pointerOffsetX, - y: reorderState.pointerClientY - reorderState.pointerOffsetY, - width: reorderState.previewWidth, - } - : null; - return ( ({ : isDisabled ? 'disabled' : undefined, - reorderable: isReorderable ? 'true' : undefined, + reorderable: showReorderHandle ? 'true' : undefined, }), stylex.props(styles.group), )}> -
    - {displayValue.length === 0 ? ( -
  1. - -
  2. - ) : ( - displayValue.map((item, index) => { - const itemKey = getItemKey(item); - const itemKeyString = String(itemKey); - const isActiveReorder = - reorderState != null && - String(reorderState.itemKey) === itemKeyString; - const dropPosition = - pointerPlacement?.itemKey === itemKeyString - ? pointerPlacement.position - : null; - const isPointerReorderSource = - isActiveReorder && reorderState?.mode === 'pointer'; - const itemStatus = getItemStatus?.(item, index); - const itemStatusID = itemStatus?.message - ? `${groupID}-item-${index}-status` - : undefined; - const reorderHandle = showReorderHandle ? ( -
    -
    +
      + {value.length === 0 ? ( +
    1. + +
    2. + ) : ( + value.map((item, index) => { + const itemKey = getItemKey(item); + const itemKeyString = String(itemKey); + // A record the owner's isReorderable rejects keeps its + // place, with no handle. + const hasHandle = + showReorderHandle && (canReorder?.(itemKey) ?? true); + const itemStatus = getItemStatus?.(item, index); + const itemStatusID = itemStatus?.message + ? `${groupID}-item-${index}-status` + : undefined; + return ( +
    3. - -
    -
    - ) : null; - return ( -
  3. -
    { - if (node == null) { - rowRefs.current.delete(itemKeyString); - } else { - rowRefs.current.set(itemKeyString, node); - } - }} - data-list-input-row={itemKeyString} - {...mergeProps( - getContainerProps({ - // A pointer drag owns the list: rows the pointer - // crosses stay quiet instead of lighting their grips. - forceState: - isPointerReorderActive && !isActiveReorder - ? 'inactive' - : undefined, - }), - stylex.props( - styles.row, - rowLayoutStyle, - dynamicStyles.rowTransition( - transitionName(groupID, itemKey), - ), - isPointerReorderSource && reorderStyles.source, - ), - )}> -
    - {columns.map((column, columnIndex) => { - const fieldStatus = getFieldStatus?.( - item, - column.key, - index, - ); - const isRepeatedLabel = index !== 0; - const cellLabel = isRepeatedLabel - ? t('@astryx.listInput.fieldLabelWithPosition', { - header: column.header, - itemName, - position: index + 1, - total: displayValue.length, - }) - : column.header; - const valueContext: ListInputValueContext = { - item, - index, - label: cellLabel, - isLabelHidden: true, - }; - const content = column.renderInput({ - ...valueContext, - status: fieldStatus, - statusVariant: 'tooltip', - isDisabled: isDisabled || isLoading, - isLoading, - updateItem: (nextItem, changeColumnKey) => - handleUpdate( - item, - index, - nextItem, - changeColumnKey ?? column.key, - ), - }); - return ( -
    - +
    + {columns.map((column, columnIndex) => { + const fieldStatus = getFieldStatus?.( + item, + column.key, + index, + ); + const isRepeatedLabel = index !== 0; + const cellLabel = isRepeatedLabel + ? t('@astryx.listInput.fieldLabelWithPosition', { + header: column.header, + itemName, + position: index + 1, + total: value.length, + }) + : column.header; + const valueContext: ListInputValueContext = { + item, + index, + label: cellLabel, + isLabelHidden: true, + }; + const content = column.renderInput({ + ...valueContext, + status: fieldStatus, + statusVariant: 'tooltip', + isDisabled: isDisabled || isLoading, + isLoading, + updateItem: (nextItem, changeColumnKey) => + handleUpdate( + item, + index, + nextItem, + changeColumnKey ?? column.key, + ), + }); + return (
    - {columnIndex === 0 ? reorderHandle : null} - {content} + +
    + {content} +
    -
    - ); - })} -
    - {showRemoveColumn ? ( -
    - } - variant="ghost" - size="md" - isDisabled={isDisabled || isLoading} - data-list-input-remove="" - onClick={() => handleRemove(item)} - /> + ); + })}
    - ) : null} - {itemStatus?.message ? ( - - ) : null} -
    -
  4. - ); - }) - )} -
+ {showRemoveColumn ? ( +
+ } + variant="ghost" + size="md" + isDisabled={isDisabled || isLoading} + data-list-input-remove="" + onClick={() => handleRemove(item)} + /> + {Handle != null && hasHandle ? ( + // Named "Reorder guest 2", like Remove. + + ) : null} +
+ ) : null} + {itemStatus?.message ? ( + + ) : null} + + + ); + }) + )} + +
({ />
- {showReorderHandle ? ( - - {t('@astryx.listInput.reorderInstructions')} - - ) : null}
- {dragPreviewPosition != null - ? renderDragPreviewLayer( -