Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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-animated-layout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[feat] `List` takes `animatedLayout`, the handle from `useAnimatedLayout({containerRef})`, and `ListItem` takes `itemKey`, the row's identity. With the handle, when rows are inserted, removed or reordered, an inserted row enters, a removed row (a swiped one with its action showing) collapses in place, and a row that crosses rows moving the other way rides above them with the surface color behind it, as an iOS table's opaque cell does. The rows after a change and the content after the list, up to the handle's container, glide to their new places. All of it runs on one shared motion and starts together, under every pointer; under Reduce Motion nothing travels. A row without an `itemKey` changes at once, and without the handle the whole list does.

@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
331 changes: 330 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 @@ -19,7 +20,9 @@ import {Icon} from '@astryxdesign/core/Icon';
import {Switch} from '@astryxdesign/core/Switch';
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 @@ -762,3 +765,329 @@ export const HeaderInFlexParent: Story = {
},
},
};

function AnimatedChangesDemo() {
const [rows, setRows] = useState([
'Inbox',
'Sent',
'Drafts',
'Archive',
'Spam',
]);
const [next, setNext] = useState(1);
const containerRef = useRef<HTMLDivElement>(null);
const animatedLayout = useAnimatedLayout({containerRef});
return (
<div ref={containerRef}>
<VStack gap={4}>
<List animatedLayout={animatedLayout} hasDividers>
{rows.map(label => (
<ListItem
key={label}
itemKey={label}
label={label}
onClick={() => {}}
/>
))}
</List>
<Text data-testid="after-list">{`${rows.length} folders`}</Text>
<Button
label="Insert a row second"
onClick={() => {
setRows(current => [
current[0],
`Folder ${next}`,
...current.slice(1),
]);
setNext(n => n + 1);
}}
/>
<Button
label="Remove the second row"
onClick={() =>
setRows(current => current.filter((_, index) => index !== 1))
}
/>
<Button
label="Move the last row first"
onClick={() =>
setRows(current => [
current[current.length - 1],
...current.slice(0, -1),
])
}
/>
</VStack>
</div>
);
}

export const AnimatedChanges: Story = {
render: () => <AnimatedChangesDemo />,
parameters: {
docs: {
description: {
story:
'With `animatedLayout` (the handle from `useAnimatedLayout`) and ' +
'an `itemKey` on each row, an inserted row enters, a removed row ' +
'collapses in place, a row crossing rows that move the other way ' +
'rides above them with the surface color behind it, and the rows ' +
'after a change and the buttons below the list glide to their new ' +
'places, all on one shared motion, under every pointer.',
},
},
},
};

/** The swipe story's row with no swipe actions. */
const PLAIN_ROW = 'Notes';

function AnimatedChangesSwipeDemo() {
const [rows, setRows] = useState([
'Inbox',
'Sent',
'Drafts',
'Archive',
PLAIN_ROW,
]);
const containerRef = useRef<HTMLDivElement>(null);
const animatedLayout = useAnimatedLayout({containerRef});
const moveFirst = (label: string) =>
setRows(current =>
current.includes(label)
? [label, ...current.filter(row => row !== label)]
: current,
);
return (
<div ref={containerRef}>
<List animatedLayout={animatedLayout} hasDividers>
{rows.map(label => (
<ListItem
key={label}
itemKey={label}
label={label}
onClick={() => {}}
swipeActions={
label === PLAIN_ROW
? undefined
: {
trailing: [
{
label: 'Delete',
variant: 'destructive',
hasRemoval: true,
onActivate: () =>
setRows(current =>
current.filter(row => row !== label),
),
},
],
}
}
/>
))}
</List>
<Button
label="Move Archive first"
variant="secondary"
onClick={() => moveFirst('Archive')}
/>
<Button
label="Move Notes first"
variant="secondary"
onClick={() => moveFirst(PLAIN_ROW)}
/>
</div>
);
}

export const AnimatedChangesSwipe: Story = {
render: () => <AnimatedChangesSwipeDemo />,
parameters: {
docs: {
description: {
story:
'With `animatedLayout`, a row swiped away with a removal action ' +
'collapses in place, its action showing, while the rows below ' +
'glide up into its gap on the shared motion. A row moved up ' +
'across the swipeable rows, with swipe actions (Archive) or ' +
'without (Notes), rides above the rows it crosses.',
},
},
},
};

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.',
},
},
},
};
Loading