From 5422f369abdd1c6ab09f4e2e47ecd5a88b0190e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 00:00:16 +0000 Subject: [PATCH] =?UTF-8?q?feat(alert-dialog):=20=E5=8F=96=E3=82=8A?= =?UTF-8?q?=E6=B6=88=E3=81=9B=E3=81=AA=E3=81=84=E6=93=8D=E4=BD=9C=E3=81=AE?= =?UTF-8?q?=E5=89=8D=E3=81=AB=E7=A2=BA=E3=81=8B=E3=82=81=E3=82=8B=20AlertD?= =?UTF-8?q?ialog=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 - Base UI の AlertDialog。見出しは対象を入れた問いかけ、ボタンは「キャンセル」と動詞 - 開くとフォーカスをキャンセルに置き、外側を押しても閉じない - onConfirm が Promise なら終わるまで処理中にして閉じない。例外なら開いたまま - DESIGN.md・04 の部品の選び方・README に追加し、バックログから外した bouzu(tkc310/bouzu#73)の削除確認の文言と流れを反映。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S5dPLGGVzZyRz8dob3cTmz --- .changeset/alert-dialog.md | 9 ++ DESIGN.md | 17 ++- README.md | 4 +- docs/improvement/backlog.md | 1 - docs/improvement/log.md | 1 + docs/rulebook/04-components.md | 1 + packages/ui/registry.json | 19 +++ .../ui/AlertDialog/index.stories.tsx | 92 ++++++++++++++ .../components/ui/AlertDialog/index.test.tsx | 76 ++++++++++++ .../src/components/ui/AlertDialog/index.tsx | 117 ++++++++++++++++++ packages/ui/src/index.ts | 1 + 11 files changed, 334 insertions(+), 4 deletions(-) create mode 100644 .changeset/alert-dialog.md create mode 100644 packages/ui/src/components/ui/AlertDialog/index.stories.tsx create mode 100644 packages/ui/src/components/ui/AlertDialog/index.test.tsx create mode 100644 packages/ui/src/components/ui/AlertDialog/index.tsx diff --git a/.changeset/alert-dialog.md b/.changeset/alert-dialog.md new file mode 100644 index 00000000..45349579 --- /dev/null +++ b/.changeset/alert-dialog.md @@ -0,0 +1,9 @@ +--- +'8341-ui': patch +--- + +`AlertDialog` を追加しました(`npx shadcn add @8341/alert-dialog`)。取り消せない操作の前に、実行してよいかを確かめるダイアログです(Base UI の AlertDialog)。 + +- 見出しは対象を入れた問いかけ(「『週次レポート』を削除しますか」)、ボタンは「キャンセル」と動詞(「削除する」) +- 開くとフォーカスを「キャンセル」に置き、外側を押しても閉じない +- `onConfirm` が Promise を返すと、終わるまで実行ボタンを処理中にして閉じない。例外を投げると開いたままになる diff --git a/DESIGN.md b/DESIGN.md index 9eaa508a..9e749565 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -156,6 +156,7 @@ DTCG では `{"colorSpace": "oklch", "components": [L, C, H], "hex": "#..."}` - `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/` @@ -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 +削除} onConfirm={() => remove(report.id)} /> +``` + +- Base UI の AlertDialog(`role="alertdialog"`)。取り消せない操作の前の確認だけに使う +- `title` は対象を入れた問いかけ、`description` は結果(「元に戻せません。」)、`confirmLabel` は動詞(「削除する」)。やめるボタンは「キャンセル」(`cancelLabel`) +- 開くとフォーカスは「キャンセル」。外側を押しても閉じず、Esc とキャンセルで閉じる +- `tone`: `danger`(既定。失う操作)/ `primary`(送信・公開など) +- `onConfirm` が Promise を返すと、終わるまで実行ボタンを `loading` にして閉じない。例外を投げると開いたまま(失敗は `children` に NotificationBanner で出す) +- 一覧の行の中など、その場で確かめられるなら、ダイアログを出さずに行の中で「削除しますか。元に戻せません。」と「削除する」「キャンセル」に置き換えてもよい ### Spinner / Skeleton diff --git a/README.md b/README.md index 11b519d6..c88e1711 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,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/) にあります。 | 種類 | コンポーネント | | --- | --- | @@ -139,7 +139,7 @@ Tailwind を使わない場合は、ビルド済みの `import '8341-ui/styles.c | 表示 | `Heading` · `Text` · `Truncate` · `Tag` · `Icon` · `Card` · `Divider` · `Stack` | | ナビゲーション | `Breadcrumb` · `Accordion` | | フィードバック | `NotificationBanner` · `Spinner` · `Skeleton` | -| オーバーレイ | `Dialog` | +| オーバーレイ | `Dialog` · `AlertDialog` | ## 技術スタック diff --git a/docs/improvement/backlog.md b/docs/improvement/backlog.md index ec031edb..a297f858 100644 --- a/docs/improvement/backlog.md +++ b/docs/improvement/backlog.md @@ -16,7 +16,6 @@ ## コンポーネント - [ ] Table(ソート・横スクロール時の見出し固定、識別子の等幅)— 特化ルール「管理画面」 -- [ ] AlertDialog(Base UI。取り消せない操作の確認)— 原則「誤りと撤回」の確認の段。入力用の Dialog は提供済み - [ ] EmptyState(「データが無い」「条件に一致しない」「権限がない」の書き分け)— 特化ルール「管理画面」 - [ ] Progress / Toast(Base UI)— 原則「状態と応答」 - [ ] 汎用の Tooltip(Base UI。1.4.13 の解除・ホバー・持続を満たすか確認)・Combobox — 原則「再認」。Truncate は省略した全文の表示だけに使う diff --git a/docs/improvement/log.md b/docs/improvement/log.md index 4e8c9fd6..f67dff11 100644 --- a/docs/improvement/log.md +++ b/docs/improvement/log.md @@ -4,6 +4,7 @@ | 日付 | 種別 | 内容 | 再発防止 | | ---------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| 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 で追従 | diff --git a/docs/rulebook/04-components.md b/docs/rulebook/04-components.md index f3d5d427..02b3ec93 100644 --- a/docs/rulebook/04-components.md +++ b/docs/rulebook/04-components.md @@ -71,6 +71,7 @@ | よくある質問など折りたたみ | Accordion + AccordionItem | 独自のディスクロージャ | | 要素の縦横の間隔 | Stack | 個別の margin | | 画面に重ねて入力・確認させる | Dialog(`title` 必須) | 独自のモーダル | +| 取り消せない操作の前に確かめる | AlertDialog(`title`・`confirmLabel` 必須) | Dialog、`window.confirm` | | 読み込み・保存の最中 | Spinner(ボタンの中は Button の `loading`) | 文言の無い回転アイコン | | 中身の形が分かる読み込み | Skeleton + 領域の `aria-busy` | 空白のまま待たせる | | 幅が限られた 1 行の文字 | Truncate(表の列・一覧の項目名) | 本文の省略 | diff --git a/packages/ui/registry.json b/packages/ui/registry.json index b38134cd..68eb06bf 100644 --- a/packages/ui/registry.json +++ b/packages/ui/registry.json @@ -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", diff --git a/packages/ui/src/components/ui/AlertDialog/index.stories.tsx b/packages/ui/src/components/ui/AlertDialog/index.stories.tsx new file mode 100644 index 00000000..85e55595 --- /dev/null +++ b/packages/ui/src/components/ui/AlertDialog/index.stories.tsx @@ -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((resolve) => setTimeout(resolve, ms)); + +const meta = { + title: 'Components/AlertDialog', + component: AlertDialog, + args: { + title: '「週次レポート」を削除しますか', + description: '元に戻せません。', + confirmLabel: '削除する', + trigger: , + onConfirm: () => wait(600), + }, + argTypes: { + tone: { control: 'inline-radio', options: ['danger', 'primary'] }, + }, + parameters: { + docs: { + description: { + component: + '取り消せない操作の前に、実行してよいかを確かめるダイアログ(Base UI の AlertDialog)。見出しは対象を入れた問いかけ、説明は結果(元に戻せないこと)、ボタンは「キャンセル」と動詞の「削除する」。開くとフォーカスはキャンセルに置き、外側を押しても閉じない。onConfirm が Promise を返すと、終わるまで実行ボタンを処理中にする。', + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +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: , + }, +}; + +function FailOnce() { + const [error, setError] = useState(false); + return ( + 削除} + onOpenChange={() => setError(false)} + onConfirm={async () => { + await wait(400); + if (!error) { + setError(true); + throw new Error('通信に失敗しました'); + } + }} + > + {error && ( + + 通信が切れました。もう一度「削除する」を押してください。 + + )} + + ); +} + +/** onConfirm が例外を投げると開いたままになる。失敗は children で知らせる(1 回目は失敗し、2 回目で閉じる) */ +export const Failure: Story = { + render: () => , +}; diff --git a/packages/ui/src/components/ui/AlertDialog/index.test.tsx b/packages/ui/src/components/ui/AlertDialog/index.test.tsx new file mode 100644 index 00000000..a3c74371 --- /dev/null +++ b/packages/ui/src/components/ui/AlertDialog/index.test.tsx @@ -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) => + render( + 削除} + />, + ); + +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((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(); + }); +}); diff --git a/packages/ui/src/components/ui/AlertDialog/index.tsx b/packages/ui/src/components/ui/AlertDialog/index.tsx new file mode 100644 index 00000000..cae99aa5 --- /dev/null +++ b/packages/ui/src/components/ui/AlertDialog/index.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { AlertDialog as BaseAlertDialog } from '@base-ui/react/alert-dialog'; +import { type ReactElement, type ReactNode, useRef, useState } from 'react'; +import { Button } from '@/components/ui/Button/index'; +import { tv } from '@/lib/tv'; + +export const alertDialogVariants = tv({ + slots: { + backdrop: [ + 'fixed inset-0 z-(--8341-z-overlay) bg-overlay', + 'transition-opacity duration-(--8341-motion-duration-base) data-[ending-style]:opacity-0 data-[starting-style]:opacity-0', + ], + popup: [ + 'fixed top-1/2 left-1/2 z-(--8341-z-modal) flex -translate-1/2 flex-col gap-stack-sm rounded-xl bg-surface p-inset-xl text-fg shadow-3', + 'max-h-[calc(100dvh-var(--8341-space-page-inline)*2)] w-[calc(100vw-var(--8341-space-page-inline)*2)] max-w-prose overflow-y-auto', + 'transition-[opacity,scale] duration-(--8341-motion-duration-base) ease-standard', + 'data-[ending-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:scale-95 data-[starting-style]:opacity-0', + 'forced-colors:border forced-colors:border-[CanvasText]', + ], + title: 'm-0 text-heading-xs', + description: 'm-0 text-body-md text-fg-subtle', + footer: 'mt-stack-sm flex flex-wrap justify-end gap-gap-sm', + }, +}); + +export interface AlertDialogProps { + /** 問いかけの見出し(必須)。対象を書く: 「『週次レポート』を削除しますか」 */ + title: ReactNode; + /** 結果の説明。取り消せないなら書く: 「元に戻せません。」 */ + description?: ReactNode; + /** 実行するボタンの文言(必須)。動詞で書く: 「削除する」 */ + confirmLabel: string; + /** やめるボタンの文言 */ + cancelLabel?: string; + /** 実行するボタンの色。取り消せない・失う操作は danger(既定)、それ以外は primary */ + tone?: 'danger' | 'primary'; + /** + * 実行したときの処理。Promise を返すと、終わるまで実行ボタンを処理中にして閉じない。 + * 失敗したときは例外を投げると開いたままになる(誤りは children で知らせる) + */ + onConfirm: () => void | Promise; + /** 開くボタン(Button など)。open で開閉を管理する場合は省略できる */ + trigger?: ReactElement; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + className?: string; + /** 説明に収まらない補足(失敗の知らせなど) */ + children?: ReactNode; +} + +/** + * 取り消せない操作の前に、実行してよいかを確かめるダイアログ(Base UI の AlertDialog。role="alertdialog")。 + * 開くとフォーカスは「キャンセル」に置き、Enter の押し間違いで実行しない。外側を押しても閉じない(Esc とキャンセルで閉じる)。 + */ +export function AlertDialog({ + title, + description, + confirmLabel, + cancelLabel = 'キャンセル', + tone = 'danger', + onConfirm, + trigger, + open: openProp, + defaultOpen = false, + onOpenChange, + className, + children, +}: AlertDialogProps) { + const [uncontrolled, setUncontrolled] = useState(defaultOpen); + const [pending, setPending] = useState(false); + const cancelRef = useRef(null); + const open = openProp ?? uncontrolled; + const setOpen = (next: boolean) => { + // 実行中は閉じない(結果が分からないまま画面が戻るのを防ぐ) + if (pending && !next) return; + if (openProp === undefined) setUncontrolled(next); + onOpenChange?.(next); + }; + const confirm = async () => { + setPending(true); + try { + await onConfirm(); + setPending(false); + if (openProp === undefined) setUncontrolled(false); + onOpenChange?.(false); + } catch { + // 失敗は呼び出し側が children で知らせる。開いたままにして、もう一度試せるようにする + setPending(false); + } + }; + const s = alertDialogVariants(); + return ( + + {trigger && } + + + + {title} + {description && ( + {description} + )} + {children} +
+ }> + {cancelLabel} + + +
+
+
+
+ ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 1d75cd32..f9cd6692 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -3,6 +3,7 @@ * スタイルは別エントリ: Tailwind を使うなら 8341-ui/theme.css、使わないなら 8341-ui/styles.css。 */ export * from './components/ui/Accordion/index'; +export * from './components/ui/AlertDialog/index'; export * from './components/ui/Breadcrumb/index'; export * from './components/ui/Button/index'; export * from './components/ui/Card/index';