Skip to content
Merged
Show file tree
Hide file tree
Changes from 35 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/cli-hook-docs-create.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/cli': patch
---

[feat] `astryx` lists a `create…` factory documented as a hook, such as `createReorderable`, with the hooks.

@vjeux
7 changes: 7 additions & 0 deletions .changeset/list-reorder-mode-hint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[feat] While a reorderable `List` row is in reorder mode, a hint anchored to its handle, in the keyboard hint's layer and theme roles, shows the moves and Done. Each is a button that does what its key does and leaves the focus on the handle, so a row can be reordered by taps alone: the handle, the moves, then Done.

@vjeux
7 changes: 7 additions & 0 deletions .changeset/list-reorderable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[feat] `createReorderable({onReorder, isReorderable})` makes a collection's reorder behavior, and `List` takes it as `reorderable`: each `ListItem` with an `itemKey` gets a reorder handle at its end, and each committed move calls `onReorder({key, to})` once, with the row's key and its index after the move. On a touch screen a finger held still on a handle picks up its row the way an iOS table's reorder control does (a flick on it still scrolls): the row stays in its place, dimmed, while a copy of it follows the finger and an accent line marks where it would land; the other rows stay still, the page scrolls near the edges, and on release the rows rearrange, gliding to their new places. A pen held on a handle picks up its row the same way where the primary pointer is coarse. A mouse, or a pen where the primary pointer is fine, drags a handle at once with the same copy and line, and rows take their places without motion; a cancelled drag puts everything back at once. Without a drag, a click, Space or Enter on a handle turns on reorder mode for its row: the insertion line marks the row's place, each arrow key moves it a place at once, announced with its place and neighbours, and Escape or another activation ends the mode, keeping the moves. The object's `start(key)` enters the same mode from the app's own UI, such as a row's context menu. A List without `reorderable` loads none of the reorder code.

@vjeux
179 changes: 178 additions & 1 deletion apps/storybook/stories/List.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@
* @position Storybook demonstrations for List consumers
*/

import {useEffect, useRef, useState} from 'react';
import {useEffect, useMemo, useRef, useState} from 'react';
import * as stylex from '@stylexjs/stylex';
import type {Meta, StoryObj} from '@storybook/react';
import {List, ListItem} from '@astryxdesign/core/List';
import {createReorderable} from '@astryxdesign/core';
import {Avatar} from '@astryxdesign/core/Avatar';
import {Badge} from '@astryxdesign/core/Badge';
import {Button} from '@astryxdesign/core/Button';
Expand All @@ -21,6 +22,7 @@ import {Heading, Text} from '@astryxdesign/core/Text';
import {VStack} from '@astryxdesign/core/Layout';
import {useAnimatedLayout} from '@astryxdesign/core/hooks';
import {rtlStyles} from '@astryxdesign/core/utils';
import {ContextMenu} from '@astryxdesign/core/ContextMenu';
import {
Cog6ToothIcon,
BellIcon,
Expand Down Expand Up @@ -914,3 +916,178 @@ export const AnimatedChangesSwipe: Story = {
},
},
};

function moveKey(
rows: ReadonlyArray<string>,
key: unknown,
to: number,
): string[] {
const next = rows.filter(row => row !== key);
next.splice(to, 0, key as string);
return next;
}

function ReorderDemo({
initial,
pinned = [],
edge,
}: {
initial: ReadonlyArray<string>;
pinned?: ReadonlyArray<string>;
edge?: 'padded' | 'flush';
}) {
const [rows, setRows] = useState(initial);
return (
<List
hasDividers
edge={edge}
reorderable={createReorderable({
onReorder: ({key, to}) => setRows(current => moveKey(current, key, to)),
isReorderable: key => !pinned.includes(key as string),
})}>
{rows.map(label => (
<ListItem key={label} itemKey={label} label={label} />
))}
</List>
);
}

export const Reorderable: Story = {
render: () => (
<ReorderDemo
initial={['Inbox', 'Sent', 'Drafts', 'Archive', 'Spam', 'Trash']}
/>
),
parameters: {
docs: {
description: {
story:
'With `reorderable` (from `createReorderable`), each row with an ' +
'`itemKey` gets a reorder handle. On a touch screen, hold a handle ' +
'for a moment, then drag: the row dims in place, a copy follows ' +
'your finger and a line marks where it will land, and the rows ' +
'rearrange on release. A mouse drags a handle at once, the same ' +
'way. Or activate a handle (click it, or press Space ' +
'or Enter) to turn on reorder mode for its row: each arrow key, or ' +
'each move in the hint below the handle, moves it a place, and ' +
'Escape or Done ends the mode.',
},
},
},
};

export const ReorderFlush: Story = {
render: () => (
<ReorderDemo
initial={['Inbox', 'Sent', 'Drafts', 'Archive']}
edge="flush"
/>
),
parameters: {
docs: {
description: {
story:
'With `edge="flush"` the rows have no side padding, so each ' +
"reorder handle ends at the row's edge, inside the list.",
},
},
},
};

export const ReorderWithPinnedRows: Story = {
render: () => (
<ReorderDemo
initial={['Favorites', 'Inbox', 'Sent', 'All mail', 'Drafts', 'Spam']}
pinned={['Favorites', 'All mail']}
/>
),
parameters: {
docs: {
description: {
story:
'A row the object’s `isReorderable` rejects has no handle. At an ' +
'end of the list it also bounds the others (nothing drops above ' +
'Favorites); in the middle, other rows move past it.',
},
},
},
};

function ReorderFromYourUIDemo() {
const [rows, setRows] = useState([
'Inbox',
'Sent',
'Drafts',
'Archive',
'Spam',
'Trash',
]);
const [selected, setSelected] = useState('Sent');
const reorderable = useMemo(
() =>
createReorderable({
onReorder: ({key, to}) => setRows(current => moveKey(current, key, to)),
}),
[],
);
return (
<VStack gap={2}>
<Button
label={`Move ${selected}`}
onClick={() => reorderable.start(selected)}
/>
<ContextMenu
label="Row actions"
items={[
{
label: `Move ${selected}`,
onClick: () => reorderable.start(selected),
},
]}>
<List hasDividers reorderable={reorderable}>
{rows.map(label => (
<ListItem
key={label}
itemKey={label}
label={label}
isSelected={label === selected}
onClick={() => setSelected(label)}
/>
))}
</List>
</ContextMenu>
</VStack>
);
}

export const ReorderFromYourUI: Story = {
render: () => <ReorderFromYourUIDemo />,
parameters: {
docs: {
description: {
story:
'The object’s `start(key)` turns on reorder mode for a row from ' +
'your own UI, here a button and the list’s context menu: the ' +
'focus moves to the row’s handle, and its arrow keys, hint and ' +
'Done work as they do from the handle.',
},
},
},
};

export const ReorderLongList: Story = {
render: () => (
<ReorderDemo
initial={Array.from({length: 30}, (_, index) => `Item ${index + 1}`)}
/>
),
parameters: {
docs: {
description: {
story:
'Dragging a row near the top or bottom of the screen scrolls the ' +
'page, faster the further past the edge the row goes.',
},
},
},
};
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
Loading