Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .changeset/sortable-list.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'8341-ui': patch
---

`SortableList` を追加しました(`npx shadcn add @8341/sortable-list`)。利用者が項目の順番を決める一覧です。

- 行の左のつまみをドラッグすると行が指に付いて動き、離した位置へ一度で動く。つまみにフォーカスして上下の矢印キーでも動かせる
- 右の開閉ボタンから「上へ」「下へ」(`onRemove` を渡すと「外す」も)を出す。ドラッグできない人の代わりの操作(WCAG 2.5.7)
- 並びが変わると前の位置から新しい位置へ動き、動かした位置を読み上げる
14 changes: 14 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,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/`
Expand Down Expand Up @@ -291,6 +292,19 @@ DTCG では `{"colorSpace": "oklch", "components": [L, C, H], "hex": "#..."}`
- 1 行に収まらない文字を … で省略する(`children` は文字列)。省略されたときだけ Tab で止まり、ホバーかフォーカスで全文を Base UI の Tooltip(`bg-inverse`)で出す
- 読み上げは常に全文。本文には使わず、表の列や一覧の項目名など幅が限られる場所に使う

### SortableList

```tsx
<SortableList label="承認の手順" items={steps} getKey={(s) => s.id} getLabel={(s) => s.name}
onChange={setSteps} onRemove={(s) => …} />
```

- 利用者が項目の順番を決める一覧(`<ol>`)。`label` 必須(一覧の名前)、`getLabel` は項目の名前(つまみ・操作のボタンの名前になる)
- 行の左のつまみをドラッグすると、行は指に付いて動き、ほかの行は入る位置を空ける。離した位置へ一度で動く。つまみにフォーカスして上下の矢印キーでも動かせ、フォーカスはつまみに残る
- 右の開閉ボタン(`aria-expanded`)で行の下に「上へ」「下へ」(`onRemove` を渡すと「外す」も)を出す。ドラッグできない人の代わりの操作(WCAG 2.5.7)。ボタンを行に並べないので、狭い幅でも項目名が折り返さない
- 並びが変わると前の位置から新しい位置へ動き(FLIP。動きを減らす設定では止まる)、動かした位置を `role="status"` で読み上げる。位置は style 属性ではなく CSSOM で動かす(CSP で止まらない)
- 文言は `messages` で上書きできる

### Card

- `border-border`、`rounded-xl`、内側余白 `p-inset-xl`
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,13 @@ Tailwind を使わない場合は、ビルド済みの `import '8341-ui/styles.c

## コンポーネント

ヘッドレス UI の [Base UI](https://base-ui.com/) に、トークンの見た目を載せた 24 種類です。使い分けの規約は [04 コンポーネント](./docs/rulebook/04-components.md)、動く見本は [Storybook](https://tkc310.github.io/8341-ui/) にあります。
ヘッドレス UI の [Base UI](https://base-ui.com/) に、トークンの見た目を載せた 25 種類です。使い分けの規約は [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` · `AlertDialog` |
Expand Down
1 change: 1 addition & 0 deletions docs/improvement/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
| 2026-11-07 | 部品 | タッチで押したことが伝わるよう、hover だけだった部品(Link・Card・Accordion・Checkbox / Radio / Switch(ラベルを押しても出る)・入力欄・NotificationBanner の閉じる)に、hover と同じ見た目の `active:` を足した。ボタンの見た目(`buttonVariants`)をリンクに当てると `enabled:` が `<a>` に当たらず hover も押した見た目も出なかったので、Button を `not-disabled:` に替えた。端末のタップの塗りは消した。bouzu(#84・#89)での知見 | クラス監査に `enabled-variant`(enabled: を禁止)と `hover-without-active`(hover: だけで active: が無い部品)を追加。Button・Link のテスト。iOS Safari の touchstart の受け手を README に記載 |
| 2026-11-07 | 部品 | 利用側が `style-src` に `'unsafe-inline'` を入れない CSP を掛けると、サーバーで描画した `style` 属性が止まり、Checkbox・Radio・Switch の隣のネイティブ入力が見えていた。`hide-native-input` でクラスからも隠し、Skeleton の既定の大きさをクラスに移した。ルールブック 04 に「CSP の下で使う」(Base UI が出す style、ハッシュでの許し方、eval を使わない検証ライブラリ)を追加。bouzu(#78・#82・#84)での知見 | Checkbox・Switch・Skeleton のテスト。ルールブック 04 に style 属性に頼らない規約を追加 |
| 2026-11-07 | 部品 | AlertDialog を追加(バックログ)。取り消せない操作の前の確認に使う。見出しは対象を入れた問いかけ、ボタンは「キャンセル」と動詞。開くとフォーカスをキャンセルに置き、`onConfirm` が Promise なら終わるまで処理中にして閉じない。bouzu(#73)の削除確認の文言と流れから | 単体テスト(名前・説明・初期フォーカス・キャンセル・処理中・失敗・外側を押しても閉じない)とストーリーの play 関数。04 の部品の選び方に追加 |
| 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 で追従 |
Expand Down
1 change: 1 addition & 0 deletions docs/rulebook/04-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,5 +91,6 @@
| 取り消せない操作の前に確かめる | AlertDialog(`title`・`confirmLabel` 必須) | Dialog、`window.confirm` |
| 読み込み・保存の最中 | Spinner(ボタンの中は Button の `loading`) | 文言の無い回転アイコン |
| 中身の形が分かる読み込み | Skeleton + 領域の `aria-busy` | 空白のまま待たせる |
| 利用者が項目の順番を決める | SortableList(ドラッグ・矢印キー・開閉式の「上へ」「下へ」) | ドラッグだけの並べ替え、順番の数字の入力欄 |
| 幅が限られた 1 行の文字 | Truncate(表の列・一覧の項目名) | 本文の省略 |
| フォームの送信と検証 | Base UI の Form + FormField | 独自の検証の仕組み |
23 changes: 23 additions & 0 deletions packages/ui/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,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",
Expand Down
84 changes: 84 additions & 0 deletions packages/ui/src/components/ui/SortableList/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SortableList
label="承認の手順"
items={items}
getKey={(step) => step.id}
getLabel={(step) => step.name}
onChange={setItems}
onRemove={removable ? () => {} : undefined}
renderItem={
detailed
? (step) => (
<span className="flex flex-col">
<span>{step.name}</span>
<span className="text-body-sm text-fg-subtle">{step.owner}</span>
</span>
)
: 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: () => <Example />,
} satisfies Meta<typeof SortableList<Step>>;

export default meta;
type Story = StoryObj<typeof meta>;

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: () => <Example removable={false} detailed />,
};
120 changes: 120 additions & 0 deletions packages/ui/src/components/ui/SortableList/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SortableList
label="手順"
items={items}
getKey={(step) => 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(<Harness />);
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(<Harness />);
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(<Harness />);
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(<Harness onRemove={onRemove} />);
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(<Harness />);
await user.click(screen.getByRole('button', { name: '下書きの操作' }));
expect(screen.queryByRole('button', { name: '下書きを外す' })).toBeNull();
});

it('つまみをドラッグして離した位置へ一度に動かす', () => {
render(<Harness />);
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(
<SortableList
label="Steps"
items={STEPS}
getKey={(step) => 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();
});
});
Loading
Loading