From a6cc7e0524480336d3a1d99ff69cc5d79818a19a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 00:06:39 +0000 Subject: [PATCH] =?UTF-8?q?feat(sortable-list):=20=E3=83=89=E3=83=A9?= =?UTF-8?q?=E3=83=83=E3=82=B0=E3=81=A8=E3=82=AD=E3=83=BC=E3=83=9C=E3=83=BC?= =?UTF-8?q?=E3=83=89=E3=81=A7=E4=B8=A6=E3=81=B9=E6=9B=BF=E3=81=88=E3=82=8B?= =?UTF-8?q?=20SortableList=20=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 左のつまみをドラッグすると行が指に付いて動き、離した位置へ一度で動く。矢印キーでも動かせ、フォーカスはつまみに残る - 右の開閉ボタンから「上へ」「下へ」「外す」を出す(WCAG 2.5.7) - 並びが変わると FLIP で動かし、動かした位置を読み上げる。位置は CSSOM で動かす(CSP で止まらない) - DESIGN.md・04 の部品の選び方・README に追加 bouzu(tkc310/bouzu#77・#89)の読誦セットの並べ替えを部品にした。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S5dPLGGVzZyRz8dob3cTmz --- .changeset/sortable-list.md | 9 + DESIGN.md | 14 + README.md | 4 +- docs/improvement/log.md | 1 + docs/rulebook/04-components.md | 1 + packages/ui/registry.json | 23 ++ .../ui/SortableList/index.stories.tsx | 84 ++++++ .../components/ui/SortableList/index.test.tsx | 120 ++++++++ .../src/components/ui/SortableList/index.tsx | 265 ++++++++++++++++++ .../ui/SortableList/useSortable.test.ts | 36 +++ .../components/ui/SortableList/useSortable.ts | 167 +++++++++++ packages/ui/src/index.ts | 1 + 12 files changed, 723 insertions(+), 2 deletions(-) create mode 100644 .changeset/sortable-list.md create mode 100644 packages/ui/src/components/ui/SortableList/index.stories.tsx create mode 100644 packages/ui/src/components/ui/SortableList/index.test.tsx create mode 100644 packages/ui/src/components/ui/SortableList/index.tsx create mode 100644 packages/ui/src/components/ui/SortableList/useSortable.test.ts create mode 100644 packages/ui/src/components/ui/SortableList/useSortable.ts diff --git a/.changeset/sortable-list.md b/.changeset/sortable-list.md new file mode 100644 index 00000000..1d9a152f --- /dev/null +++ b/.changeset/sortable-list.md @@ -0,0 +1,9 @@ +--- +'8341-ui': patch +--- + +`SortableList` を追加しました(`npx shadcn add @8341/sortable-list`)。利用者が項目の順番を決める一覧です。 + +- 行の左のつまみをドラッグすると行が指に付いて動き、離した位置へ一度で動く。つまみにフォーカスして上下の矢印キーでも動かせる +- 右の開閉ボタンから「上へ」「下へ」(`onRemove` を渡すと「外す」も)を出す。ドラッグできない人の代わりの操作(WCAG 2.5.7) +- 並びが変わると前の位置から新しい位置へ動き、動かした位置を読み上げる diff --git a/DESIGN.md b/DESIGN.md index 9eaa508a..b0021729 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -170,6 +170,7 @@ DTCG では `{"colorSpace": "oklch", "components": [L, C, H], "hex": "#..."}` - `RadioGroup`(`@8341/radio-group`)— `packages/ui/src/components/ui/RadioGroup/` - `Select`(`@8341/select`)— `packages/ui/src/components/ui/Select/` - `Skeleton`(`@8341/skeleton`)— `packages/ui/src/components/ui/Skeleton/` +- `SortableList`(`@8341/sortable-list`)— `packages/ui/src/components/ui/SortableList/` - `Spinner`(`@8341/spinner`)— `packages/ui/src/components/ui/Spinner/` - `Stack`(`@8341/stack`)— `packages/ui/src/components/ui/Stack/` - `Switch`(`@8341/switch`)— `packages/ui/src/components/ui/Switch/` @@ -276,6 +277,19 @@ DTCG では `{"colorSpace": "oklch", "components": [L, C, H], "hex": "#..."}` - 1 行に収まらない文字を … で省略する(`children` は文字列)。省略されたときだけ Tab で止まり、ホバーかフォーカスで全文を Base UI の Tooltip(`bg-inverse`)で出す - 読み上げは常に全文。本文には使わず、表の列や一覧の項目名など幅が限られる場所に使う +### SortableList + +```tsx + s.id} getLabel={(s) => s.name} + onChange={setSteps} onRemove={(s) => …} /> +``` + +- 利用者が項目の順番を決める一覧(`
    `)。`label` 必須(一覧の名前)、`getLabel` は項目の名前(つまみ・操作のボタンの名前になる) +- 行の左のつまみをドラッグすると、行は指に付いて動き、ほかの行は入る位置を空ける。離した位置へ一度で動く。つまみにフォーカスして上下の矢印キーでも動かせ、フォーカスはつまみに残る +- 右の開閉ボタン(`aria-expanded`)で行の下に「上へ」「下へ」(`onRemove` を渡すと「外す」も)を出す。ドラッグできない人の代わりの操作(WCAG 2.5.7)。ボタンを行に並べないので、狭い幅でも項目名が折り返さない +- 並びが変わると前の位置から新しい位置へ動き(FLIP。動きを減らす設定では止まる)、動かした位置を `role="status"` で読み上げる。位置は style 属性ではなく CSSOM で動かす(CSP で止まらない) +- 文言は `messages` で上書きできる + ### Card - `border-border`、`rounded-xl`、内側余白 `p-inset-xl` diff --git a/README.md b/README.md index 11b519d6..255b8eb4 100644 --- a/README.md +++ b/README.md @@ -130,13 +130,13 @@ Tailwind を使わない場合は、ビルド済みの `import '8341-ui/styles.c ## コンポーネント -ヘッドレス UI の [Base UI](https://base-ui.com/) に、トークンの見た目を載せた 23 種類です。使い分けの規約は [04 コンポーネント](./docs/rulebook/04-components.md)、動く見本は [Storybook](https://tkc310.github.io/8341-ui/) にあります。 +ヘッドレス UI の [Base UI](https://base-ui.com/) に、トークンの見た目を載せた 24 種類です。使い分けの規約は [04 コンポーネント](./docs/rulebook/04-components.md)、動く見本は [Storybook](https://tkc310.github.io/8341-ui/) にあります。 | 種類 | コンポーネント | | --- | --- | | 操作 | `Button` · `Link` | | フォーム | `FormField` · `TextField` · `Textarea` · `Select` · `Checkbox` · `RadioGroup` · `Switch` | -| 表示 | `Heading` · `Text` · `Truncate` · `Tag` · `Icon` · `Card` · `Divider` · `Stack` | +| 表示 | `Heading` · `Text` · `Truncate` · `Tag` · `Icon` · `Card` · `Divider` · `Stack` · `SortableList` | | ナビゲーション | `Breadcrumb` · `Accordion` | | フィードバック | `NotificationBanner` · `Spinner` · `Skeleton` | | オーバーレイ | `Dialog` | diff --git a/docs/improvement/log.md b/docs/improvement/log.md index 4e8c9fd6..0fe7dbb0 100644 --- a/docs/improvement/log.md +++ b/docs/improvement/log.md @@ -4,6 +4,7 @@ | 日付 | 種別 | 内容 | 再発防止 | | ---------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| 2026-11-07 | 部品 | SortableList を追加。つまみのドラッグ(行が指に付いて動き、離した位置へ一度で動く)と矢印キーで並べ替え、右の開閉ボタンから「上へ」「下へ」「外す」を出す。並びが変わると FLIP で動かし、動かした位置を読み上げる。bouzu(#77・#89)の読誦セットの並べ替えを部品にした | 単体テスト(矢印キーとフォーカスの維持・開閉の操作・外したあとのフォーカス・ドラッグ・文言の上書き)と、位置の計算の純粋な関数のテスト。ストーリーの play 関数 | | 2026-11-02 | 保守 | Node.js を 26.13.0(LTS Lithium)に更新。`devEngines` を `>=26.13.0`、`@types/node` を 26 系(`^26.6.6`)に合わせた。他の依存の版は変えていない | ADR-0020。CI は `.tool-versions` の版を使う | | 2026-10-23 | 保守 | 全体のリファクタリング。古い参照(存在しない make ターゲット・パス・README の節名)を直し、トークンを変えた後の手順を `make generate` に統一。Makefile と重複していた pnpm スクリプト、design-review に混ざっていたコードレビュー・Next.js の項目、使われていない関数と export を削除。DESIGN.md・ルールブック 04・06・CONTRIBUTING・factory.md の重複を正本へのリンクにし、トークンの CSS 化の規則を `packages/tokens/scripts/lib/css.ts` に集めて build.ts とスライドで共有 | 規則を 1 か所にしたので、tokens.css とスライドの値の書き方がずれない。重複していた手順は正本(Makefile・COMPONENT_STRUCTURE.md・06 章・ds-factory)だけに置いた | | 2026-10-23 | 機能 | 8341-ui を使うプロダクトに、DESIGN.md と design-principles を配るレジストリ item `@8341/design-md` を追加。DESIGN.md はコードから読み取れない考え方(プロダクトの前提・画面領域ごとの方向性・大事にすること・外した必須)と原則の必須の要約だけにし、トークンや部品の一覧は載せない | ADR-0019。原則の要約は `scripts/sync-product-design.ts` が docs/design-principles から生成し、`make check` で鮮度を検査。配布する原則のファイル一覧も registry.json の --check で追従 | diff --git a/docs/rulebook/04-components.md b/docs/rulebook/04-components.md index f3d5d427..efd76c2d 100644 --- a/docs/rulebook/04-components.md +++ b/docs/rulebook/04-components.md @@ -73,5 +73,6 @@ | 画面に重ねて入力・確認させる | Dialog(`title` 必須) | 独自のモーダル | | 読み込み・保存の最中 | Spinner(ボタンの中は Button の `loading`) | 文言の無い回転アイコン | | 中身の形が分かる読み込み | Skeleton + 領域の `aria-busy` | 空白のまま待たせる | +| 利用者が項目の順番を決める | SortableList(ドラッグ・矢印キー・開閉式の「上へ」「下へ」) | ドラッグだけの並べ替え、順番の数字の入力欄 | | 幅が限られた 1 行の文字 | Truncate(表の列・一覧の項目名) | 本文の省略 | | フォームの送信と検証 | Base UI の Form + FormField | 独自の検証の仕組み | diff --git a/packages/ui/registry.json b/packages/ui/registry.json index b38134cd..26785748 100644 --- a/packages/ui/registry.json +++ b/packages/ui/registry.json @@ -428,6 +428,29 @@ } ] }, + { + "name": "sortable-list", + "type": "registry:ui", + "title": "SortableList", + "description": "ドラッグとキーボードで並べ替えられる順番つきリスト。", + "dependencies": [], + "registryDependencies": [ + "@8341/button", + "@8341/icon", + "@8341/theme", + "@8341/tv" + ], + "files": [ + { + "path": "src/components/ui/SortableList/index.tsx", + "type": "registry:ui" + }, + { + "path": "src/components/ui/SortableList/useSortable.ts", + "type": "registry:ui" + } + ] + }, { "name": "spinner", "type": "registry:ui", diff --git a/packages/ui/src/components/ui/SortableList/index.stories.tsx b/packages/ui/src/components/ui/SortableList/index.stories.tsx new file mode 100644 index 00000000..a9f8133f --- /dev/null +++ b/packages/ui/src/components/ui/SortableList/index.stories.tsx @@ -0,0 +1,84 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; +import { expect, userEvent, within } from 'storybook/test'; +import { SortableList } from './index'; + +type Step = { id: string; name: string; owner: string }; +const STEPS: Step[] = [ + { id: 'draft', name: '下書きを作る', owner: '担当者' }, + { id: 'review', name: '内容をレビューする', owner: 'チームリーダー' }, + { id: 'legal', name: '法務の確認を受ける(契約書・利用規約・個人情報の取り扱いを含む)', owner: '法務部' }, + { id: 'approve', name: '承認する', owner: '部長' }, + { id: 'publish', name: '公開する', owner: '担当者' }, +]; + +function Example({ removable = true, detailed = false }: { removable?: boolean; detailed?: boolean }) { + const [items, setItems] = useState(STEPS); + return ( + step.id} + getLabel={(step) => step.name} + onChange={setItems} + onRemove={removable ? () => {} : undefined} + renderItem={ + detailed + ? (step) => ( + + {step.name} + {step.owner} + + ) + : undefined + } + /> + ); +} + +const meta = { + title: 'Components/SortableList', + component: SortableList, + parameters: { + docs: { + description: { + component: + 'ドラッグとキーボードで並べ替えられる順番つきリスト。左のつまみをドラッグする(行は指に付いて動き、ほかの行は入る位置を空ける)か、つまみにフォーカスして上下の矢印キーで動かす。ドラッグできない人のために、右の開閉ボタンから「上へ」「下へ」「外す」も出す(WCAG 2.5.7)。並びが変わると行は前の位置から新しい位置へ動き(動きを減らす設定では止まる)、動かした位置を読み上げる。', + }, + }, + }, + args: { + label: '承認の手順', + items: STEPS, + getKey: (s: Step) => s.id, + getLabel: (s: Step) => s.name, + onChange: () => {}, + }, + render: () => , +} satisfies Meta>; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const handle = canvas.getByRole('button', { name: '下書きを作るを並べ替える' }); + handle.focus(); + await userEvent.keyboard('{ArrowDown}'); + const rows = within(canvas.getByRole('list', { name: '承認の手順' })).getAllByRole('listitem'); + await expect(rows[1]).toHaveTextContent('下書きを作る'); + await expect(canvas.getByRole('button', { name: '下書きを作るを並べ替える' })).toHaveFocus(); + // 開閉ボタンから上へ戻す + await userEvent.click(canvas.getByRole('button', { name: '下書きを作るの操作' })); + await userEvent.click(canvas.getByRole('button', { name: '下書きを作るを上へ' })); + await expect( + within(canvas.getByRole('list', { name: '承認の手順' })).getAllByRole('listitem')[0], + ).toHaveTextContent('下書きを作る'); + }, +}; + +/** renderItem で行の中身を組み立てる。外す操作が要らなければ onRemove を渡さない */ +export const CustomContent: Story = { + render: () => , +}; diff --git a/packages/ui/src/components/ui/SortableList/index.test.tsx b/packages/ui/src/components/ui/SortableList/index.test.tsx new file mode 100644 index 00000000..52cdd35c --- /dev/null +++ b/packages/ui/src/components/ui/SortableList/index.test.tsx @@ -0,0 +1,120 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vite-plus/test'; +import { expectNoA11yViolations } from '@/test/a11y'; +import { SortableList } from './index'; + +type Step = { id: string; name: string }; +const STEPS: Step[] = [ + { id: 'a', name: '下書き' }, + { id: 'b', name: 'レビュー' }, + { id: 'c', name: '承認' }, +]; + +function Harness({ onRemove }: { onRemove?: (step: Step) => void }) { + const [items, setItems] = useState(STEPS); + return ( + step.id} + getLabel={(step) => step.name} + onChange={setItems} + onRemove={onRemove} + /> + ); +} + +const names = () => + within(screen.getByRole('list', { name: '手順' })) + .getAllByRole('listitem') + .map((row) => within(row).getAllByRole('button')[0]?.getAttribute('aria-label')); + +describe('SortableList', () => { + it('順番つきリストに名前が付き、つまみに名前と操作の説明が付く', async () => { + const { container } = render(); + expect(screen.getByRole('list', { name: '手順' }).tagName).toBe('OL'); + const handle = screen.getByRole('button', { name: '下書きを並べ替える' }); + expect(handle).toHaveAccessibleDescription(/上下の矢印キー/); + await expectNoA11yViolations(container); + }); + + it('つまみで上下の矢印キーを押すと動き、フォーカスはつまみに残り、動いた位置を読み上げる', async () => { + const user = userEvent.setup(); + render(); + const handle = screen.getByRole('button', { name: '下書きを並べ替える' }); + handle.focus(); + await user.keyboard('{ArrowDown}'); + expect(names()).toEqual(['レビューを並べ替える', '下書きを並べ替える', '承認を並べ替える']); + expect(screen.getByRole('button', { name: '下書きを並べ替える' })).toHaveFocus(); + expect(screen.getByRole('status')).toHaveTextContent('下書きを3件中2番目に動かしました'); + // 端を越える動きは何もしない + await user.keyboard('{ArrowUp}{ArrowUp}'); + expect(names()[0]).toBe('下書きを並べ替える'); + }); + + it('開閉ボタンで行の操作を出し、上へ・下へで動かせる(ドラッグできない人の代わりの操作)', async () => { + const user = userEvent.setup(); + render(); + const toggle = screen.getByRole('button', { name: '承認の操作' }); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByRole('button', { name: '承認を上へ' })).toBeNull(); + await user.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByRole('button', { name: '承認を下へ' })).toBeDisabled(); + await user.click(screen.getByRole('button', { name: '承認を上へ' })); + expect(names()).toEqual(['下書きを並べ替える', '承認を並べ替える', 'レビューを並べ替える']); + }); + + it('onRemove を渡すと外せ、フォーカスは隣の行のつまみへ移る', async () => { + const user = userEvent.setup(); + const onRemove = vi.fn(); + render(); + await user.click(screen.getByRole('button', { name: 'レビューの操作' })); + await user.click(screen.getByRole('button', { name: 'レビューを外す' })); + expect(onRemove).toHaveBeenCalledWith(STEPS[1]); + expect(names()).toEqual(['下書きを並べ替える', '承認を並べ替える']); + await waitFor(() => expect(screen.getByRole('button', { name: '承認を並べ替える' })).toHaveFocus()); + expect(screen.getByRole('status')).toHaveTextContent('レビューを外しました'); + }); + + it('onRemove が無ければ外すボタンを出さない', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: '下書きの操作' })); + expect(screen.queryByRole('button', { name: '下書きを外す' })).toBeNull(); + }); + + it('つまみをドラッグして離した位置へ一度に動かす', () => { + render(); + const rows = within(screen.getByRole('list', { name: '手順' })).getAllByRole('listitem'); + // happy-dom は配置を計算しないので、行の位置を与える(高さ 40、間 8) + rows.forEach((row, i) => { + row.getBoundingClientRect = () => ({ top: i * 48, height: 40 }) as DOMRect; + }); + const handle = screen.getByRole('button', { name: '承認を並べ替える' }); + fireEvent.pointerDown(handle, { button: 0, clientY: 100, pointerId: 1 }); + fireEvent.pointerMove(handle, { clientY: 20, pointerId: 1 }); + // ドラッグ中は並びを変えず、行を指に付けて動かす + expect(names()[2]).toBe('承認を並べ替える'); + expect(rows[2]?.style.transform).toBe('translateY(-80px)'); + fireEvent.pointerUp(handle, { clientY: 0, pointerId: 1 }); + expect(names()).toEqual(['承認を並べ替える', '下書きを並べ替える', 'レビューを並べ替える']); + }); + + it('文言を上書きできる', () => { + render( + step.id} + getLabel={(step) => step.name} + onChange={() => {}} + messages={{ handle: (name) => `Reorder ${name}`, actions: (name) => `Actions for ${name}` }} + />, + ); + expect(screen.getByRole('button', { name: 'Reorder 承認' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Actions for 承認' })).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/ui/SortableList/index.tsx b/packages/ui/src/components/ui/SortableList/index.tsx new file mode 100644 index 00000000..a42b9560 --- /dev/null +++ b/packages/ui/src/components/ui/SortableList/index.tsx @@ -0,0 +1,265 @@ +'use client'; + +import { type ReactNode, useId, useState } from 'react'; +import { Button } from '@/components/ui/Button/index'; +import { Icon } from '@/components/ui/Icon/index'; +import { focusRing } from '@/lib/focus'; +import { tv } from '@/lib/tv'; +import { moveItemTo, useSortable } from './useSortable'; + +export { moveItemTo } from './useSortable'; + +export const sortableListVariants = tv({ + slots: { + root: 'flex flex-col gap-stack-sm', + description: 'm-0 text-body-sm text-fg-subtle', + list: 'm-0 flex list-none flex-col gap-stack-sm p-0', + row: [ + 'flex min-w-0 flex-col rounded-md border border-border bg-surface', + 'transition-transform duration-(--8341-motion-duration-base) ease-standard', + 'forced-colors:border-[CanvasText]', + ], + line: 'flex min-w-0 items-center gap-gap-sm px-inset-sm py-inset-sm', + iconButton: [ + focusRing, + 'tap-target inline-flex size-control-sm shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0', + 'hover:bg-key-subtle active:bg-key-subtle-hover', + ], + handle: 'cursor-grab touch-none text-icon active:cursor-grabbing', + toggle: 'text-icon-key', + // 桁数が変わっても項目名の位置を揃える + number: 'min-w-[2ch] shrink-0 text-end text-body-md font-bold text-fg-subtle tabular-nums', + content: 'min-w-0 flex-1 text-body-md break-words text-fg', + actions: 'flex flex-wrap justify-end gap-gap-sm border-t border-border-subtle px-inset-sm py-inset-sm', + }, + variants: { + dragging: { + true: { row: 'relative z-(--8341-z-dropdown) border-border-key shadow-2' }, + }, + }, +}); + +export interface SortableListMessages { + /** 一覧の上の操作の説明。つまみの説明として読み上げられる */ + description: string; + /** つまみの名前 */ + handle: (name: string) => string; + /** 行の操作を開くボタンの名前 */ + actions: (name: string) => string; + moveUp: string; + moveDown: string; + remove: string; + /** 動かしたあとの読み上げ */ + moved: (name: string, position: number, count: number) => string; + /** 外したあとの読み上げ */ + removed: (name: string) => string; +} + +const MESSAGES: SortableListMessages = { + description: + '左のつまみをドラッグするか、つまみにフォーカスして上下の矢印キーで並べ替えます。右のボタンからも動かせます。', + handle: (name) => `${name}を並べ替える`, + actions: (name) => `${name}の操作`, + moveUp: '上へ', + moveDown: '下へ', + remove: '外す', + moved: (name, position, count) => `${name}を${count}件中${position}番目に動かしました`, + removed: (name) => `${name}を外しました`, +}; + +export interface SortableListProps { + /** 一覧の名前(必須)。順番つきリストの名前として読み上げられる */ + label: string; + items: readonly T[]; + /** 項目を見分けるキー。並べ替えても変わらない値 */ + getKey: (item: T) => string; + /** 項目の名前。つまみや操作のボタンの名前になる */ + getLabel: (item: T) => string; + /** 並べ替えた・外したあとの並び */ + onChange: (next: T[]) => void; + /** 行の中身。既定は getLabel の文字 */ + renderItem?: (item: T, index: number) => ReactNode; + /** 渡すと、行の操作に「外す」が出る(onChange にも外したあとの並びが渡る) */ + onRemove?: (item: T) => void; + /** 行の先頭に順番の数字を出す */ + numbered?: boolean; + /** 文言の上書き(既定は日本語) */ + messages?: Partial; + className?: string; +} + +/** + * ドラッグとキーボードで並べ替えられる順番つきリスト。 + * 行の左のつまみをポインタでドラッグするか、つまみにフォーカスして上下の矢印キーで動かす。 + * ドラッグできない人のために、右の開閉ボタンから「上へ」「下へ」(と「外す」)も出す(WCAG 2.5.7)。 + */ +export function SortableList({ + label, + items, + getKey, + getLabel, + onChange, + renderItem, + onRemove, + numbered = true, + messages, + className, +}: SortableListProps) { + const m = { ...MESSAGES, ...messages }; + const id = useId(); + const keys = items.map(getKey); + const [announcement, setAnnouncement] = useState(''); + const { dragging, handleProps, rowRef, move, focusAfterChange } = useSortable({ + keys, + onMove: (from, to) => { + const item = items[from]; + onChange(moveItemTo(items, from, to)); + if (item !== undefined) setAnnouncement(m.moved(getLabel(item), to + 1, items.length)); + }, + }); + const remove = (index: number) => { + const item = items[index]; + if (item === undefined) return; + // 外した行の操作は消えるので、フォーカスを隣の行のつまみへ移す + focusAfterChange(keys[index + 1] ?? keys[index - 1]); + onChange(items.filter((_, i) => i !== index)); + onRemove?.(item); + setAnnouncement(m.removed(getLabel(item))); + }; + const s = sortableListVariants(); + const descriptionId = `${id}-description`; + return ( +
    +

    + {m.description} +

    +
      + {items.map((item, index) => { + const key = keys[index] ?? String(index); + return ( + move(index, index + delta)} + onRemove={onRemove ? () => remove(index) : undefined} + > + {renderItem ? renderItem(item, index) : getLabel(item)} + + ); + })} +
    +
    + {announcement} +
    +
    + ); +} + +interface RowProps { + panelId: string; + name: string; + index: number; + count: number; + numbered: boolean; + dragging: boolean; + rowRef: (element: HTMLLIElement | null) => void; + handle: ReturnType['handleProps']>; + describedBy: string; + messages: SortableListMessages; + onMove: (delta: -1 | 1) => void; + onRemove: (() => void) | undefined; + children: ReactNode; +} + +function SortableRow({ + panelId, + name, + index, + count, + numbered, + dragging, + rowRef, + handle, + describedBy, + messages: m, + onMove, + onRemove, + children, +}: RowProps) { + const [open, setOpen] = useState(false); + const s = sortableListVariants({ dragging }); + return ( +
  1. +
    + + {numbered && ( + + )} +
    {children}
    + +
    + +
  2. + ); +} + +/** 縦に 2 列 3 段の点。つかんで動かせることを示す */ +function GripIcon() { + return ( + + ); +} diff --git a/packages/ui/src/components/ui/SortableList/useSortable.test.ts b/packages/ui/src/components/ui/SortableList/useSortable.test.ts new file mode 100644 index 00000000..4f609f22 --- /dev/null +++ b/packages/ui/src/components/ui/SortableList/useSortable.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { dropIndex, moveItemTo, shiftOf, type Slot } from './useSortable'; + +// 高さ 40、行の間 8 の 4 行 +const slots: Slot[] = [0, 48, 96, 144].map((top) => ({ top, height: 40 })); + +describe('moveItemTo', () => { + it('離れた位置へ一度に動かす', () => { + expect(moveItemTo(['a', 'b', 'c', 'd'], 3, 0)).toEqual(['d', 'a', 'b', 'c']); + expect(moveItemTo(['a', 'b', 'c', 'd'], 0, 2)).toEqual(['b', 'c', 'a', 'd']); + }); + + it('範囲の外を指したら元の並びを返す', () => { + expect(moveItemTo(['a', 'b'], 0, 2)).toEqual(['a', 'b']); + expect(moveItemTo(['a', 'b'], -1, 0)).toEqual(['a', 'b']); + }); +}); + +describe('dropIndex', () => { + it('動かしている行の中央が越えた行の数で、入る位置を決める', () => { + expect(dropIndex(slots, 0, 0)).toBe(0); + expect(dropIndex(slots, 0, 30)).toBe(0); + expect(dropIndex(slots, 0, 50)).toBe(1); + expect(dropIndex(slots, 0, 150)).toBe(3); + expect(dropIndex(slots, 3, -150)).toBe(0); + }); +}); + +describe('shiftOf', () => { + it('入る位置を空けるよう、間の行を 1 行分(高さ+行の間)ずらす', () => { + // 0 番目を 2 番目へ: 1・2 番目が上へ詰める + expect([0, 1, 2, 3].map((i) => shiftOf(slots, 0, 2, i))).toEqual([0, -48, -48, 0]); + // 3 番目を 1 番目へ: 1・2 番目が下へずれる + expect([0, 1, 2, 3].map((i) => shiftOf(slots, 3, 1, i))).toEqual([0, 48, 48, 0]); + }); +}); diff --git a/packages/ui/src/components/ui/SortableList/useSortable.ts b/packages/ui/src/components/ui/SortableList/useSortable.ts new file mode 100644 index 00000000..c78ec309 --- /dev/null +++ b/packages/ui/src/components/ui/SortableList/useSortable.ts @@ -0,0 +1,167 @@ +'use client'; + +import { type KeyboardEvent, type PointerEvent, useLayoutEffect, useRef, useState } from 'react'; + +/** 行の縦の位置と高さ。ドラッグを始めたときに測る */ +export interface Slot { + top: number; + height: number; +} + +/** 並びの中の 1 つを、離れた位置へ一度に動かす。範囲の外を指したら元の並びを返す */ +export function moveItemTo(items: readonly T[], from: number, to: number): T[] { + if (from < 0 || from >= items.length || to < 0 || to >= items.length) return [...items]; + const next = [...items]; + const [moved] = next.splice(from, 1); + if (moved !== undefined) next.splice(to, 0, moved); + return next; +} + +/** 動かしている行の中央が、ほかの行の中央をいくつ越えたかで、入る位置を決める */ +export function dropIndex(slots: readonly Slot[], from: number, dy: number): number { + const dragged = slots[from]; + if (dragged === undefined) return from; + const center = dragged.top + dragged.height / 2 + dy; + return slots.filter((slot, index) => index !== from && slot.top + slot.height / 2 < center).length; +} + +/** 入る位置を空けるために、ほかの行を縦にずらす量。動かしている行の高さと行の間の分だけ詰める */ +export function shiftOf(slots: readonly Slot[], from: number, to: number, index: number): number { + const dragged = slots[from]; + const [first, second] = slots; + if (dragged === undefined || index === from) return 0; + const gap = first !== undefined && second !== undefined ? second.top - (first.top + first.height) : 0; + const step = dragged.height + gap; + if (from < index && index <= to) return -step; + if (to <= index && index < from) return step; + return 0; +} + +// 位置は style 属性ではなく CSSOM で動かす。CSP の style-src はインラインの style 属性を止めるが、スクリプトからの変更は止めない +const translate = (element: HTMLElement | undefined, y: number) => { + if (element !== undefined) element.style.transform = y === 0 ? '' : `translateY(${y}px)`; +}; + +type Drag = { key: string; from: number; startY: number; slots: Slot[] }; + +export interface HandleProps { + ref: (element: HTMLButtonElement | null) => void; + onPointerDown: (event: PointerEvent) => void; + onPointerMove: (event: PointerEvent) => void; + onPointerUp: (event: PointerEvent) => void; + onPointerCancel: () => void; + onKeyDown: (event: KeyboardEvent) => void; +} + +/** + * つまみのドラッグと上下の矢印キーでの並べ替え。 + * - ドラッグ中は行を指に付けて動かし、ほかの行は入る位置を空けるようにずらす。並びは離したときに一度で決める + * - 並びが変わったら、前の見た目の位置から新しい位置へ動かす(FLIP)。動きを減らす設定ではテーマの規則で止まる + * - 矢印キーで動かしたあとも、フォーカスは動かした行のつまみに残す + */ +export function useSortable({ keys, onMove }: { keys: readonly string[]; onMove: (from: number, to: number) => void }) { + const rows = useRef(new Map()); + const handles = useRef(new Map()); + const before = useRef | undefined>(undefined); + const refocus = useRef(undefined); + const drag = useRef(undefined); + const [dragging, setDragging] = useState(undefined); + + const measure = (): Slot[] => + keys.map((key) => { + const rect = rows.current.get(key)?.getBoundingClientRect(); + return { top: rect?.top ?? 0, height: rect?.height ?? 0 }; + }); + + useLayoutEffect(() => { + const tops = before.current; + before.current = undefined; + if (tops !== undefined) { + for (const [key, element] of rows.current) { + const top = tops.get(key); + element.style.transition = 'none'; + translate(element, top === undefined ? 0 : top - element.getBoundingClientRect().top); + // 前の位置を一度描かせてから、遷移を戻して新しい位置へ動かす + void element.offsetHeight; + element.style.transition = ''; + translate(element, 0); + } + } + // 行の DOM が並べ替わるとフォーカスが外れることがあるので、動かした行のつまみへ戻す + if (refocus.current !== undefined) handles.current.get(refocus.current)?.focus(); + refocus.current = undefined; + }, [keys]); + + /** 並びを変える。FLIP のために、変える前の位置を覚えておく */ + const move = (from: number, to: number) => { + if (from === to || to < 0 || to >= keys.length) return; + const slots = measure(); + before.current = new Map(keys.map((key, index) => [key, slots[index]?.top ?? 0])); + onMove(from, to); + }; + + const follow = (current: Drag, clientY: number) => { + const dy = clientY - current.startY; + const to = dropIndex(current.slots, current.from, dy); + keys.forEach((key, index) => { + translate(rows.current.get(key), key === current.key ? dy : shiftOf(current.slots, current.from, to, index)); + }); + return to; + }; + + const release = () => { + for (const element of rows.current.values()) { + element.style.transition = ''; + translate(element, 0); + } + drag.current = undefined; + setDragging(undefined); + }; + + const handleProps = (key: string): HandleProps => ({ + ref: (element) => { + if (element === null) handles.current.delete(key); + else handles.current.set(key, element); + }, + onPointerDown: (event) => { + if (event.button !== 0) return; + event.currentTarget.setPointerCapture?.(event.pointerId); + drag.current = { key, from: keys.indexOf(key), startY: event.clientY, slots: measure() }; + const element = rows.current.get(key); + // 動かしている行だけは遅れずに指に付ける + if (element !== undefined) element.style.transition = 'none'; + setDragging(key); + }, + onPointerMove: (event) => { + if (drag.current?.key === key) follow(drag.current, event.clientY); + }, + onPointerUp: (event) => { + const current = drag.current; + if (current?.key !== key) return; + const to = follow(current, event.clientY); + release(); + move(current.from, to); + }, + onPointerCancel: release, + onKeyDown: (event) => { + const delta = event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0; + if (delta === 0) return; + event.preventDefault(); + const from = keys.indexOf(key); + refocus.current = key; + move(from, from + delta); + }, + }); + + const rowRef = (key: string) => (element: HTMLElement | null) => { + if (element === null) rows.current.delete(key); + else rows.current.set(key, element); + }; + + /** 次に並びが変わったあと、この行のつまみへフォーカスを移す(外した行の隣へ移すときなど) */ + const focusAfterChange = (key: string | undefined) => { + refocus.current = key; + }; + + return { dragging, handleProps, rowRef, move, focusAfterChange }; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 1d75cd32..329c6b36 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -17,6 +17,7 @@ export * from './components/ui/NotificationBanner/index'; export * from './components/ui/RadioGroup/index'; export * from './components/ui/Select/index'; export * from './components/ui/Skeleton/index'; +export * from './components/ui/SortableList/index'; export * from './components/ui/Spinner/index'; export * from './components/ui/Stack/index'; export * from './components/ui/Switch/index';