diff --git a/.changeset/checkbox-indicator-center.md b/.changeset/checkbox-indicator-center.md new file mode 100644 index 000000000..4984957d3 --- /dev/null +++ b/.changeset/checkbox-indicator-center.md @@ -0,0 +1,5 @@ +--- +'8341-ui': patch +--- + +Checkbox の選択時のチェック(と一部選択の横線)が枠の中心から下にずれていたのを直しました。 diff --git a/.changeset/yorozuya-components.md b/.changeset/yorozuya-components.md new file mode 100644 index 000000000..f5ad9981b --- /dev/null +++ b/.changeset/yorozuya-components.md @@ -0,0 +1,11 @@ +--- +'8341-ui': patch +--- + +コンポーネントを 5 つ追加しました(yorozuya で使っていた部品を、8341-ui のトークンと Base UI で作り直したもの)。 + +- `Switch`: 押した瞬間に反映するオン / オフ(Base UI の Switch。`npx shadcn add @8341/switch`) +- `Dialog`: 画面に重ねて入力させるダイアログ。`placement="side"` で先頭側からせり出し、裏の画面も操作できる(`@8341/dialog`) +- `Spinner`: 読み込みや保存の最中を `role="status"` で伝える(`@8341/spinner`) +- `Skeleton`: 読み込み中に中身の形だけを先に見せる(`@8341/skeleton`) +- `Truncate`: 1 行に収まらない文字を省略し、ホバーとフォーカスで全文を出す(`@8341/truncate`) diff --git a/DESIGN.md b/DESIGN.md index 9dd79ca56..97a3ec7b9 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -160,6 +160,7 @@ DTCG では `{"colorSpace": "oklch", "components": [L, C, H], "hex": "#..."}` - `Button`(`@8341/button`)— `packages/ui/src/components/ui/Button/` - `Card`(`@8341/card`)— `packages/ui/src/components/ui/Card/` - `Checkbox`(`@8341/checkbox`)— `packages/ui/src/components/ui/Checkbox/` +- `Dialog`(`@8341/dialog`)— `packages/ui/src/components/ui/Dialog/` - `Divider`(`@8341/divider`)— `packages/ui/src/components/ui/Divider/` - `FormField`(`@8341/form-field`)— `packages/ui/src/components/ui/FormField/` - `Heading`(`@8341/heading`)— `packages/ui/src/components/ui/Heading/` @@ -168,11 +169,15 @@ DTCG では `{"colorSpace": "oklch", "components": [L, C, H], "hex": "#..."}` - `NotificationBanner`(`@8341/notification-banner`)— `packages/ui/src/components/ui/NotificationBanner/` - `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/` +- `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/` - `Tag`(`@8341/tag`)— `packages/ui/src/components/ui/Tag/` - `Text`(`@8341/text`)— `packages/ui/src/components/ui/Text/` - `TextField`(`@8341/text-field`)— `packages/ui/src/components/ui/TextField/` - `Textarea`(`@8341/textarea`)— `packages/ui/src/components/ui/Textarea/` +- `Truncate`(`@8341/truncate`)— `packages/ui/src/components/ui/Truncate/` ### 共通規約 @@ -184,7 +189,7 @@ DTCG では `{"colorSpace": "oklch", "components": [L, C, H], "hex": "#..."}` - 見た目が 44px 未満の操作対象には `tap-target` クラスでタップ領域を足す - `hover:` は Tailwind v4 では自動で `@media (hover: hover)` に入る。無効状態に効かせないよう `enabled:hover:` を使う - 強制カラーモードは `forced-colors:` で補う -- `'use client'` はフック・コンテキスト・イベントハンドラを作るエントリだけに付ける(button / form-field / text-field / textarea / select) +- `'use client'` はフック・コンテキスト・イベントハンドラを作るエントリだけに付ける(button / form-field / text-field / textarea / select / truncate) ### Button @@ -245,6 +250,36 @@ DTCG では `{"colorSpace": "oklch", "components": [L, C, H], "hex": "#..."}` - 見出しの先頭に種別(「エラー: 」など)を視覚的に隠して読ませる - 動的に挿入するときだけ `live`(danger は `role="alert"`、他は `role="status"`) +### Switch + +- Base UI の Switch(`role="switch"`)。`label` 必須、`description` で補足。`size`: `md`(トラック 60×36px)/ `sm`(40×24px)。ラベル全体がタップ領域で行の高さ 44px を確保 +- 押した瞬間に反映する設定に使う。送信して反映する選択(同意など)は Checkbox +- オフ: `border-border-input` の枠と同色のつまみ / オン: `bg-key` のトラックに `bg-surface` のつまみとチェック(色だけで伝えない) + +### Dialog + +```tsx +経歴を追加する} + footer={<>}> + … + +``` + +- Base UI の Dialog。`title` 必須(名前)、`description`(説明)。開くとフォーカスが中に移り、Esc か「閉じる」(`closeLabel`)で閉じて開いたボタンに戻る +- `open` / `onOpenChange` で開閉を管理するときは `trigger` を省く +- `placement`: `center`(既定。`bg-overlay` の背面、`rounded-xl`、最大幅 `max-w-prose`、本文だけスクロール)/ `side`(先頭側からせり出す。裏の画面も操作でき、フォーカスだけ閉じ込める) +- 取り消せない操作の確認には使わない(AlertDialog はバックログ) + +### Spinner / Skeleton + +- **Spinner**: `role="status"` の中に `label`(必須)を文字で持つ。既定は視覚的に隠し、`showLabel` で表示。`size`: `sm` / `md` / `lg`(アイコンの寸法)。`delay` で 300ms 遅れて表示。ボタンの中は Button の `loading` +- **Skeleton**: `aria-hidden` の `bg-muted` の箱(`animate-pulse`、動きを減らす設定では止まる)。`width` / `height`(既定 100% / 1em)、`radius`: `none` / `xs`(既定)/ `md` / `xl` / `full`。待っている領域に `aria-busy` を立て、状態は Spinner で伝える + +### Truncate + +- 1 行に収まらない文字を … で省略する(`children` は文字列)。省略されたときだけ Tab で止まり、ホバーかフォーカスで全文を Base UI の Tooltip(`bg-inverse`)で出す +- 読み上げは常に全文。本文には使わず、表の列や一覧の項目名など幅が限られる場所に使う + ### Card - `border-border`、`rounded-xl`、内側余白 `p-inset-xl` diff --git a/README.md b/README.md index 50f0e7015..f1c213fbd 100644 --- a/README.md +++ b/README.md @@ -122,15 +122,16 @@ Tailwind を使わない場合は、ビルド済みの `import '8341-ui/styles.c ## コンポーネント -ヘッドレス UI の [Base UI](https://base-ui.com/) に、トークンの見た目を載せた 18 種類です。使い分けの規約は [04 コンポーネント](./docs/rulebook/04-components.md)、動く見本は [Storybook](https://tkc310.github.io/8341-ui/) にあります。 +ヘッドレス UI の [Base UI](https://base-ui.com/) に、トークンの見た目を載せた 23 種類です。使い分けの規約は [04 コンポーネント](./docs/rulebook/04-components.md)、動く見本は [Storybook](https://tkc310.github.io/8341-ui/) にあります。 | 種類 | コンポーネント | | --- | --- | | 操作 | `Button` · `Link` | -| フォーム | `FormField` · `TextField` · `Textarea` · `Select` · `Checkbox` · `RadioGroup` | -| 表示 | `Heading` · `Text` · `Tag` · `Icon` · `Card` · `Divider` · `Stack` | +| フォーム | `FormField` · `TextField` · `Textarea` · `Select` · `Checkbox` · `RadioGroup` · `Switch` | +| 表示 | `Heading` · `Text` · `Truncate` · `Tag` · `Icon` · `Card` · `Divider` · `Stack` | | ナビゲーション | `Breadcrumb` · `Accordion` | -| フィードバック | `NotificationBanner` | +| フィードバック | `NotificationBanner` · `Spinner` · `Skeleton` | +| オーバーレイ | `Dialog` | ## 技術スタック diff --git a/docs/improvement/backlog.md b/docs/improvement/backlog.md index 5de1da4f0..ec031edbc 100644 --- a/docs/improvement/backlog.md +++ b/docs/improvement/backlog.md @@ -16,11 +16,11 @@ ## コンポーネント - [ ] Table(ソート・横スクロール時の見出し固定、識別子の等幅)— 特化ルール「管理画面」 -- [ ] Dialog / AlertDialog(Base UI)— 原則「誤りと撤回」の確認の段 +- [ ] AlertDialog(Base UI。取り消せない操作の確認)— 原則「誤りと撤回」の確認の段。入力用の Dialog は提供済み - [ ] EmptyState(「データが無い」「条件に一致しない」「権限がない」の書き分け)— 特化ルール「管理画面」 -- [ ] Progress / Toast(Base UI)/ Skeleton — 原則「状態と応答」 -- [ ] Tooltip(Base UI。1.4.13 の解除・ホバー・持続を満たすか確認)・Combobox — 原則「再認」 -- [ ] Tabs・Switch・Menu(Base UI) +- [ ] Progress / Toast(Base UI)— 原則「状態と応答」 +- [ ] 汎用の Tooltip(Base UI。1.4.13 の解除・ホバー・持続を満たすか確認)・Combobox — 原則「再認」。Truncate は省略した全文の表示だけに使う +- [ ] Tabs・Menu(Base UI) - [ ] SkipLink(本文へ移動)— WCAG 2.4.1 - [ ] ErrorSummary(送信時のエラー一覧と最初の誤りへのフォーカス移動。現在は NotificationBanner の組み合わせで代用)— WCAG 3.3.1 - [ ] Pagination diff --git a/docs/improvement/log.md b/docs/improvement/log.md index 12bfe48de..b4e23b31a 100644 --- a/docs/improvement/log.md +++ b/docs/improvement/log.md @@ -4,6 +4,8 @@ | 日付 | 種別 | 内容 | 再発防止 | | ---------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| 2026-10-20 | 不具合 | Checkbox のチェックのアイコンが枠の中心から下にずれていた。Indicator(span)が本文の行の高さを引き継いだ行ボックスになり、`align-middle` の SVG がベースライン基準で置かれていたため。Indicator を flex にして行ボックスを作らない | 単体テストでアイコンが flex の中心に置かれていることを検査 | +| 2026-10-20 | 部品 | yorozuya で使っていた Switch・Dialog・Spinner・Skeleton・Truncate を、Base UI と semantic トークンで作り直して追加。Spinner は文言をライブリージョンの中の文字で持ち、Truncate は省略時だけフォーカスできるようにした | Switch のつまみとトラックの組み合わせを `contrast.rules.json` に追加。各部品の単体テスト(キーボード操作・名前・状態)とストーリーの play 関数 | | 2026-10-19 | 文書 | principles.md が参照する外部の文書の要約を `docs/design-principles/sources/` に追加。読めなくなった Jakob's Law のリンクを Nielsen 本人の解説に差し替え | 要約に原文・確認日・経緯を記録し、リンク切れ時の手順を sources/README.md に記載 | | 2026-10-19 | 構成 | コンポーネント構造規則(yorozuya)を取り込み、`src/components/ui//index.tsx` に本体・テスト・ストーリー・フックを置く形に変更。テストをコンポーネントごとに分け、useFormField を `FormField/useFormField.ts` に分けた | ADR-0016。`scripts/check-structure.ts` と dependency-cruiser で構造と import を検査 | | 2026-10-19 | 文書 | アイコン画像(ICO・PNG。ホーム画面用と 1024px)を追加し、Storybook のファビコンとホーム画面のアイコンに設定 | 書き出し方と差し替えを `docs/assets/README.md` に記載。manager-head.html を置くと SVG のファビコンが自動で入らなくなるため明示し、e2e(`e2e/docs.spec.ts`)でリンクを検査 | diff --git a/docs/rulebook/01-principles.md b/docs/rulebook/01-principles.md index 7392058e9..7c525cf17 100644 --- a/docs/rulebook/01-principles.md +++ b/docs/rulebook/01-principles.md @@ -28,9 +28,9 @@ UI の方向性とサービス種別は、8341-ui を使う各プロダクトの | 操作の幾何 | 最小タップ領域 `size.target-min` 44px(design-principles の必須 24px より厳しい)。Button sm は擬似要素で 44px を確保 | 続けて押す操作の配置 | | 一度に出す数 | RadioGroup(2〜6 個)と Select(7 個以上)の使い分け規約 | ページ分割、絞り込み(バックログ) | | 一貫性 | semantic カラーで色の意味を固定、Button の variant、アイコンセットを線の描き方で統一 | ナビゲーション部品(バックログ) | -| 状態と応答 | Button の `loading`(`aria-busy`)、NotificationBanner の `live`、Breadcrumb の `aria-current` | 進捗表示、トースト、スケルトン(バックログ) | -| 誤りと撤回 | FormField(Base UI の Field)の補足とエラー、Base UI の Form による送信時の検証とフォーカス移動、`validationMode="onBlur"`、送信ボタンを無効化しない規約、danger ボタン | 確認ダイアログ(Base UI の AlertDialog)、エラー一覧(バックログ) | -| 再認 | Select、RadioGroup、アイコン単体利用時の `label` 必須 | コンボボックス・ツールチップ(Base UI にあり、バックログ) | +| 状態と応答 | Button の `loading`(`aria-busy`)、NotificationBanner の `live`、Breadcrumb の `aria-current`、Spinner(`role="status"`)、Skeleton、Switch(押した瞬間に反映) | 進捗表示(Progress)、トースト(バックログ) | +| 誤りと撤回 | FormField(Base UI の Field)の補足とエラー、Base UI の Form による送信時の検証とフォーカス移動、`validationMode="onBlur"`、送信ボタンを無効化しない規約、danger ボタン | 確認ダイアログ(Base UI の AlertDialog)、エラー一覧(バックログ)。入力用の Dialog は提供済み | +| 再認 | Select、RadioGroup、アイコン単体利用時の `label` 必須、Truncate(省略した全文をホバーとフォーカスで出す) | コンボボックス・汎用のツールチップ(Base UI にあり、バックログ) | | 文言 | [05 文言](./05-writing.md)、`label` / `legend` を必須 props にする契約 | — | | 削る | 影は 3 段階・ボタンに使わない、`prefers-reduced-motion` で動きを止める | 画面ごとの要素の取捨 | | 助けの場所 | — | ヘッダーの配置(プロダクト側) | diff --git a/docs/rulebook/02-accessibility.md b/docs/rulebook/02-accessibility.md index 3cb2e7055..eb0876e7e 100644 --- a/docs/rulebook/02-accessibility.md +++ b/docs/rulebook/02-accessibility.md @@ -32,9 +32,12 @@ | Button | Tab で移動、Enter / Space で実行 | | Link | Tab で移動、Enter で遷移 | | Checkbox | Space で切り替え | +| Switch | Space で切り替え(Base UI。`role="switch"`) | | RadioGroup | Tab でグループに入り、矢印キーで選択(Base UI) | | Accordion | Enter / Space で開閉(Base UI。見出しの中のボタン) | | Select | Enter / Space で開き、矢印キーで移動、Enter で決定、Esc で閉じる(Base UI) | +| Dialog | 開くとフォーカスが中に移り、Tab は中で循環。Esc か「閉じる」で閉じ、開いたボタンに戻る(Base UI) | +| Truncate | 省略されたときだけ Tab で止まり、フォーカス中は全文のツールチップを出す | 独自のキーボード操作(ロービングタブインデックスなど)が必要な部品を作る場合は、 [WAI-ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/) のパターンに従い、ADR を書きます。 diff --git a/docs/rulebook/04-components.md b/docs/rulebook/04-components.md index 8e5cc0655..0a58a49bf 100644 --- a/docs/rulebook/04-components.md +++ b/docs/rulebook/04-components.md @@ -72,9 +72,14 @@ packages/ui/src/components/ui/Button/ | ページを移動する | Link(カード全体なら Card の `href`) | Button | | 2〜6 個から 1 つ選ぶ | RadioGroup | Select | | 7 個以上から 1 つ選ぶ | Select | RadioGroup | -| オン / オフ、複数選択 | Checkbox | トグルスイッチ(未提供) | +| 送信して反映するオン / オフ、複数選択 | Checkbox | Switch | +| 押した瞬間に反映する設定 | Switch | Checkbox | | ページ上部の重要なお知らせ | NotificationBanner | Tag | | 一覧の項目の状態 | Tag(アイコン付き) | 色だけの点 | | よくある質問など折りたたみ | Accordion + AccordionItem | 独自のディスクロージャ | | 要素の縦横の間隔 | Stack | 個別の margin | +| 画面に重ねて入力・確認させる | Dialog(`title` 必須) | 独自のモーダル | +| 読み込み・保存の最中 | Spinner(ボタンの中は Button の `loading`) | 文言の無い回転アイコン | +| 中身の形が分かる読み込み | Skeleton + 領域の `aria-busy` | 空白のまま待たせる | +| 幅が限られた 1 行の文字 | Truncate(表の列・一覧の項目名) | 本文の省略 | | フォームの送信と検証 | Base UI の Form + FormField | 独自の検証の仕組み | diff --git a/packages/tokens/src/contrast.rules.json b/packages/tokens/src/contrast.rules.json index a04271e1c..eef11c5b7 100644 --- a/packages/tokens/src/contrast.rules.json +++ b/packages/tokens/src/contrast.rules.json @@ -117,6 +117,18 @@ "min": 3, "wcag": "1.4.11" }, + { + "id": "switch-thumb", + "$description": "Switch のつまみとトラック。オフのつまみは枠線の色、オンのつまみは面の色を key の上に置く(ホバー時を含む)", + "pairs": [ + ["color.border.input", "color.bg.muted"], + ["color.border.strong", "color.bg.muted"], + ["color.bg.surface", "color.bg.key"], + ["color.bg.surface", "color.bg.key-hover"] + ], + "min": 3, + "wcag": "1.4.11" + }, { "id": "key-bg-as-boundary", "fg": ["color.bg.key", "color.bg.danger"], diff --git a/packages/ui/registry.json b/packages/ui/registry.json index ef53ef51f..f892a27d6 100644 --- a/packages/ui/registry.json +++ b/packages/ui/registry.json @@ -156,6 +156,25 @@ } ] }, + { + "name": "dialog", + "type": "registry:ui", + "title": "Dialog", + "description": "画面の上に重ねて表示するダイアログ(Base UI の Dialog)。", + "dependencies": [ + "@base-ui/react@^1.9.0" + ], + "registryDependencies": [ + "@8341/button", + "@8341/tv" + ], + "files": [ + { + "path": "src/components/ui/Dialog/index.tsx", + "type": "registry:ui" + } + ] + }, { "name": "divider", "type": "registry:ui", @@ -305,6 +324,38 @@ } ] }, + { + "name": "skeleton", + "type": "registry:ui", + "title": "Skeleton", + "description": "読み込み中に、中身の形だけを先に見せる箱。", + "dependencies": [], + "registryDependencies": [ + "@8341/tv" + ], + "files": [ + { + "path": "src/components/ui/Skeleton/index.tsx", + "type": "registry:ui" + } + ] + }, + { + "name": "spinner", + "type": "registry:ui", + "title": "Spinner", + "description": "読み込みや保存の最中であることを示すスピナー。", + "dependencies": [], + "registryDependencies": [ + "@8341/tv" + ], + "files": [ + { + "path": "src/components/ui/Spinner/index.tsx", + "type": "registry:ui" + } + ] + }, { "name": "stack", "type": "registry:ui", @@ -321,6 +372,26 @@ } ] }, + { + "name": "switch", + "type": "registry:ui", + "title": "Switch", + "description": "オン / オフを切り替えるスイッチ(Base UI の Switch)。", + "dependencies": [ + "@base-ui/react@^1.9.0" + ], + "registryDependencies": [ + "@8341/icon", + "@8341/theme", + "@8341/tv" + ], + "files": [ + { + "path": "src/components/ui/Switch/index.tsx", + "type": "registry:ui" + } + ] + }, { "name": "tag", "type": "registry:ui", @@ -390,6 +461,25 @@ "type": "registry:ui" } ] + }, + { + "name": "truncate", + "type": "registry:ui", + "title": "Truncate", + "description": "1 行に収まらない文字を … で省略し、マウスを乗せるかフォーカスすると全文をツールチップで出す。", + "dependencies": [ + "@base-ui/react@^1.9.0" + ], + "registryDependencies": [ + "@8341/theme", + "@8341/tv" + ], + "files": [ + { + "path": "src/components/ui/Truncate/index.tsx", + "type": "registry:ui" + } + ] } ] } diff --git a/packages/ui/src/components/ui/Checkbox/index.test.tsx b/packages/ui/src/components/ui/Checkbox/index.test.tsx index 95cc5a013..4568ffa69 100644 --- a/packages/ui/src/components/ui/Checkbox/index.test.tsx +++ b/packages/ui/src/components/ui/Checkbox/index.test.tsx @@ -9,4 +9,12 @@ describe('Checkbox', () => { expect(screen.getByRole('checkbox', { name: 'すべて' })).toHaveAttribute('aria-checked', 'mixed'); await expectNoA11yViolations(container); }); + + it('チェックのアイコンを行ボックスに乗せず、枠の中心に置く', () => { + const { container } = render(); + const icon = container.querySelector('svg'); + // インライン要素の中に置くと本文の行の高さを引き継ぎ、アイコンが中心から下にずれる(happy-dom では位置を測れないのでクラスで確かめる) + expect(icon).toHaveClass('block'); + expect(icon?.parentElement).toHaveClass('flex', 'items-center', 'justify-center'); + }); }); diff --git a/packages/ui/src/components/ui/Checkbox/index.tsx b/packages/ui/src/components/ui/Checkbox/index.tsx index c89b5c017..4bbc8c4b1 100644 --- a/packages/ui/src/components/ui/Checkbox/index.tsx +++ b/packages/ui/src/components/ui/Checkbox/index.tsx @@ -53,8 +53,12 @@ export function Checkbox({ label, description, size, className, ...rest }: Check })} {...rest} > - - + {/* + flex にして行ボックスを作らない。インライン要素のままだと本文の行の高さ(16px × 1.7)を引き継ぎ、 + align-middle のアイコンがベースライン基準で置かれて枠の中心から下にずれる + */} + + diff --git a/packages/ui/src/components/ui/Dialog/index.stories.tsx b/packages/ui/src/components/ui/Dialog/index.stories.tsx new file mode 100644 index 000000000..05ae8a05c --- /dev/null +++ b/packages/ui/src/components/ui/Dialog/index.stories.tsx @@ -0,0 +1,101 @@ +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 { FormField } from '@/components/ui/FormField/index'; +import { TextField } from '@/components/ui/TextField/index'; +import { Dialog } from './index'; + +const meta = { + title: 'Components/Dialog', + component: Dialog, + args: { + title: '経歴を追加', + description: '保存するまで一覧には反映されません', + trigger: , + children: null, + }, + argTypes: { + placement: { control: 'inline-radio', options: ['center', 'side'] }, + }, + parameters: { + docs: { + description: { + component: + '画面の上に重ねて表示するダイアログ(Base UI の Dialog)。開くとフォーカスが中に移り、Esc か「閉じる」で閉じると開いたボタンに戻る。見出し(title)は必須。placement="side" は先頭側からせり出し、裏の画面も操作できる。', + }, + }, + }, + render: (args) => ( + + + + + } + > + + + + + + + + ), +} 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('dialog', { name: '経歴を追加' }); + // 開くときは不透明度 0 から遷移するので、見えるまで待つ + await waitFor(() => expect(dialog).toBeVisible()); + await expect(within(dialog).getByRole('textbox', { name: /会社名/ })).toBeInTheDocument(); + // Esc で閉じ、フォーカスは開いたボタンに戻る + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(body.queryByRole('dialog')).toBeNull()); + await expect(trigger).toHaveFocus(); + }, +}; + +export const Side: Story = { + args: { placement: 'side', title: '経歴を編集', trigger: }, +}; + +function Controlled() { + const [open, setOpen] = useState(false); + return ( + <> + + setOpen(false)}>同意する} + > +

本文が長い場合は、ダイアログの中だけがスクロールします。

+ {Array.from({ length: 12 }, (_, i) => ( +

+ 第 {i + 1} 条 サービスの利用にあたっては、この規約のすべての条項に同意したものとみなします。 +

+ ))} +
+ + ); +} + +/** open / onOpenChange で開閉を管理する例。本文が長いときは本文だけがスクロールする */ +export const ControlledLongContent: Story = { + args: { title: '利用規約', trigger: undefined }, + render: () => , +}; diff --git a/packages/ui/src/components/ui/Dialog/index.test.tsx b/packages/ui/src/components/ui/Dialog/index.test.tsx new file mode 100644 index 000000000..cc96e96fd --- /dev/null +++ b/packages/ui/src/components/ui/Dialog/index.test.tsx @@ -0,0 +1,57 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vite-plus/test'; +import { Button } from '@/components/ui/Button/index'; +import { expectNoA11yViolations } from '@/test/a11y'; +import { Dialog } from './index'; + +describe('Dialog', () => { + it('見出しと補足でダイアログに名前と説明が付く', async () => { + render( + {}}> +

本文

+
, + ); + const dialog = screen.getByRole('dialog', { name: '経歴を編集' }); + expect(dialog).toHaveAccessibleDescription('変更は保存するまで反映されません'); + await expectNoA11yViolations(dialog); + }); + + it('開くボタンで開き、閉じるボタンで閉じてフォーカスを開くボタンに戻す', async () => { + const user = userEvent.setup(); + render( + 経歴を追加する} footer={}> +

本文

+
, + ); + const trigger = screen.getByRole('button', { name: '経歴を追加する' }); + await user.click(trigger); + const dialog = await screen.findByRole('dialog', { name: '経歴を追加' }); + expect(dialog).toContainElement(screen.getByRole('button', { name: '保存する' })); + await user.click(screen.getByRole('button', { name: '閉じる' })); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(trigger).toHaveFocus(); + }); + + it('Esc で閉じる', async () => { + const user = userEvent.setup(); + render( + 経歴を追加する}> +

本文

+
, + ); + await user.click(screen.getByRole('button', { name: '経歴を追加する' })); + await screen.findByRole('dialog'); + await user.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + }); + + it('閉じるボタンの文言を変えられる', () => { + render( + {}}> +

body

+
, + ); + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/ui/Dialog/index.tsx b/packages/ui/src/components/ui/Dialog/index.tsx new file mode 100644 index 000000000..4e332581e --- /dev/null +++ b/packages/ui/src/components/ui/Dialog/index.tsx @@ -0,0 +1,109 @@ +import { Dialog as BaseDialog } from '@base-ui/react/dialog'; +import type { ComponentProps, ReactElement, ReactNode } from 'react'; +import { Button } from '@/components/ui/Button/index'; +import { type VariantProps, tv } from '@/lib/tv'; + +export const dialogVariants = 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 z-(--8341-z-modal) flex flex-col overflow-hidden bg-surface text-fg shadow-3', + 'transition-[opacity,scale,translate] duration-(--8341-motion-duration-base) ease-standard', + 'forced-colors:border forced-colors:border-[CanvasText]', + ], + header: + 'flex shrink-0 items-start justify-between gap-gap-md border-b border-border-subtle px-inset-xl py-inset-lg', + heading: 'flex min-w-0 flex-col gap-gap-xs', + title: 'm-0 text-heading-xs', + description: 'm-0 text-body-sm text-fg-subtle', + // 見出しが長くても閉じるボタンを潰さない + close: 'shrink-0', + body: 'flex min-h-0 flex-1 flex-col gap-stack-md overflow-y-auto p-inset-xl', + footer: 'flex shrink-0 flex-wrap justify-end gap-gap-sm border-t border-border-subtle px-inset-xl py-inset-lg', + }, + variants: { + /** + * - center: 画面の中央に重ね、裏の画面は操作できない(既定) + * - side: 先頭側(左)からせり出し、裏の画面も操作できる。プレビューを見ながら入力する場合など + */ + placement: { + center: { + popup: [ + 'top-1/2 left-1/2 -translate-1/2 rounded-xl', + 'max-h-[calc(100dvh-var(--8341-space-page-inline)*2)] w-[calc(100vw-var(--8341-space-page-inline)*2)] max-w-prose', + 'data-[ending-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:scale-95 data-[starting-style]:opacity-0', + ], + }, + side: { + popup: [ + 'inset-y-0 start-0 h-dvh w-full border-e border-border md:w-1/2', + 'data-[ending-style]:-translate-x-full data-[starting-style]:-translate-x-full', + 'rtl:data-[ending-style]:translate-x-full rtl:data-[starting-style]:translate-x-full', + ], + }, + }, + }, + defaultVariants: { placement: 'center' }, +}); + +export interface DialogProps + extends Omit, 'children' | 'modal'>, VariantProps { + /** 見出し(必須)。ダイアログの名前として読み上げられる */ + title: ReactNode; + /** 見出しの下の補足。ダイアログの説明として読み上げられる */ + description?: ReactNode; + /** 下部の操作(Button を右寄せで並べる。主要な操作を末尾に置く) */ + footer?: ReactNode; + /** 開くボタン(Button など)。open で開閉を管理する場合は省略できる */ + trigger?: ReactElement; + /** 閉じるボタンの文言 */ + closeLabel?: string; + className?: string; + children: ReactNode; +} + +/** + * 画面の上に重ねて表示するダイアログ(Base UI の Dialog)。開くとフォーカスが中に移り、閉じると元のボタンに戻る。 + * Esc と「閉じる」ボタンで閉じられる。確認だけなら NotificationBanner など画面の中で済ませられないかを先に検討する。 + */ +export function Dialog({ + title, + description, + footer, + trigger, + placement = 'center', + closeLabel = '閉じる', + className, + children, + ...rest +}: DialogProps) { + const side = placement === 'side'; + const s = dialogVariants({ placement }); + return ( + // side は裏の画面を操作できるよう、フォーカスだけを閉じ込め、外側を押しても閉じない + + {trigger && } + + {!side && } + +
+
+ {title} + {description && ( + {description} + )} +
+ }> + {closeLabel} + +
+
{children}
+ {footer &&
{footer}
} +
+
+
+ ); +} diff --git a/packages/ui/src/components/ui/FormField/index.stories.tsx b/packages/ui/src/components/ui/FormField/index.stories.tsx index 99c385db7..3a460fad5 100644 --- a/packages/ui/src/components/ui/FormField/index.stories.tsx +++ b/packages/ui/src/components/ui/FormField/index.stories.tsx @@ -8,6 +8,7 @@ import { NotificationBanner } from '@/components/ui/NotificationBanner/index'; import { Radio, RadioGroup } from '@/components/ui/RadioGroup/index'; import { Select } from '@/components/ui/Select/index'; import { Stack } from '@/components/ui/Stack/index'; +import { Switch } from '@/components/ui/Switch/index'; import { TextField } from '@/components/ui/TextField/index'; import { Textarea } from '@/components/ui/Textarea/index'; import { FormField } from './index'; @@ -142,6 +143,34 @@ export const Radios: Story = { ), }; +/** + * スイッチ(Base UI の Switch)。押した瞬間に反映する設定に使い、送信して反映する選択(同意など)は Checkbox を使う。 + * オンでは色に加えてつまみにチェックを出す。size は md / sm + */ +export const Switches: Story = { + args: { label: '', children: null }, + render: () => ( + + + + + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const control = canvas.getByRole('switch', { name: '新着の通知を受け取る' }); + await expect(control).toHaveAttribute('aria-checked', 'false'); + await userEvent.click(canvas.getByText('新着の通知を受け取る')); + await expect(control).toHaveAttribute('aria-checked', 'true'); + // キーボードでも切り替えられる + control.focus(); + await userEvent.keyboard(' '); + await expect(control).toHaveAttribute('aria-checked', 'false'); + }, +}; + function ValidationForm() { const [submitted, setSubmitted] = useState(false); return ( diff --git a/packages/ui/src/components/ui/Skeleton/index.stories.tsx b/packages/ui/src/components/ui/Skeleton/index.stories.tsx new file mode 100644 index 000000000..5fe0fbb76 --- /dev/null +++ b/packages/ui/src/components/ui/Skeleton/index.stories.tsx @@ -0,0 +1,61 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect } from 'storybook/test'; +import { Spinner } from '@/components/ui/Spinner/index'; +import { Stack } from '@/components/ui/Stack/index'; +import { Skeleton } from './index'; + +const meta = { + title: 'Components/Skeleton', + component: Skeleton, + args: { width: '40%', height: '1.5em' }, + argTypes: { + radius: { control: 'inline-radio', options: ['none', 'xs', 'md', 'xl', 'full'] }, + }, + parameters: { + docs: { + description: { + component: + '読み込み中に、中身の形だけを先に見せる。読み上げには出さないので、読み込み中であることは Spinner(文言は隠してよい)や aria-busy で別に伝える。動きを減らす設定では点滅しない。', + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Text: Story = { + render: (args) => ( + + + + + + ), + play: async ({ canvasElement }) => { + await expect(canvasElement.querySelectorAll('[aria-hidden="true"]').length).toBe(3); + }, +}; + +/** カードの読み込み中。待っている領域に aria-busy を立て、状態は Spinner で読み上げに伝える */ +export const Card: Story = { + render: () => ( +
+ + + + + + + + + + +
+ ), +}; diff --git a/packages/ui/src/components/ui/Skeleton/index.test.tsx b/packages/ui/src/components/ui/Skeleton/index.test.tsx new file mode 100644 index 000000000..785234cd4 --- /dev/null +++ b/packages/ui/src/components/ui/Skeleton/index.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it } from 'vite-plus/test'; +import { Skeleton } from './index'; + +describe('Skeleton', () => { + it('読み上げには出さず、指定した寸法で描く', () => { + const { container } = render(); + const box = container.firstElementChild as HTMLElement; + expect(box).toHaveAttribute('aria-hidden', 'true'); + expect(box.style.width).toBe('40%'); + expect(box.style.height).toBe('2em'); + expect(box).toHaveClass('rounded-full'); + }); +}); diff --git a/packages/ui/src/components/ui/Skeleton/index.tsx b/packages/ui/src/components/ui/Skeleton/index.tsx new file mode 100644 index 000000000..99a63ed75 --- /dev/null +++ b/packages/ui/src/components/ui/Skeleton/index.tsx @@ -0,0 +1,44 @@ +import type { CSSProperties, HTMLAttributes } from 'react'; +import { type VariantProps, tv } from '@/lib/tv'; + +export const skeletonVariants = tv({ + base: [ + 'block max-w-full animate-pulse bg-muted motion-reduce:animate-none', + // 強制カラーでは背景が消えるので、枠線で形を残す + 'forced-colors:border forced-colors:border-[GrayText]', + ], + variants: { + /** 置き換える中身の角丸に合わせる(文字は xs、ボタン・入力欄は md、カードは xl、アイコン・顔写真は full) */ + radius: { + none: 'rounded-none', + xs: 'rounded-xs', + md: 'rounded-md', + xl: 'rounded-xl', + full: 'rounded-full', + }, + }, + defaultVariants: { radius: 'xs' }, +}); + +export interface SkeletonProps + extends Omit, 'children'>, VariantProps { + /** 幅。既定は親の幅いっぱい */ + width?: CSSProperties['width']; + /** 高さ。既定は 1 文字分(1em) */ + height?: CSSProperties['height']; +} + +/** + * 読み込み中に、中身の形だけを先に見せる箱。読み上げには出さない(aria-hidden)ので、 + * 読み込み中であることは Spinner や、待っている領域の aria-busy で別に伝える。 + */ +export function Skeleton({ width = '100%', height = '1em', radius, className, style, ...rest }: SkeletonProps) { + return ( +