Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
cad762b
feat(core): createReorderable makes List rows reorderable
vjeux Oct 26, 2026
d0c1590
feat(List): reorder mode's hint, its moves and Done as buttons
vjeux Oct 26, 2026
6ff3d0b
fix(List): a move that renames a row keeps reorder mode
vjeux Nov 5, 2026
66fc409
fix(cli): a create* factory with its own hook lists beside that hook
vjeux Nov 6, 2026
32d137f
docs(List): split comment sentences over 25 words
vjeux Nov 6, 2026
13c6534
fix(List): a finger held on a reorder handle selects no text
vjeux Nov 6, 2026
ca8a76b
fix(List): a drag crossing rows reads no layout
vjeux Nov 6, 2026
8de33bf
fix(List): the reorder imports the touch hit area from internal
vjeux Nov 7, 2026
e5f5e02
fix(List): the reorder finds its scroller through core's shared walk
vjeux Nov 7, 2026
77fb567
fix(List): a dragged row's preview and line keep its inline start in RTL
vjeux Nov 7, 2026
1baf07c
fix(List): picking a row up lays the page out once
vjeux Nov 7, 2026
28b44c3
spec(AST-103): a held drag near a scroll edge scrolls toward it
vjeux Nov 7, 2026
0abe4b2
fix(List): a dragged row's preview joins no form or radio group
vjeux Nov 7, 2026
4b907b5
fix(List): a scroll during a drag keeps the drop under the pointer
vjeux Nov 7, 2026
c89aa9c
spec(react-component-runtime): INV18 allows the reorder's scroll read
vjeux Nov 7, 2026
dd99dfa
feat(lab): TransferList reorders through core's createReorderable
vjeux Oct 26, 2026
59c9ffb
fix(lab): a disabled TransferList keeps reordering off
vjeux Nov 6, 2026
2608bef
docs(lab): split comment sentences over 25 words
vjeux Nov 6, 2026
6beee21
chore(lint): the provenance baseline drops List's two removed citations
vjeux Nov 8, 2026
f5f1796
fix(lab): a disabled TransferList keeps focusable disabled handles
vjeux Nov 6, 2026
51c7f0c
fix(TransferList): its JSDoc is on the component again
vjeux Nov 7, 2026
f2e6a1c
docs(TransferList): it reorders only with reorderable
vjeux Nov 7, 2026
1ee5ffc
docs(TransferList): the anatomy names the reorder hint
vjeux Nov 7, 2026
b44d561
spec(react-component-runtime): INV13 lists List's reorder session
vjeux Nov 7, 2026
9a29d9e
feat(lab): ListInput reorders through core's createReorderable
vjeux Oct 24, 2026
4ad7c1c
fix(ListInput): the Add button and the handles stay in the columns
vjeux Nov 7, 2026
c03aeed
fix(TransferList): the phone layout's panels stop clipping
vjeux Nov 7, 2026
c7702fb
fix(ListInput): a record that stays put keeps Remove in its column
vjeux Nov 7, 2026
05c5150
Merge branch 'main' into uikit/c13-list-reorder
cixzhang Nov 8, 2026
d9379cc
Merge branch 'uikit/c13-list-reorder' into uikit/c13-transferlist-reo…
cixzhang Nov 8, 2026
3a1c5e5
Merge branch 'uikit/c13-transferlist-reorder' into uikit/c13-listinpu…
cixzhang Nov 8, 2026
9a497c6
fix(List): a drop or key move reads its rows once, before it writes
vjeux Nov 8, 2026
3384ec6
docs(List): a key move's first glides cost a style pass per row
vjeux Nov 8, 2026
39059ef
Merge branch 'uikit/c13-list-reorder' into uikit/c13-transferlist-reo…
cixzhang Nov 8, 2026
7a333bd
Merge main into createReorderable's List reorder
vjeux Nov 8, 2026
4678530
docs(motion): the reorder measurements name no batch label
vjeux Nov 8, 2026
d858d64
Merge branch 'uikit/c13-transferlist-reorder' into uikit/c13-listinpu…
cixzhang Nov 8, 2026
c66c799
Merge branch 'uikit/c13-list-reorder' into uikit/c13-transferlist-reo…
cixzhang Nov 8, 2026
d3b8e98
Merge branch 'uikit/c13-transferlist-reorder' into uikit/c13-listinpu…
cixzhang Nov 8, 2026
ed28397
Merge branch 'main' into uikit/c13-list-reorder
cixzhang Nov 8, 2026
ff96b6f
Merge main into createReorderable's List reorder
vjeux Nov 8, 2026
92365e4
refactor(List): the reorder warns once through createDevWarnings
vjeux Nov 8, 2026
ba4c743
Merge #8139's final head
vjeux Nov 8, 2026
f9029bb
Merge main, where #8139 squash-merged
vjeux Nov 8, 2026
52f7a74
Merge #8143's head, which merges main after #8139
vjeux Nov 8, 2026
1e21069
chore(changeset): Core's internal reorder entry serves TransferList
vjeux Nov 8, 2026
25b6734
Merge #8143's head, which adds its Changeset
vjeux Nov 8, 2026
d50b386
chore(changeset): Core's internal reorder entry serves ListInput
vjeux Nov 8, 2026
9bb411b
Merge main, where #8143 squash-merged
vjeux Nov 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/core-internal-reorder-lab-listinput.md
Original file line number Diff line number Diff line change
@@ -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
45 changes: 40 additions & 5 deletions apps/storybook/stories/ListInput.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<T extends {id: string}>(
setRecords: Dispatch<SetStateAction<T[]>>,
) {
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),
Expand Down Expand Up @@ -235,7 +267,7 @@ function TagOptionsExample({isReorderable}: {isReorderable?: boolean}) {
? {type: 'error', message: 'Enter a label.'}
: undefined
}
isReorderable={isReorderable || undefined}
reorderable={isReorderable ? reorderable : undefined}
/>
);
}
Expand Down Expand Up @@ -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<ListInputColumn<TripLeg>[]>(
() => [
Expand Down Expand Up @@ -906,7 +939,7 @@ function ResponsiveItineraryExample() {
}
return undefined;
}}
isReorderable
reorderable={reorderable}
isRequired
/>
</div>
Expand Down Expand Up @@ -940,6 +973,7 @@ function StaticTagsExample({
status?: React.ComponentProps<typeof ListInput<TagOption>>['status'];
}) {
const [tags, setTags] = useState(INITIAL_TAGS.slice(0, 3));
const reorderable = useRecordReorder(setTags);
const columns = useMemo(() => createTagColumns(() => {}), []);

return (
Expand All @@ -955,7 +989,7 @@ function StaticTagsExample({
isDisabled={isDisabled}
isLoading={isLoading}
status={status}
isReorderable
reorderable={reorderable}
/>
);
}
Expand Down Expand Up @@ -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 (
Expand All @@ -1037,7 +1072,7 @@ function StressExample() {
createItem={() => ({id: crypto.randomUUID(), color: 'blue', label: ''})}
columns={columns}
itemName="tag"
isReorderable
reorderable={reorderable}
/>
);
}
Expand Down
84 changes: 84 additions & 0 deletions apps/storybook/stories/ListInputA11y.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<Guest>[] = [
{
key: 'name',
header: 'Name',
renderInput: ({item, label, isLabelHidden, isDisabled, updateItem}) => (
<TextInput
label={label}
isLabelHidden={isLabelHidden}
value={item.name}
onChange={name => updateItem({...item, name}, 'name')}
isDisabled={isDisabled}
/>
),
},
];

export const FixedRecord: StoryObj = {
render: function FixedRecordStory() {
const [guests, setGuests] = useState<Guest[]>([
{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 (
<ListInput<Guest>
label="Guests"
value={guests}
onChange={setGuests}
getItemKey={guest => guest.id}
createItem={() => ({id: crypto.randomUUID(), name: ''})}
columns={COLUMNS}
itemName="guest"
reorderable={reorderable}
/>
);
},
};
11 changes: 1 addition & 10 deletions docs/design/ordered-collection-reordering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/**"]
}
```

Expand Down Expand Up @@ -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).
Comment thread
astracat-bot[bot] marked this conversation as resolved.
Resolved
Comment thread
astracat-bot[bot] marked this conversation as resolved.
Resolved

## Decision log

### DEC-1 — Reordering follows this design, not a slide-aside
Expand Down
4 changes: 4 additions & 0 deletions packages/core/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Comment thread
astracat-bot[bot] marked this conversation as resolved.
Resolved
"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\")."
Expand Down
35 changes: 14 additions & 21 deletions packages/core/src/List/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -346,16 +346,12 @@ export function List({
}: ListProps) {
const headerId = useId();
const listRef = useRef<HTMLUListElement & HTMLOListElement>(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
Expand All @@ -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<HTMLUListElement & HTMLOListElement>,
Expand Down
86 changes: 86 additions & 0 deletions packages/core/src/List/ListReorderScope.tsx
Original file line number Diff line number Diff line change
@@ -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<ListContextValue | null>(
() =>
reorder == null
? null
: {
density: 'balanced',
hasDividers: false,
edge: 'padded',
listStyle: 'none',
reorder,
},
[reorder],
);
if (reorder == null || value == null) {
return children;
}
return (
<ListContext value={value}>
{children}
<reorder.session.Host reorder={reorder} />
</ListContext>
);
}
Loading
Loading