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(true)}> + 利用規約を読む + + 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 ( + + ); +} diff --git a/packages/ui/src/components/ui/Spinner/index.stories.tsx b/packages/ui/src/components/ui/Spinner/index.stories.tsx new file mode 100644 index 000000000..2cdb531e9 --- /dev/null +++ b/packages/ui/src/components/ui/Spinner/index.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, within } from 'storybook/test'; +import { Stack } from '@/components/ui/Stack/index'; +import { Spinner } from './index'; + +const meta = { + title: 'Components/Spinner', + component: Spinner, + args: { label: '読み込んでいます' }, + argTypes: { + size: { control: 'inline-radio', options: ['sm', 'md', 'lg'] }, + }, + parameters: { + docs: { + description: { + component: + '読み込みや保存の最中であることを示す。role="status" の中に文言(label)を持つので、表示しない場合も読み上げには伝わる。ボタンの処理中は Button の loading を使う。', + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const IconOnly: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole('status')).toHaveTextContent('読み込んでいます'); + }, +}; + +export const WithLabel: Story = { + args: { label: '保存しています', showLabel: true, size: 'sm' }, +}; + +export const Sizes: Story = { + render: (args) => ( + + + + + + ), +}; + +/** 待ち時間が短いときにちらつかないよう、300ms 遅れて表示する */ +export const Delayed: Story = { + args: { delay: true, showLabel: true }, +}; diff --git a/packages/ui/src/components/ui/Spinner/index.test.tsx b/packages/ui/src/components/ui/Spinner/index.test.tsx new file mode 100644 index 000000000..8867c4d4b --- /dev/null +++ b/packages/ui/src/components/ui/Spinner/index.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vite-plus/test'; +import { expectNoA11yViolations } from '@/test/a11y'; +import { Spinner } from './index'; + +describe('Spinner', () => { + it('role="status" の中に文言を持ち、既定では視覚的に隠す', async () => { + const { container } = render(); + const status = screen.getByRole('status'); + expect(status).toHaveTextContent('読み込んでいます'); + expect(screen.getByText('読み込んでいます')).toHaveClass('sr-only'); + await expectNoA11yViolations(container); + }); + + it('showLabel で文言を画面にも表示する', () => { + render(); + expect(screen.getByText('保存しています')).not.toHaveClass('sr-only'); + }); +}); diff --git a/packages/ui/src/components/ui/Spinner/index.tsx b/packages/ui/src/components/ui/Spinner/index.tsx new file mode 100644 index 000000000..c79bd09bc --- /dev/null +++ b/packages/ui/src/components/ui/Spinner/index.tsx @@ -0,0 +1,43 @@ +import type { HTMLAttributes } from 'react'; +import { type VariantProps, tv } from '@/lib/tv'; + +export const spinnerVariants = tv({ + slots: { + root: 'inline-flex items-center gap-gap-sm text-body-sm text-fg-subtle', + // 強制カラーでは枠線の色が上書きされ、透明の欠けが消えてしまうので、色の調整を止める + ring: 'inline-block shrink-0 animate-spin rounded-full border-2 border-current border-r-transparent motion-reduce:animate-none forced-colors:forced-color-adjust-none', + }, + variants: { + size: { + sm: { ring: 'size-icon-sm' }, + md: { ring: 'size-icon-md' }, + lg: { ring: 'size-icon-lg' }, + }, + /** 待ち時間が短いときにちらつかないよう、少し遅れて表示する */ + delay: { true: { root: 'transition-opacity delay-300 starting:opacity-0' } }, + }, + defaultVariants: { size: 'md' }, +}); + +export interface SpinnerProps + extends Omit, 'children' | 'role'>, VariantProps { + /** 何を待っているか(必須)。「保存しています」のように書く */ + label: string; + /** label を画面にも表示する。表示しない場合も読み上げには伝わる */ + showLabel?: boolean; +} + +/** + * 読み込みや保存の最中であることを示すスピナー。role="status" で読み上げにも伝える。 + * ボタンの処理中は Button の loading を使う。中身の形が分かる読み込みは Skeleton を検討する。 + */ +export function Spinner({ label, showLabel = false, size, delay, className, ...rest }: SpinnerProps) { + const s = spinnerVariants({ size, delay }); + return ( + + + {/* ライブリージョンは中身の文字を読み上げるため、aria-label ではなく文字で持つ */} + {label} + + ); +} diff --git a/packages/ui/src/components/ui/Switch/index.test.tsx b/packages/ui/src/components/ui/Switch/index.test.tsx new file mode 100644 index 000000000..8d46042d6 --- /dev/null +++ b/packages/ui/src/components/ui/Switch/index.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vite-plus/test'; +import { expectNoA11yViolations } from '@/test/a11y'; +import { Switch } from './index'; + +describe('Switch', () => { + it('ラベルで名前が付き、role="switch" で状態を伝える', async () => { + const { container } = render(); + expect(screen.getByRole('switch', { name: '通知を受け取る' })).toHaveAttribute('aria-checked', 'true'); + await expectNoA11yViolations(container); + }); + + it('ラベルとスイッチのクリックで切り替わる', async () => { + const user = userEvent.setup(); + const onCheckedChange = vi.fn(); + render(); + const control = screen.getByRole('switch', { name: '自動保存' }); + await user.click(screen.getByText('自動保存')); + expect(control).toHaveAttribute('aria-checked', 'true'); + // キーボード(Space)での切り替えは、実ブラウザで動くストーリー(Components/Form の Switches)の play 関数で検査する(happy-dom は非ネイティブのボタンの Space を再現しない) + await user.click(control); + expect(control).toHaveAttribute('aria-checked', 'false'); + expect(onCheckedChange).toHaveBeenCalledTimes(2); + }); + + it('無効のときは切り替わらない', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByText('自動保存')); + expect(screen.getByRole('switch', { name: '自動保存' })).toHaveAttribute('aria-checked', 'false'); + }); +}); diff --git a/packages/ui/src/components/ui/Switch/index.tsx b/packages/ui/src/components/ui/Switch/index.tsx new file mode 100644 index 000000000..17a464c6c --- /dev/null +++ b/packages/ui/src/components/ui/Switch/index.tsx @@ -0,0 +1,89 @@ +import { Switch as BaseSwitch } from '@base-ui/react/switch'; +import type { ComponentProps, ReactNode } from 'react'; +import { Icon } from '@/components/ui/Icon/index'; +import { focusRing } from '@/lib/focus'; +import { type VariantProps, tv } from '@/lib/tv'; + +/** + * トラックの寸法はつまみの大きさ(--switch-thumb)と余白(--switch-pad)から決める。 + * 幅 = つまみ 2 個分 + 余白 + 枠線。オンでつまみ 1 個分だけ動く。 + */ +export const switchVariants = tv({ + slots: { + label: [ + 'grid w-fit cursor-pointer grid-cols-[auto_minmax(0,1fr)] items-center gap-x-gap-sm text-fg', + // ラベル全体がタップ領域になる。トラックの見た目は 44px 未満でも行の高さで 44px を確保する + 'min-h-target-min', + 'has-[[data-disabled]]:cursor-not-allowed has-[[data-disabled]]:text-fg-disabled', + ], + track: [ + 'group/switch relative inline-flex shrink-0 items-center rounded-full border-2 p-(--switch-pad)', + 'w-[calc(var(--switch-thumb)*2+var(--switch-pad)*2+var(--8341-border-width-strong)*2)]', + 'border-border-input bg-surface transition-colors', + 'hover:not-data-[disabled]:border-border-strong hover:not-data-[disabled]:bg-muted', + 'data-[checked]:border-key data-[checked]:bg-key', + 'data-[checked]:hover:not-data-[disabled]:border-key-hover data-[checked]:hover:not-data-[disabled]:bg-key-hover', + 'data-[invalid]:border-border-danger', + 'data-[disabled]:border-border-disabled data-[disabled]:bg-disabled', + 'data-[disabled]:data-[checked]:border-border-disabled data-[disabled]:data-[checked]:bg-disabled', + focusRing, + 'forced-colors:border-[ButtonText] forced-colors:data-[checked]:border-[Highlight] forced-colors:data-[disabled]:border-[GrayText]', + ], + thumb: [ + 'grid size-(--switch-thumb) place-items-center rounded-full transition-[translate,background-color]', + 'bg-(--8341-color-border-input) group-hover/switch:not-data-[disabled]:bg-(--8341-color-border-strong)', + // オンでは白抜きのつまみにチェックを出す(色だけで状態を伝えない) + 'data-[checked]:translate-x-(--switch-thumb) rtl:data-[checked]:-translate-x-(--switch-thumb)', + 'data-[checked]:bg-surface group-hover/switch:data-[checked]:not-data-[disabled]:bg-surface', + 'data-[disabled]:bg-(--8341-color-text-disabled) data-[disabled]:data-[checked]:bg-(--8341-color-text-disabled)', + 'forced-colors:bg-[ButtonText] forced-colors:data-[checked]:bg-[Highlight] forced-colors:data-[disabled]:bg-[GrayText]', + ], + icon: [ + 'hidden size-[75%] stroke-3 text-icon-key group-data-[checked]/switch:block', + 'group-data-[disabled]/switch:text-(--8341-color-bg-disabled) forced-colors:text-[Canvas]', + ], + text: 'col-start-2', + description: 'col-start-2 text-body-sm text-fg-subtle', + }, + variants: { + size: { + md: { + label: 'text-body-md [--switch-pad:var(--8341-space-inset-xs)] [--switch-thumb:var(--8341-size-icon-lg)]', + }, + sm: { + label: 'text-body-sm [--switch-pad:var(--8341-border-width-strong)] [--switch-thumb:var(--8341-size-icon-sm)]', + }, + }, + }, + defaultVariants: { size: 'md' }, +}); + +export interface SwitchProps + extends + Omit, 'className' | 'children' | 'render'>, + VariantProps { + /** ラベルは必須。ラベル全体がクリック領域になる */ + label: ReactNode; + /** ラベル下の補足 */ + description?: ReactNode; + className?: string; +} + +/** + * オン / オフを切り替えるスイッチ(Base UI の Switch)。押した瞬間に設定が反映されるものに使う。 + * 送信して初めて反映される選択(同意など)は Checkbox を使う。 + */ +export function Switch({ label, description, size, className, ...rest }: SwitchProps) { + const s = switchVariants({ size }); + return ( + + + + + + + {label} + {description && {description}} + + ); +} diff --git a/packages/ui/src/components/ui/Truncate/index.stories.tsx b/packages/ui/src/components/ui/Truncate/index.stories.tsx new file mode 100644 index 000000000..9950b5938 --- /dev/null +++ b/packages/ui/src/components/ui/Truncate/index.stories.tsx @@ -0,0 +1,61 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, userEvent, within } from 'storybook/test'; +import { Stack } from '@/components/ui/Stack/index'; +import { Truncate } from './index'; + +const long = 'AWS (EC2 / ECS(Fargate) / RDS / DynamoDB / ElastiCache / S3 / CloudFront / Lambda)'; + +const meta = { + title: 'Components/Truncate', + component: Truncate, + args: { children: long }, + decorators: [ + (Story) => ( + + + + ), + ], + parameters: { + docs: { + description: { + component: + '1 行に収まらない文字を … で省略し、マウスを乗せるかフォーカスすると全文をツールチップで出す。省略されたときだけフォーカスできる。読み上げでは常に全文が読まれる。本文には使わず、表の列や一覧の項目名など幅が限られる場所に使う。', + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const el = canvas.getByText(long); + await expect(el).toHaveAttribute('tabindex', '0'); + // キーボードでも全文を確かめられる + await userEvent.tab(); + await expect(el).toHaveFocus(); + await expect(await within(document.body).findAllByText(long)).toHaveLength(2); + }, +}; + +export const Fits: Story = { + args: { children: 'TypeScript' }, + play: async ({ canvasElement }) => { + await expect(within(canvasElement).getByText('TypeScript')).not.toHaveAttribute('tabindex'); + }, +}; + +export const InList: Story = { + render: () => ( + + {['React / TypeScript', long, 'Go (Echo / gRPC / sqlc / Testcontainers / OpenTelemetry / Cloud Run)'].map((t) => ( + + {t} + + ))} + + ), +}; diff --git a/packages/ui/src/components/ui/Truncate/index.test.tsx b/packages/ui/src/components/ui/Truncate/index.test.tsx new file mode 100644 index 000000000..a3996e5c0 --- /dev/null +++ b/packages/ui/src/components/ui/Truncate/index.test.tsx @@ -0,0 +1,47 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { expectNoA11yViolations } from '@/test/a11y'; +import { Truncate } from './index'; + +const text = 'AWS (EC2 / ECS / RDS / DynamoDB / ElastiCache / S3 / CloudFront / Lambda)'; + +/** happy-dom はレイアウトを計算しないので、はみ出しの有無を差し替える */ +function mockOverflow(overflowing: boolean) { + Object.defineProperty(HTMLElement.prototype, 'scrollWidth', { + configurable: true, + get: () => (overflowing ? 400 : 100), + }); + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => 100 }); +} + +describe('Truncate', () => { + afterEach(() => { + // 差し替えを外し、Element.prototype の本来の定義に戻す + Reflect.deleteProperty(HTMLElement.prototype, 'scrollWidth'); + Reflect.deleteProperty(HTMLElement.prototype, 'clientWidth'); + }); + + it('収まっているときはフォーカスできず、ツールチップも出さない', async () => { + mockOverflow(false); + const user = userEvent.setup(); + const { container } = render({text}); + const el = screen.getByText(text); + expect(el).not.toHaveAttribute('tabindex'); + await user.hover(el); + expect(screen.getAllByText(text)).toHaveLength(1); + await expectNoA11yViolations(container); + }); + + it('省略されたときはフォーカスでき、フォーカスすると全文を出す', async () => { + mockOverflow(true); + const user = userEvent.setup(); + render({text}); + const el = screen.getByText(text); + expect(el).toHaveAttribute('tabindex', '0'); + await user.tab(); + expect(el).toHaveFocus(); + // ツールチップは見た目の補助なので読み上げには出さない(全文は元の文字で読まれる) + expect(await screen.findByText(text, { ignore: '[data-base-ui-tooltip-trigger]' })).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/ui/Truncate/index.tsx b/packages/ui/src/components/ui/Truncate/index.tsx new file mode 100644 index 000000000..d3b6dfcc6 --- /dev/null +++ b/packages/ui/src/components/ui/Truncate/index.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip'; +import { useEffect, useState } from 'react'; +import { focusRing } from '@/lib/focus'; +import { tv } from '@/lib/tv'; + +export const truncateVariants = tv({ + slots: { + text: ['block min-w-0 truncate', focusRing], + positioner: 'z-(--8341-z-toast)', + popup: [ + 'max-w-[min(24rem,calc(100vw-var(--8341-space-page-inline)*2))] rounded-sm bg-inverse px-inset-sm py-inset-xs text-body-sm text-fg-inverse shadow-2', + 'origin-(--transform-origin) transition-opacity data-[ending-style]:opacity-0 data-[starting-style]:opacity-0', + 'forced-colors:border forced-colors:border-[CanvasText]', + ], + }, +}); + +export interface TruncateProps { + /** 表示する文字。省略されても読み上げでは全文が読まれる */ + children: string; + className?: string; +} + +/** + * 1 行に収まらない文字を … で省略し、マウスを乗せるかフォーカスすると全文をツールチップで出す。 + * 省略されたときだけフォーカスできるようにし、キーボードでも全文を確かめられるようにする。 + * 本文や大事な情報には使わない(折り返して全文を見せる)。表の列や一覧の項目名など、幅が限られる場所に使う。 + */ +export function Truncate({ children, className }: TruncateProps) { + const [element, setElement] = useState(null); + const [truncated, setTruncated] = useState(false); + + useEffect(() => { + if (!element) return undefined; + const update = () => setTruncated(element.scrollWidth > element.clientWidth); + update(); + if (typeof ResizeObserver === 'undefined') return undefined; + const observer = new ResizeObserver(update); + observer.observe(element); + return () => observer.disconnect(); + }, [element, children]); + + const s = truncateVariants(); + return ( + + } + delay={100} + closeDelay={0} + tabIndex={truncated ? 0 : undefined} + className={s.text({ className })} + > + {children} + + + + {/* 全文は省略された文字そのものが読み上げられるので、ツールチップは見た目だけの補助 */} + + {children} + + + + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 96fc84766..1d75cd32a 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -7,6 +7,7 @@ export * from './components/ui/Breadcrumb/index'; export * from './components/ui/Button/index'; export * from './components/ui/Card/index'; export * from './components/ui/Checkbox/index'; +export * from './components/ui/Dialog/index'; export * from './components/ui/Divider/index'; export * from './components/ui/FormField/index'; export * from './components/ui/Heading/index'; @@ -15,9 +16,13 @@ export * from './components/ui/Link/index'; 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/Spinner/index'; export * from './components/ui/Stack/index'; +export * from './components/ui/Switch/index'; export * from './components/ui/Tag/index'; export * from './components/ui/Text/index'; export * from './components/ui/TextField/index'; export * from './components/ui/Textarea/index'; +export * from './components/ui/Truncate/index'; export { cn, tv, type VariantProps } from './lib/tv';
本文が長い場合は、ダイアログの中だけがスクロールします。
+ 第 {i + 1} 条 サービスの利用にあたっては、この規約のすべての条項に同意したものとみなします。 +
本文
body