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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-01
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
## Context

The Mori component library has Button, Input, Card, Badge, Skeleton, Dialog, and Navigation, but no standard pattern for zero-data screens. Every time a ShiftMate or FaxBridge view renders an empty list (no shifts scheduled, no faxes received), the product team has to write one-off layouts. This produces inconsistent icon sizes, copy tone, and spacing across views. A shared `EmptyState` component fixes this by codifying the visual pattern once.

The existing `localisation-copy` spec already includes `empty.default` and `empty.search` keys; we extend it with `empty.description` to provide a generic fallback description.

## Goals / Non-Goals

**Goals:**
- Single `EmptyState` component covering all zero-data scenarios in both product repos
- Follows the existing Mori copy-paste component model (shadcn/ui pattern — products own the file)
- All design tokens referenced via CSS variables; no hardcoded values
- Locale-aware default copy via `useCopy()` (heading and description fallbacks)
- Optional Lucide icon slot with consistent sizing (48×48px, `--color-ink-tertiary`)
- Optional CTA using the existing `Button` component
- Vitest tests covering all prop combinations and locale fallbacks
- Showcase section demonstrating icon / no-icon and CTA / no-CTA variants

**Non-Goals:**
- Animated illustrations or Lottie files (MVP uses icons only)
- Context-specific copy built into the component (products pass their own heading/description)
- Full-page error states (separate concern; `EmptyState` is for zero-data, not error conditions)

## Decisions

### 1. Explicit `heading` prop vs. locale-derived default

**Decision:** `heading` is a required prop. Products always pass their own Japanese heading (e.g., `まだシフトがありません`). The component does NOT fall back to `empty.default` copy — that copy is used by product code directly when a generic message is acceptable.

**Rationale:** Empty-state messages are data-domain specific ("No shifts yet" vs. "No faxes received"). Baking a generic fallback into the component would encourage lazy, context-free copy. Requiring `heading` forces product teams to write meaningful messages at each call site.

**Alternative considered:** `heading` optional with `useCopy().empty.default` fallback. Rejected because it hides poor copy behind a reasonable-looking default; code review can't tell whether the heading was intentional.

---

### 2. Icon type — `LucideIcon` component vs. string name

**Decision:** Accept a Lucide icon component reference (`icon?: LucideIcon`) rather than a string icon name.

**Rationale:** Component references are tree-shakeable and type-safe. String names would require a runtime registry or dynamic import — unnecessary complexity for MVP.

**Alternative considered:** `iconName: string` with a Lucide dynamic import. Rejected: adds runtime complexity and breaks tree-shaking.

---

### 3. CTA — render `Button` internally vs. accept ReactNode

**Decision:** Accept a structured `action?: { label: string; onClick: () => void }` prop and render an internal `Button` with `variant="primary"` and `size="md"`.

**Rationale:** Constraining the CTA to a `Button` keeps the visual contract tight — no accidental link-style or ghost CTAs in empty states. Products that need exotic CTA behaviour can omit the prop and render their own button below the component.

**Alternative considered:** `action?: React.ReactNode` — maximum flexibility. Rejected for MVP because it opens the door to inconsistent styling.

---

### 4. `localisation-copy` extension — `empty.description`

**Decision:** Add `empty.description` to `MoriCopy` across all three locales, providing a generic "nothing here yet" message for contexts where a description is needed but no product-specific copy exists.

**Rationale:** The existing `empty.default` serves the heading role. A separate description key avoids overloading the same string in two UI positions with different typographic treatment.

## Risks / Trade-offs

- [Heading always required] → Product teams may pass empty strings to bypass the requirement. Mitigated by TypeScript `string` type (not `string | undefined`) which at least ensures intention, and code-review convention.
- [Fixed CTA variant] → A future design requirement may need a ghost or secondary CTA in empty states. Mitigation: the `action` prop struct can be extended with `variant` later without breaking the interface.
- [Single CTA only] → Some empty states may need two actions. Out of scope for MVP; product can render a second `Button` below the component.

## Migration Plan

No existing code to migrate — this is a net-new component. Products copy `src/components/ui/empty-state.tsx` into their own `components/ui/` directory per the standard Mori copy-paste model.

## Open Questions

None — all decisions are resolved above.
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
## Why

Every data view in ShiftMate and FaxBridge — shift lists, fax logs, search results — can return zero items, and there is currently no standard pattern for rendering that state. Without a shared component, each product team would invent ad-hoc "no data" screens that break visual consistency and miss the Japanese UX expectations (friendly, encouraging tone rather than a cold system message).

## What Changes

- New `EmptyState` component in `src/components/ui/empty-state.tsx`
- Optional Lucide icon slot, required heading, optional description, optional CTA button
- Locale-aware copy via `useCopy()` (ja / en / zh-Hans)
- Vitest unit tests covering all prop combinations and locale output
- New `EmptyState` section added to the component showcase

## Capabilities

### New Capabilities

- `empty-state`: A composable empty-state display component with icon, heading, description, and optional CTA — used whenever a data view returns zero results.

### Modified Capabilities

- `localisation-copy`: Add empty-state copy keys (`emptyState.heading` and `emptyState.description`) to the `MoriCopy` catalogue for all three locales.

## Impact

- **New file**: `src/components/ui/empty-state.tsx`
- **New test file**: `src/components/ui/__tests__/empty-state.test.tsx`
- **Modified**: `src/lib/locale/copy.ts` — extend `MoriCopy` type with `emptyState` copy keys
- **Modified**: `src/index.ts` — export `EmptyState` and its props type
- **Modified**: `showcase/src/App.tsx` and `showcase/src/sections/` — add EmptyState demo section
- **Dependencies**: Lucide React (already installed), existing `Button` component, existing `useCopy()` hook
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
## ADDED Requirements

### Requirement: EmptyState component renders a centred zero-data layout
`src/components/ui/empty-state.tsx` SHALL export an `EmptyState` React functional component and an `EmptyStateProps` TypeScript interface. The component SHALL render a vertically and horizontally centred layout containing an optional icon, a required heading, an optional description, and an optional CTA button. All colour and spacing values SHALL reference CSS custom property tokens — no hardcoded hex or px values.

#### Scenario: Minimal render with heading only
- **WHEN** `<EmptyState heading="まだシフトがありません" />` is rendered
- **THEN** the heading text SHALL be visible in the DOM
- **THEN** no icon, description, or button SHALL be rendered

#### Scenario: Full render with all props
- **WHEN** `<EmptyState heading="まだシフトがありません" description="シフトを追加してください" icon={CalendarIcon} action={{ label: "追加する", onClick: fn }} />` is rendered
- **THEN** an SVG icon, the heading, the description, and a button labeled "追加する" SHALL all be present in the DOM

---

### Requirement: Icon slot accepts a Lucide icon component
The `icon` prop SHALL accept a `LucideIcon` component reference (i.e., `React.ComponentType<React.SVGProps<SVGSVGElement>>`). When provided, the icon SHALL render at 48×48px using `--color-ink-tertiary`. When omitted, no icon element SHALL be rendered.

#### Scenario: Icon renders with correct dimensions
- **WHEN** `<EmptyState heading="x" icon={InboxIcon} />` is rendered
- **THEN** the rendered SVG element SHALL have `width="48"` and `height="48"` attributes (or equivalent CSS)

#### Scenario: No icon when prop omitted
- **WHEN** `<EmptyState heading="x" />` is rendered
- **THEN** no `<svg>` element SHALL appear in the output

---

### Requirement: Heading is required and rendered as a prominent text element
The `heading` prop SHALL be a required `string`. It SHALL render as a visible text node styled with `--color-ink` at the `--text-lg` size and `--font-weight-semibold` weight. Line height SHALL use `--leading-snug`.

#### Scenario: Heading text appears in DOM
- **WHEN** `<EmptyState heading="テスト見出し" />` is rendered
- **THEN** `getByText("テスト見出し")` SHALL resolve successfully

---

### Requirement: Description is optional and rendered as secondary text
The `description` prop SHALL be an optional `string`. When provided, it SHALL render below the heading with `--color-ink-secondary` and `--text-sm` size. When omitted, no description element SHALL appear.

#### Scenario: Description renders when provided
- **WHEN** `<EmptyState heading="x" description="説明文" />` is rendered
- **THEN** `getByText("説明文")` SHALL resolve successfully

#### Scenario: Description absent when omitted
- **WHEN** `<EmptyState heading="x" />` is rendered
- **THEN** no element with a description role or description-class SHALL appear

---

### Requirement: CTA renders as a primary Button when action prop is provided
The `action` prop SHALL be an optional object `{ label: string; onClick: () => void }`. When provided, the component SHALL render a `Button` with `variant="primary"` and `size="md"` labelled with `action.label`. Clicking the button SHALL invoke `action.onClick`.

#### Scenario: CTA button is clickable
- **WHEN** `<EmptyState heading="x" action={{ label: "追加する", onClick: mockFn }} />` is rendered and the button is clicked
- **THEN** `mockFn` SHALL have been called exactly once

#### Scenario: No button when action omitted
- **WHEN** `<EmptyState heading="x" />` is rendered
- **THEN** no `<button>` element SHALL appear in the output

---

### Requirement: Component is exported from the public API
`src/index.ts` SHALL export both `EmptyState` and `EmptyStateProps`.

#### Scenario: Named exports are available
- **WHEN** a consumer imports `{ EmptyState, EmptyStateProps }` from the package root
- **THEN** TypeScript SHALL resolve both without error

---

### Requirement: EmptyState is demonstrated in the component showcase
A new `EmptyStateSection` SHALL be added to the showcase (`showcase/src/sections/empty-state-section.tsx`) and rendered in `showcase/src/App.tsx`. It SHALL demonstrate at minimum: (1) heading only, (2) heading + icon + description + CTA, (3) heading + icon, no CTA.

#### Scenario: Showcase section renders all variants
- **WHEN** the showcase is loaded in a browser
- **THEN** three distinct EmptyState examples SHALL be visible on screen
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
## ADDED Requirements

### Requirement: MoriCopy catalogue includes a generic empty-state description key
`src/lib/locale/copy.ts` SHALL add the key `empty.description` to the `CopyKeys` interface and provide translations for all three locales. This key provides a generic description for zero-data screens when no product-specific description is available.

| Key | `ja` | `en` | `zh-Hans` |
|-----|------|------|-----------|
| `empty.description` | `まだ項目が登録されていません。` | `No items have been added yet.` | `还没有添加任何项目。` |

The `zh-Hans` value SHALL be accompanied by a `// TODO: zh-Hans review` comment per the existing convention.

#### Scenario: New key present for all locales
- **WHEN** the `MoriCopy` object is type-checked after the addition
- **THEN** TypeScript SHALL confirm that `ja`, `en`, and `zh-Hans` each have an `empty.description` property with a non-empty string value

#### Scenario: Missing key on CopyKeys is a compile error
- **WHEN** code references `copy[locale].empty.description` on a `CopyKeys` type that does not include the key
- **THEN** TypeScript SHALL emit a property-not-found error, confirming the type is updated
44 changes: 44 additions & 0 deletions openspec/changes/archive/2026-04-01-empty-state-component/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
## 1. Extend Localisation Copy

- [x] 1.1 Add `empty.description` key to the `CopyKeys` interface in `src/lib/locale/copy.ts`
- [x] 1.2 Add Japanese translation: `まだ項目が登録されていません。`
- [x] 1.3 Add English translation: `No items have been added yet.`
- [x] 1.4 Add Simplified Chinese translation with `// TODO: zh-Hans review` comment: `还没有添加任何项目。`

## 2. Implement EmptyState Component

- [x] 2.1 Create `src/components/ui/empty-state.tsx` with `EmptyStateProps` interface (`heading: string`, `description?: string`, `icon?: LucideIcon`, `action?: { label: string; onClick: () => void }`)
- [x] 2.2 Render centred flex column layout using only CSS variable tokens for colours and spacing
- [x] 2.3 Render icon at 48×48px with `--color-ink-tertiary` when `icon` prop provided; render nothing when omitted
- [x] 2.4 Render heading as `--text-lg` / `--font-weight-semibold` / `--color-ink`
- [x] 2.5 Render description as `--text-sm` / `--color-ink-secondary` below heading when provided; render nothing when omitted
- [x] 2.6 Render `Button` with `variant="primary"` and `size="md"` labelled with `action.label` when action prop provided; render nothing when omitted

## 3. Export from Public API

- [x] 3.1 Add `export { EmptyState, type EmptyStateProps } from './components/ui/empty-state'` to `src/index.ts`

## 4. Write Vitest Unit Tests

- [x] 4.1 Create `src/components/ui/__tests__/empty-state.test.tsx`
- [x] 4.2 Test: heading-only render — heading visible, no icon, no description, no button
- [x] 4.3 Test: full render — icon, heading, description, and button all present
- [x] 4.4 Test: icon renders with 48px dimensions when prop supplied
- [x] 4.5 Test: no `<svg>` in DOM when icon omitted
- [x] 4.6 Test: description visible when prop supplied; absent when omitted
- [x] 4.7 Test: CTA button click invokes `action.onClick` exactly once
- [x] 4.8 Test: no `<button>` in DOM when action omitted
- [x] 4.9 Run `pnpm test` and confirm all tests pass

## 5. Add Showcase Section

- [x] 5.1 Create `showcase/src/sections/empty-state-section.tsx` with three variants: (a) heading only, (b) heading + icon + description + CTA, (c) heading + icon, no CTA
- [x] 5.2 Import and render `EmptyStateSection` in `showcase/src/App.tsx`
- [x] 5.3 Run `pnpm dev` and visually verify all three variants render correctly on 390px and desktop viewports

## 6. Quality Checks

- [x] 6.1 Run `pnpm typecheck` — zero errors
- [x] 6.2 Run `pnpm lint` — zero warnings or errors
- [x] 6.3 Run `pnpm format:check` — no formatting issues
- [x] 6.4 Run `pnpm test` — all tests green
84 changes: 84 additions & 0 deletions openspec/specs/empty-state/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# empty-state Specification

## Purpose
Define the requirements for the Mori `EmptyState` component — a centred zero-data layout used across all products when a list or view has no content to display.

## Requirements

### Requirement: EmptyState component renders a centred zero-data layout
`src/components/ui/empty-state.tsx` SHALL export an `EmptyState` React functional component and an `EmptyStateProps` TypeScript interface. The component SHALL render a vertically and horizontally centred layout containing an optional icon, a required heading, an optional description, and an optional CTA button. All colour and spacing values SHALL reference CSS custom property tokens — no hardcoded hex or px values.

#### Scenario: Minimal render with heading only
- **WHEN** `<EmptyState heading="まだシフトがありません" />` is rendered
- **THEN** the heading text SHALL be visible in the DOM
- **THEN** no icon, description, or button SHALL be rendered

#### Scenario: Full render with all props
- **WHEN** `<EmptyState heading="まだシフトがありません" description="シフトを追加してください" icon={CalendarIcon} action={{ label: "追加する", onClick: fn }} />` is rendered
- **THEN** an SVG icon, the heading, the description, and a button labeled "追加する" SHALL all be present in the DOM

---

### Requirement: Icon slot accepts a Lucide icon component
The `icon` prop SHALL accept a `LucideIcon` component reference (i.e., `React.ComponentType<React.SVGProps<SVGSVGElement>>`). When provided, the icon SHALL render at 48×48px using `--color-ink-tertiary`. When omitted, no icon element SHALL be rendered.

#### Scenario: Icon renders with correct dimensions
- **WHEN** `<EmptyState heading="x" icon={InboxIcon} />` is rendered
- **THEN** the rendered SVG element SHALL have `width="48"` and `height="48"` attributes (or equivalent CSS)

#### Scenario: No icon when prop omitted
- **WHEN** `<EmptyState heading="x" />` is rendered
- **THEN** no `<svg>` element SHALL appear in the output

---

### Requirement: Heading is required and rendered as a prominent text element
The `heading` prop SHALL be a required `string`. It SHALL render as a visible text node styled with `--color-ink` at the `--text-lg` size and `--font-weight-semibold` weight. Line height SHALL use `--leading-snug`.

#### Scenario: Heading text appears in DOM
- **WHEN** `<EmptyState heading="テスト見出し" />` is rendered
- **THEN** `getByText("テスト見出し")` SHALL resolve successfully

---

### Requirement: Description is optional and rendered as secondary text
The `description` prop SHALL be an optional `string`. When provided, it SHALL render below the heading with `--color-ink-secondary` and `--text-sm` size. When omitted, no description element SHALL appear.

#### Scenario: Description renders when provided
- **WHEN** `<EmptyState heading="x" description="説明文" />` is rendered
- **THEN** `getByText("説明文")` SHALL resolve successfully

#### Scenario: Description absent when omitted
- **WHEN** `<EmptyState heading="x" />` is rendered
- **THEN** no element with a description role or description-class SHALL appear

---

### Requirement: CTA renders as a primary Button when action prop is provided
The `action` prop SHALL be an optional object `{ label: string; onClick: () => void }`. When provided, the component SHALL render a `Button` with `variant="primary"` and `size="md"` labelled with `action.label`. Clicking the button SHALL invoke `action.onClick`.

#### Scenario: CTA button is clickable
- **WHEN** `<EmptyState heading="x" action={{ label: "追加する", onClick: mockFn }} />` is rendered and the button is clicked
- **THEN** `mockFn` SHALL have been called exactly once

#### Scenario: No button when action omitted
- **WHEN** `<EmptyState heading="x" />` is rendered
- **THEN** no `<button>` element SHALL appear in the output

---

### Requirement: Component is exported from the public API
`src/index.ts` SHALL export both `EmptyState` and `EmptyStateProps`.

#### Scenario: Named exports are available
- **WHEN** a consumer imports `{ EmptyState, EmptyStateProps }` from the package root
- **THEN** TypeScript SHALL resolve both without error

---

### Requirement: EmptyState is demonstrated in the component showcase
A new `EmptyStateSection` SHALL be added to the showcase (`showcase/src/sections/empty-state-section.tsx`) and rendered in `showcase/src/App.tsx`. It SHALL demonstrate at minimum: (1) heading only, (2) heading + icon + description + CTA, (3) heading + icon, no CTA.

#### Scenario: Showcase section renders all variants
- **WHEN** the showcase is loaded in a browser
- **THEN** three distinct EmptyState examples SHALL be visible on screen
Loading
Loading