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/alert-dialog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'8341-ui': patch
---

`AlertDialog` を追加しました(`npx shadcn add @8341/alert-dialog`)。取り消せない操作の前に、実行してよいかを確かめるダイアログです(Base UI の AlertDialog)。

- 見出しは対象を入れた問いかけ(「『週次レポート』を削除しますか」)、ボタンは「キャンセル」と動詞(「削除する」)
- 開くとフォーカスを「キャンセル」に置き、外側を押しても閉じない
- `onConfirm` が Promise を返すと、終わるまで実行ボタンを処理中にして閉じない。例外を投げると開いたままになる
17 changes: 16 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ DTCG では `{"colorSpace": "oklch", "components": [L, C, H], "hex": "#..."}`

<!-- auto:components -->
- `Accordion`(`@8341/accordion`)— `packages/ui/src/components/ui/Accordion/`
- `AlertDialog`(`@8341/alert-dialog`)— `packages/ui/src/components/ui/AlertDialog/`
- `Breadcrumb`(`@8341/breadcrumb`)— `packages/ui/src/components/ui/Breadcrumb/`
- `Button`(`@8341/button`)— `packages/ui/src/components/ui/Button/`
- `Card`(`@8341/card`)— `packages/ui/src/components/ui/Card/`
Expand Down Expand Up @@ -264,7 +265,21 @@ DTCG では `{"colorSpace": "oklch", "components": [L, C, H], "hex": "#..."}`
- Base UI の Dialog。`title` 必須(名前)、`description`(説明)。開くとフォーカスが中に移り、Esc か「閉じる」(`closeLabel`)で閉じて開いたボタンに戻る
- `open` / `onOpenChange` で開閉を管理するときは `trigger` を省く
- `placement`: `center`(既定。`bg-overlay` の背面、`rounded-xl`、最大幅 `max-w-prose`、本文だけスクロール)/ `side`(先頭側からせり出す。裏の画面も操作でき、フォーカスだけ閉じ込める)
- 取り消せない操作の確認には使わない(AlertDialog はバックログ)
- 取り消せない操作の確認には使わない(AlertDialog を使う)

### AlertDialog

```tsx
<AlertDialog title="「週次レポート」を削除しますか" description="元に戻せません。" confirmLabel="削除する"
trigger={<Button variant="tertiary">削除</Button>} onConfirm={() => remove(report.id)} />
```

- Base UI の AlertDialog(`role="alertdialog"`)。取り消せない操作の前の確認だけに使う
- `title` は対象を入れた問いかけ、`description` は結果(「元に戻せません。」)、`confirmLabel` は動詞(「削除する」)。やめるボタンは「キャンセル」(`cancelLabel`)
- 開くとフォーカスは「キャンセル」。外側を押しても閉じず、Esc とキャンセルで閉じる
- `tone`: `danger`(既定。失う操作)/ `primary`(送信・公開など)
- `onConfirm` が Promise を返すと、終わるまで実行ボタンを `loading` にして閉じない。例外を投げると開いたまま(失敗は `children` に NotificationBanner で出す)
- 一覧の行の中など、その場で確かめられるなら、ダイアログを出さずに行の中で「削除しますか。元に戻せません。」と「削除する」「キャンセル」に置き換えてもよい

### Spinner / Skeleton

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ 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/) にあります。

| 種類 | コンポーネント |
| --- | --- |
Expand All @@ -149,7 +149,7 @@ Tailwind を使わない場合は、ビルド済みの `import '8341-ui/styles.c
| 表示 | `Heading` · `Text` · `Truncate` · `Tag` · `Icon` · `Card` · `Divider` · `Stack` |
| ナビゲーション | `Breadcrumb` · `Accordion` |
| フィードバック | `NotificationBanner` · `Spinner` · `Skeleton` |
| オーバーレイ | `Dialog` |
| オーバーレイ | `Dialog` · `AlertDialog` |

## 技術スタック

Expand Down
1 change: 0 additions & 1 deletion docs/improvement/backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
## コンポーネント

- [ ] Table(ソート・横スクロール時の見出し固定、識別子の等幅)— 特化ルール「管理画面」
- [ ] AlertDialog(Base UI。取り消せない操作の確認)— 原則「誤りと撤回」の確認の段。入力用の Dialog は提供済み
- [ ] EmptyState(「データが無い」「条件に一致しない」「権限がない」の書き分け)— 特化ルール「管理画面」
- [ ] Progress / Toast(Base UI)— 原則「状態と応答」
- [ ] 汎用の Tooltip(Base UI。1.4.13 の解除・ホバー・持続を満たすか確認)・Combobox — 原則「再認」。Truncate は省略した全文の表示だけに使う
Expand Down
1 change: 1 addition & 0 deletions docs/improvement/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,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-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 @@ -88,6 +88,7 @@
| よくある質問など折りたたみ | Accordion + AccordionItem | 独自のディスクロージャ |
| 要素の縦横の間隔 | Stack | 個別の margin |
| 画面に重ねて入力・確認させる | Dialog(`title` 必須) | 独自のモーダル |
| 取り消せない操作の前に確かめる | AlertDialog(`title`・`confirmLabel` 必須) | Dialog、`window.confirm` |
| 読み込み・保存の最中 | Spinner(ボタンの中は Button の `loading`) | 文言の無い回転アイコン |
| 中身の形が分かる読み込み | Skeleton + 領域の `aria-busy` | 空白のまま待たせる |
| 幅が限られた 1 行の文字 | Truncate(表の列・一覧の項目名) | 本文の省略 |
Expand Down
19 changes: 19 additions & 0 deletions packages/ui/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,25 @@
}
]
},
{
"name": "alert-dialog",
"type": "registry:ui",
"title": "AlertDialog",
"description": "取り消せない操作の前に、実行してよいかを確かめるダイアログ(Base UI の AlertDialog。",
"dependencies": [
"@base-ui/react@^1.9.0"
],
"registryDependencies": [
"@8341/button",
"@8341/tv"
],
"files": [
{
"path": "src/components/ui/AlertDialog/index.tsx",
"type": "registry:ui"
}
]
},
{
"name": "breadcrumb",
"type": "registry:ui",
Expand Down
92 changes: 92 additions & 0 deletions packages/ui/src/components/ui/AlertDialog/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useState } from 'react';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { Button } from '@/components/ui/Button/index';
import { NotificationBanner } from '@/components/ui/NotificationBanner/index';
import { AlertDialog } from './index';

const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));

const meta = {
title: 'Components/AlertDialog',
component: AlertDialog,
args: {
title: '「週次レポート」を削除しますか',
description: '元に戻せません。',
confirmLabel: '削除する',
trigger: <Button variant="tertiary">削除</Button>,
onConfirm: () => wait(600),
},
argTypes: {
tone: { control: 'inline-radio', options: ['danger', 'primary'] },
},
parameters: {
docs: {
description: {
component:
'取り消せない操作の前に、実行してよいかを確かめるダイアログ(Base UI の AlertDialog)。見出しは対象を入れた問いかけ、説明は結果(元に戻せないこと)、ボタンは「キャンセル」と動詞の「削除する」。開くとフォーカスはキャンセルに置き、外側を押しても閉じない。onConfirm が Promise を返すと、終わるまで実行ボタンを処理中にする。',
},
},
},
} satisfies Meta<typeof AlertDialog>;

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

export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(document.body);
const trigger = canvas.getByRole('button', { name: '削除' });
await userEvent.click(trigger);
const dialog = await body.findByRole('alertdialog', { name: '「週次レポート」を削除しますか' });
await waitFor(() => expect(dialog).toBeVisible());
// Enter の押し間違いで実行しないよう、フォーカスはキャンセルにある
await waitFor(() => expect(within(dialog).getByRole('button', { name: 'キャンセル' })).toHaveFocus());
await userEvent.keyboard('{Escape}');
await waitFor(() => expect(body.queryByRole('alertdialog')).toBeNull());
await expect(trigger).toHaveFocus();
},
};

/** 失うものが無い確認(送信・公開など)は primary にする */
export const Primary: Story = {
args: {
tone: 'primary',
title: '申請を送信しますか',
description: '送信すると、承認されるまで内容を変えられません。',
confirmLabel: '送信する',
trigger: <Button>申請を送信する</Button>,
},
};

function FailOnce() {
const [error, setError] = useState(false);
return (
<AlertDialog
title="「週次レポート」を削除しますか"
description="元に戻せません。"
confirmLabel="削除する"
trigger={<Button variant="tertiary">削除</Button>}
onOpenChange={() => setError(false)}
onConfirm={async () => {
await wait(400);
if (!error) {
setError(true);
throw new Error('通信に失敗しました');
}
}}
>
{error && (
<NotificationBanner tone="danger" title="削除できませんでした" live>
通信が切れました。もう一度「削除する」を押してください。
</NotificationBanner>
)}
</AlertDialog>
);
}

/** onConfirm が例外を投げると開いたままになる。失敗は children で知らせる(1 回目は失敗し、2 回目で閉じる) */
export const Failure: Story = {
render: () => <FailOnce />,
};
76 changes: 76 additions & 0 deletions packages/ui/src/components/ui/AlertDialog/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vite-plus/test';
import { Button } from '@/components/ui/Button/index';
import { expectNoA11yViolations } from '@/test/a11y';
import { AlertDialog } from './index';

const renderDelete = (onConfirm: () => void | Promise<void>) =>
render(
<AlertDialog
title="「週次レポート」を削除しますか"
description="元に戻せません。"
confirmLabel="削除する"
onConfirm={onConfirm}
trigger={<Button variant="tertiary">削除</Button>}
/>,
);

describe('AlertDialog', () => {
it('問いかけと説明で alertdialog に名前と説明が付き、フォーカスはキャンセルに置く', async () => {
const user = userEvent.setup();
renderDelete(() => {});
await user.click(screen.getByRole('button', { name: '削除' }));
const dialog = await screen.findByRole('alertdialog', { name: '「週次レポート」を削除しますか' });
expect(dialog).toHaveAccessibleDescription('元に戻せません。');
await waitFor(() => expect(screen.getByRole('button', { name: 'キャンセル' })).toHaveFocus());
await expectNoA11yViolations(dialog);
});

it('キャンセルで閉じ、実行せずにフォーカスを開くボタンに戻す', async () => {
const user = userEvent.setup();
const onConfirm = vi.fn();
renderDelete(onConfirm);
const trigger = screen.getByRole('button', { name: '削除' });
await user.click(trigger);
await user.click(await screen.findByRole('button', { name: 'キャンセル' }));
await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
expect(onConfirm).not.toHaveBeenCalled();
expect(trigger).toHaveFocus();
});

it('実行すると onConfirm を呼び、終わるまで処理中にしてから閉じる', async () => {
const user = userEvent.setup();
let finish = () => {};
const onConfirm = vi.fn(() => new Promise<void>((resolve) => (finish = resolve)));
renderDelete(onConfirm);
await user.click(screen.getByRole('button', { name: '削除' }));
const confirm = await screen.findByRole('button', { name: '削除する' });
await user.click(confirm);
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(confirm).toHaveAttribute('aria-busy', 'true');
// 処理中は Esc でも閉じない
await user.keyboard('{Escape}');
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
finish();
await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
});

it('onConfirm が失敗したら開いたままにする', async () => {
const user = userEvent.setup();
renderDelete(() => Promise.reject(new Error('通信に失敗しました')));
await user.click(screen.getByRole('button', { name: '削除' }));
await user.click(await screen.findByRole('button', { name: '削除する' }));
await waitFor(() => expect(screen.getByRole('button', { name: '削除する' })).not.toHaveAttribute('aria-busy'));
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
});

it('外側を押しても閉じない', async () => {
const user = userEvent.setup();
renderDelete(() => {});
await user.click(screen.getByRole('button', { name: '削除' }));
await screen.findByRole('alertdialog');
await user.click(document.body);
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
});
});
Loading
Loading