diff --git a/apps/docs/src/components/document-renderer/components/category-overview/category-overview.tsx b/apps/docs/src/components/document-renderer/components/category-overview/category-overview.tsx index ffb2b0a58..e6a11be3d 100644 --- a/apps/docs/src/components/document-renderer/components/category-overview/category-overview.tsx +++ b/apps/docs/src/components/document-renderer/components/category-overview/category-overview.tsx @@ -93,13 +93,12 @@ const CategoryOverviewContent: FC<{ variant?: string }> = ({ variant }) => { - + @@ -118,7 +117,7 @@ const CategoryOverviewContent: FC<{ variant?: string }> = ({ variant }) => { - + @@ -135,11 +134,10 @@ const CategoryOverviewContent: FC<{ variant?: string }> = ({ variant }) => { - + @@ -157,7 +155,7 @@ const CategoryOverviewContent: FC<{ variant?: string }> = ({ variant }) => { {doc.description} - + ))} diff --git a/apps/docs/src/components/document-renderer/components/frontpage/frontpage.tsx b/apps/docs/src/components/document-renderer/components/frontpage/frontpage.tsx index f8d0d31a5..72dd52da3 100644 --- a/apps/docs/src/components/document-renderer/components/frontpage/frontpage.tsx +++ b/apps/docs/src/components/document-renderer/components/frontpage/frontpage.tsx @@ -39,7 +39,7 @@ const links = [ export const Frontpage = () => { return ( - + Nimbus @@ -55,15 +55,15 @@ export const Frontpage = () => { - + {links.map((link) => ( - - + + {link.icon} @@ -77,7 +77,7 @@ export const Frontpage = () => { - + ))} diff --git a/openspec/changes/rework-card-component/proposal.md b/openspec/changes/rework-card-component/proposal.md new file mode 100644 index 000000000..a6c3e60c3 --- /dev/null +++ b/openspec/changes/rework-card-component/proposal.md @@ -0,0 +1,140 @@ +# Proposal: Rework Card Component + +## Summary + +Rework the Card component to fix architectural flaws, align with Nimbus compound +component conventions, and add missing sub-components. The current +implementation uses a unique context-registration pattern (useState + useEffect +to register JSX from children into parent state) that no other Nimbus component +uses and that introduces re-render risks. This proposal replaces it with direct +rendering (matching Dialog, DefaultPage, Accordion, etc.), renames Content to +Body for Nimbus naming consistency, adds a Footer sub-component, and moves +padding logic to CSS-only slot styling. + +## Motivation + +1. **Context registration anti-pattern**: CardHeader and CardContent return + `null` and use `useEffect` to register JSX into parent state. The effect + dependencies include `styleProps` and `functionalProps` which are new objects + every render, creating infinite re-render risk. Every other compound + component in Nimbus renders children directly. + +2. **Missing Footer**: Every major UI library (Chakra v3, MUI, shadcn, React + Bootstrap) and Nimbus compound components (Dialog, DefaultPage) provide a + footer region. Card lacks one. + +3. **Naming inconsistency**: Nimbus convention is "Content" for outer structural + containers (Dialog.Content, Drawer.Content) and "Body" for main content areas + (Dialog.Body, Drawer.Body). Card.Content is doing the Body job. + +4. **Non-standard variant props**: Card uses `cardPadding`, `borderStyle`, + `elevation`, and `backgroundStyle` instead of the standard Nimbus `variant` + and `size` props. Every other Nimbus component (Button, Badge, Alert, Tabs, + etc.) uses `variant` for visual treatment and `size` for dimensional scaling. + The four separate visual props create a combinatorial explosion (12 + permutations) that should be collapsed into curated `variant` presets. + +5. **Hardcoded gap**: The internal Stack with `gap="200"` is not configurable. + Consumers cannot fine-tune spacing between card sections. + +6. **`display: inline-flex`**: Unusual default for a card container. Cards + typically fill available width. + +7. **Empty slot styles**: Header and Content slots are declared but have zero + styles in the recipe, making the slot system pointless for these parts. + +## Impact + +- **Breaking change**: `Card.Content` renamed to `Card.Body`. Card is not yet in + consumer use, so impact is zero. +- **Breaking change**: `cardPadding`, `borderStyle`, `elevation`, + `backgroundStyle` replaced by standard `variant` and `size` props. +- **Additive**: `Card.Footer` new sub-component. +- **Internal**: Architecture simplification (context registration removed), + recipe restructured for CSS-only padding distribution. + +## Scope + +- Affected spec: `nimbus-card` +- Files changed: `card.tsx`, `card.types.ts`, `card.recipe.ts`, `card.slots.tsx`, + `components/card.root.tsx`, `components/card.header.tsx`, + `components/card.content.tsx` (renamed to card.body.tsx), + new `components/card.footer.tsx`, `components/index.ts`, + `card.stories.tsx`, `card.docs.spec.tsx`, `card/index.ts` +- Theme registration: slot-recipes/index.ts (slot name update) + +## Design Decisions + +### Direct rendering over context registration + +Every other Nimbus compound component (Dialog, DefaultPage, Accordion, +PageContent) renders children directly via slot components. The Card's +context-registration pattern was meant to enforce Header-before-Content ordering +regardless of JSX placement, but this guarantee adds complexity without clear +consumer value. Removing it aligns Card with the rest of the library. + +### CSS-only padding distribution + +Move padding from Root to individual slots. Each slot receives full padding +(`p: --card-spacing`). When two card slots are directly adjacent, the later slot +suppresses its own top padding via adjacent sibling class selectors (e.g. +`.nimbus-card__header + .nimbus-card__body`). This ensures correct spacing +regardless of which combination of Header/Body/Footer is present, with no +runtime logic. When a non-slot element (e.g. Separator) sits between slots, both +slots retain full padding, providing visually balanced spacing around the +element. + +### Standard `variant` and `size` props + +Nimbus convention: `variant` controls visual treatment (border, shadow, +background), `size` controls dimensions (padding, gap, font). Card's four +separate props (`cardPadding`, `borderStyle`, `elevation`, `backgroundStyle`) +are replaced by: + +**`size`** (sm | md | lg, default: md) — replaces `cardPadding`: +- Sets `--card-spacing` CSS variable used for all slot padding +- sm: spacing.300 +- md: spacing.400 +- lg: spacing.600 + +**`variant`** (outlined | elevated | filled | plain, default: outlined) — +replaces `borderStyle` + `elevation` + `backgroundStyle`: +- `outlined`: border solid-25 + colorPalette.6, default bg, no shadow (current default) +- `elevated`: no border, shadow level 1, default bg +- `filled`: no border, no shadow, colorPalette.2 muted bg +- `plain`: no border, no shadow, default bg (minimal) + +This eliminates the 12-permutation combinatorial explosion and gives consumers +the same vocabulary used by Button, Alert, Badge, Tabs, and every other Nimbus +component. + +### Unified spacing via `--card-spacing` + +Instead of a hardcoded `gap="200"`, a single `--card-spacing` CSS variable +controls both slot padding and inter-slot spacing. Each size variant sets this +variable, and adjacent sibling selectors collapse redundant padding between +slots. This keeps proportional spacing without adding a new prop. + +### display: flex (not inline-flex) + +Cards are block-level containers. `flex` with `flexDirection: column` gives +natural full-width behavior while maintaining flex layout for internal spacing +via `gap`. + +### Slot-based ARIA wiring via React Aria + +Card.Root uses React Aria's `useSlotId` + `Provider` pattern (identical to React +Aria Components' DropZone) to automatically wire `aria-labelledby` and +`aria-describedby`. When a consumer places `` or +`` inside a Card, the Card: + +1. Gets `role="article"` (only when slots are present) +2. Gets `aria-labelledby` pointing to the Heading's auto-generated ID +3. Gets `aria-describedby` pointing to the Text's auto-generated ID + +This follows the Adobe React Spectrum Card spec +([adobe/react-spectrum#2080](https://github.com/adobe/react-spectrum/issues/2080)). +The wiring is zero-config for consumers and non-breaking: Cards without +slot-prop children remain plain divs with no role or ARIA attributes. The +conditional `role="article"` avoids polluting screen reader landmark/article +navigation for cards that are purely visual containers. diff --git a/openspec/changes/rework-card-component/specs/nimbus-card/spec.md b/openspec/changes/rework-card-component/specs/nimbus-card/spec.md new file mode 100644 index 000000000..fd473d528 --- /dev/null +++ b/openspec/changes/rework-card-component/specs/nimbus-card/spec.md @@ -0,0 +1,354 @@ +# Spec Delta: nimbus-card + +## MODIFIED Requirements + +### Requirement: Namespace Structure + +The component SHALL export as compound component namespace. + +#### Scenario: Component parts + +- **WHEN** Card is imported +- **THEN** SHALL provide Card.Root as card container +- **AND** SHALL provide Card.Header for title section +- **AND** SHALL provide Card.Body for main content area +- **AND** SHALL provide Card.Footer for actions/metadata section +- **AND** Root SHALL be first property in namespace + +#### Scenario: Flexible composition + +- **WHEN** consumer uses Card components +- **THEN** SHALL allow Card.Root without compound parts (free-form content) +- **AND** SHALL allow Card.Root with any single part (Header, Body, or Footer) +- **AND** SHALL allow any combination of Header, Body, and Footer +- **AND** SHALL render parts in DOM order as placed by consumer +- **AND** SHALL apply correct edge padding via CSS regardless of which parts are + present + +### Requirement: Container Component + +The component SHALL provide root container with styling configuration. + +#### Scenario: Root rendering + +- **WHEN** Card.Root renders +- **THEN** SHALL render as div element +- **AND** SHALL accept all Chakra style props +- **AND** SHALL apply recipe variants +- **AND** SHALL NOT use React context for child layout coordination + +#### Scenario: Layout behavior + +- **WHEN** Card.Root renders with child parts +- **THEN** SHALL use CSS flexbox column layout +- **AND** SHALL use a unified `--card-spacing` CSS variable for all spacing +- **AND** spacing between adjacent slot children equals `--card-spacing` (via + slot padding, not flexbox gap) +- **AND** SHALL NOT wrap children in an intermediate Stack component + +#### Scenario: Style prop forwarding + +- **WHEN** style props are provided on Root +- **THEN** SHALL extract and apply style props to root element +- **AND** SHALL separate recipe props from style props +- **AND** SHALL apply recipe variants first, then style overrides + +### Requirement: Title Section + +The component SHALL provide header section for titles and metadata. + +#### Scenario: Header rendering + +- **WHEN** Card.Header renders +- **THEN** SHALL render as div element via header slot component +- **AND** SHALL accept all Chakra style props +- **AND** SHALL render content as provided +- **AND** SHALL forward ref to div element + +#### Scenario: Header padding + +- **WHEN** Card.Header renders +- **THEN** SHALL receive full padding (`p: --card-spacing`) on all sides +- **AND** when followed directly by Body or Footer, the next slot suppresses its + own top padding via adjacent sibling selectors + +### Requirement: Main Content Area + +The component SHALL provide primary content container named Body. + +#### Scenario: Body rendering + +- **WHEN** Card.Body renders +- **THEN** SHALL render as div element via body slot component +- **AND** SHALL accept all Chakra style props +- **AND** SHALL support any content type +- **AND** SHALL forward ref to div element + +#### Scenario: Body padding + +- **WHEN** Card.Body renders +- **THEN** SHALL receive full padding (`p: --card-spacing`) on all sides +- **AND** SHALL suppress top padding when directly preceded by Card.Header + (adjacent sibling selector) + +### Requirement: Footer Section + +The component SHALL provide footer section for actions and metadata. + +#### Scenario: Footer rendering + +- **WHEN** Card.Footer renders +- **THEN** SHALL render as div element via footer slot component +- **AND** SHALL accept all Chakra style props +- **AND** SHALL render content as provided +- **AND** SHALL forward ref to div element + +#### Scenario: Footer padding + +- **WHEN** Card.Footer renders +- **THEN** SHALL receive full padding (`p: --card-spacing`) on all sides +- **AND** SHALL suppress top padding when directly preceded by Card.Header or + Card.Body (adjacent sibling selectors) +- **AND** when a non-slot element (e.g. Separator) sits between slots, both + slots retain full padding for visually balanced spacing around the element + +### Requirement: Size Options + +The component SHALL support size variants controlling padding and gap per +nimbus-core standards. + +#### Scenario: Small size + +- **WHEN** size="sm" is set on Root +- **THEN** SHALL set `--card-spacing` to `spacing.300` token +- **AND** all slots SHALL use `--card-spacing` for padding on all sides +- **AND** adjacent slots SHALL collapse top padding on the later slot + +#### Scenario: Medium size + +- **WHEN** size="md" is set or no value provided (default) +- **THEN** SHALL set `--card-spacing` to `spacing.400` token +- **AND** all slots SHALL use `--card-spacing` for padding on all sides +- **AND** adjacent slots SHALL collapse top padding on the later slot + +#### Scenario: Large size + +- **WHEN** size="lg" is set on Root +- **THEN** SHALL set `--card-spacing` to `spacing.600` token +- **AND** all slots SHALL use `--card-spacing` for padding on all sides +- **AND** adjacent slots SHALL collapse top padding on the later slot + +### Requirement: Visual Variant Options + +The component SHALL support variant prop for visual treatment per nimbus-core +standards. + +#### Scenario: Outlined variant + +- **WHEN** variant="outlined" is set or no value provided (default) +- **THEN** SHALL render with solid-25 border using colorPalette.6 +- **AND** SHALL use default background (bg token) +- **AND** SHALL have no shadow + +#### Scenario: Elevated variant + +- **WHEN** variant="elevated" is set +- **THEN** SHALL render without border +- **AND** SHALL apply shadow token level 1 +- **AND** SHALL use default background (bg token) + +#### Scenario: Filled variant + +- **WHEN** variant="filled" is set +- **THEN** SHALL render without border +- **AND** SHALL have no shadow +- **AND** SHALL use colorPalette.2 muted background + +#### Scenario: Plain variant + +- **WHEN** variant="plain" is set +- **THEN** SHALL render without border +- **AND** SHALL have no shadow +- **AND** SHALL use default background (bg token) + +### Requirement: Layout Display + +The component SHALL control layout display characteristics. + +#### Scenario: Display mode + +- **WHEN** Card.Root renders +- **THEN** SHALL use display: flex +- **AND** SHALL use flexDirection: column +- **AND** SHALL allow width override via style props +- **AND** SHALL align items flex-start for vertical layout + +### Requirement: Multi-Slot Recipe + +The component SHALL use multi-slot recipe per nimbus-core standards. + +#### Scenario: Slot styling + +- **WHEN** card renders +- **THEN** SHALL apply card slot recipe from theme/slot-recipes +- **AND** SHALL style: root, header, body, footer slots +- **AND** SHALL support size variants: sm, md, lg +- **AND** SHALL support variant options: outlined, elevated, filled, plain + +#### Scenario: Recipe registration + +- **WHEN** Card component is used +- **THEN** cardRecipe SHALL be registered in theme/slot-recipes/index.ts +- **AND** registration SHALL use "nimbusCard" key +- **AND** registration SHALL be manual (no auto-discovery) + +### Requirement: Type Definitions + +The component SHALL provide comprehensive TypeScript types per nimbus-core +standards. + +#### Scenario: Recipe props type + +- **WHEN** CardRecipeProps type is defined +- **THEN** SHALL include size?: "sm" | "md" | "lg" +- **AND** SHALL include variant?: "outlined" | "elevated" | "filled" | "plain" +- **AND** SHALL extend UnstyledProp + +#### Scenario: Slot props types + +- **WHEN** slot props types are defined +- **THEN** SHALL export CardRootSlotProps extending HTMLChakraProps<"div", + CardRecipeProps> +- **AND** SHALL export CardHeaderSlotProps extending HTMLChakraProps<"div"> +- **AND** SHALL export CardBodySlotProps extending HTMLChakraProps<"div"> +- **AND** SHALL export CardFooterSlotProps extending HTMLChakraProps<"div"> + +#### Scenario: Main props types + +- **WHEN** main component props types are defined +- **THEN** SHALL export CardProps with children, ref, data-\* attributes +- **AND** SHALL export CardHeaderProps with children and ref +- **AND** SHALL export CardBodyProps with children and ref +- **AND** SHALL export CardFooterProps with children and ref +- **AND** SHALL omit internal props using OmitInternalProps utility +- **AND** all props SHALL have JSDoc documentation + +### Requirement: Debug Identification + +The component SHALL provide display names for debugging per nimbus-core +standards. + +#### Scenario: Display name setting + +- **WHEN** Card components are defined +- **THEN** Card.Root SHALL set displayName="Card.Root" +- **AND** Card.Header SHALL set displayName="Card.Header" +- **AND** Card.Body SHALL set displayName="Card.Body" +- **AND** Card.Footer SHALL set displayName="Card.Footer" +- **AND** names SHALL appear in React DevTools + +### Requirement: Content Flexibility + +The component SHALL support flexible content composition. + +#### Scenario: Free-form content + +- **WHEN** Card.Root contains non-compound children +- **THEN** SHALL render children directly +- **AND** SHALL not require Header, Body, or Footer components +- **AND** SHALL maintain styling from variants +- **AND** SHALL allow complete layout freedom + +#### Scenario: Mixed content + +- **WHEN** Card.Root contains both compound parts and other elements +- **THEN** SHALL render all children in DOM order +- **AND** SHALL apply slot styling to compound parts +- **AND** SHALL maintain proper spacing via gap + +### Requirement: Slot-Based Accessibility + +The component SHALL automatically apply ARIA attributes when React Aria slot +children are present. + +#### Scenario: Full slot wiring + +- **WHEN** Card.Root contains `` and + `` +- **THEN** SHALL apply `role="article"` on root element +- **AND** SHALL apply `aria-labelledby` pointing to the Heading's generated ID +- **AND** SHALL apply `aria-describedby` pointing to the Text's generated ID + +#### Scenario: Title slot only + +- **WHEN** Card.Root contains `` but no + `` +- **THEN** SHALL apply `role="article"` on root element +- **AND** SHALL apply `aria-labelledby` pointing to the Heading's generated ID +- **AND** SHALL NOT apply `aria-describedby` + +#### Scenario: Description slot only + +- **WHEN** Card.Root contains `` but no + `` +- **THEN** SHALL apply `role="article"` on root element +- **AND** SHALL NOT apply `aria-labelledby` +- **AND** SHALL apply `aria-describedby` pointing to the Text's generated ID + +#### Scenario: No slots + +- **WHEN** Card.Root contains no slot-prop children +- **THEN** SHALL NOT apply `role` attribute +- **AND** SHALL NOT apply `aria-labelledby` +- **AND** SHALL NOT apply `aria-describedby` + +#### Scenario: Manual aria-label + +- **WHEN** consumer passes `aria-label` directly to Card.Root +- **THEN** SHALL forward `aria-label` to the root element +- **AND** slot-based wiring SHALL still function if slots are also present + +#### Scenario: Slot matching + +- **WHEN** `` without `slot="title"` is inside Card.Root +- **THEN** SHALL NOT affect the Heading (slot matching is strict) +- **AND** SHALL NOT apply `role="article"` + +## REMOVED Requirements + +### Requirement: Rendering Optimization + +_Removed: The context-based registration pattern (useMemo for context value, +useEffect for registration) is being removed entirely. There is no context to +optimize. Standard React rendering applies._ + +### Requirement: Main Content Area (original Content-based) + +_Removed: Replaced by the Body-based Main Content Area requirement above. +Card.Content is renamed to Card.Body._ + +### Requirement: Card Padding Options + +_Removed: Replaced by the Size Options requirement. `cardPadding` prop is +renamed to `size` to align with Nimbus conventions._ + +### Requirement: Border Styling Options + +_Removed: Collapsed into the Visual Variant Options requirement. `borderStyle` +prop is replaced by `variant`._ + +### Requirement: Shadow Options + +_Removed: Collapsed into the Visual Variant Options requirement. `elevation` +prop is replaced by `variant`._ + +### Requirement: Background Styling Options + +_Removed: Collapsed into the Visual Variant Options requirement. +`backgroundStyle` prop is replaced by `variant`._ + +### Requirement: Variant Composition + +_Removed: The four independent variant props are collapsed into two standard +props (`variant` and `size`). The combinatorial explosion is replaced by curated +presets._ diff --git a/openspec/changes/rework-card-component/tasks.md b/openspec/changes/rework-card-component/tasks.md new file mode 100644 index 000000000..c54693aa3 --- /dev/null +++ b/openspec/changes/rework-card-component/tasks.md @@ -0,0 +1,235 @@ +# Tasks: Rework Card Component + +## - [x] Task 1: Update recipe — add footer slot, restructure to variant/size, fix display + +**File:** `packages/nimbus/src/components/card/card.recipe.ts` + +- Add `"body"` and `"footer"` to slots array, rename `"content"` to `"body"` +- Change root `display` from `inline-flex` to `flex` +- Add `flexDirection: "column"` to root base +- Remove old variants (`cardPadding`, `borderStyle`, `elevation`, + `backgroundStyle`) +- Add `size` variant (sm, md, lg) controlling: + - Gap on root: sm=100, md=200, lg=400 + - Shared slot styles for header, body, footer: + - Horizontal padding (`px`) from size variant + - `_first`: top padding matching size + - `_last`: bottom padding matching size +- Add `variant` option (outlined, elevated, filled, plain): + - `outlined`: border solid-25 + colorPalette.6, bg, no shadow + - `elevated`: no border, shadow 1, bg + - `filled`: no border, no shadow, colorPalette.2 bg + - `plain`: no border, no shadow, bg +- Set defaults: `size: "md"`, `variant: "outlined"` +- Update theme registration in `src/theme/slot-recipes/index.ts` if slot names + changed + +**Verify:** Recipe compiles, theme typings generate without errors +(`pnpm --filter @commercetools/nimbus build-theme-typings`) + +## - [x] Task 2: Update types — rename Content to Body, add Footer, use variant/size + +**File:** `packages/nimbus/src/components/card/card.types.ts` + +- Replace `CardRecipeProps` internals: + - Remove `cardPadding`, `borderStyle`, `elevation`, `backgroundStyle` + - Add `size?: "sm" | "md" | "lg"` + - Add `variant?: "outlined" | "elevated" | "filled" | "plain"` +- Rename `CardContentSlotProps` to `CardBodySlotProps` +- Rename `CardContentProps` to `CardBodyProps` +- Add `CardFooterSlotProps` extending `HTMLChakraProps<"div">` +- Add `CardFooterProps` with children, ref, OmitInternalProps +- Update all JSDoc comments + +**Verify:** `pnpm --filter @commercetools/nimbus typecheck` + +## - [x] Task 3: Update slots — rename content to body, add footer + +**File:** `packages/nimbus/src/components/card/card.slots.tsx` + +- Rename `CardContent` slot to `CardBody` (withContext "body") +- Add `CardFooter` slot (withContext "footer") +- Update type imports + +**Verify:** Types align with recipe slot names + +## - [x] Task 4: Rewrite card.root.tsx — remove context, direct rendering + +**File:** `packages/nimbus/src/components/card/components/card.root.tsx` + +- Remove `createContext`, `useState`, `useMemo` imports +- Remove `CardContext` and `CardContextValue` +- Remove `headerNode`, `contentNode` state +- Remove `contextValue` memo +- Remove `CardContext.Provider` wrapper +- Remove intermediate `Stack` component +- Render `CardRootSlot` with `children` directly (no reordering) +- Keep recipe splitting and style prop extraction + +**Verify:** Component renders children in DOM order + +## - [x] Task 5: Rewrite card.header.tsx — simple passthrough + +**File:** `packages/nimbus/src/components/card/components/card.header.tsx` + +- Remove `useContext`, `useEffect` imports +- Remove context registration pattern +- Render `CardHeaderSlot` directly with children, forwarding ref and props +- Follow Dialog.Body/Dialog.Footer pattern (simple passthrough) + +**Verify:** Header renders content directly, no null return + +## - [x] Task 6: Rename card.content.tsx to card.body.tsx + +**File:** `packages/nimbus/src/components/card/components/card.content.tsx` → +`card.body.tsx` + +- Rename file +- Rename component from `CardContent` to `CardBody` +- Remove `useContext`, `useEffect` imports +- Remove context registration pattern +- Render `CardBodySlot` directly with children, forwarding ref and props +- Update displayName to `"Card.Body"` + +**Verify:** Body renders content directly + +## - [x] Task 7: Create card.footer.tsx + +**File:** `packages/nimbus/src/components/card/components/card.footer.tsx` (new) + +- Create footer component following same simple pattern as Header/Body +- Import `CardFooterSlot` from slots +- Import `CardFooterProps` from types +- Render slot directly with children, forwarding ref and props +- Set displayName to `"Card.Footer"` + +**Verify:** Footer renders correctly + +## - [x] Task 8: Update barrel exports + +**Files:** + +- `components/index.ts` — export CardBody (not CardContent), add CardFooter +- `card.tsx` — update namespace: rename Content to Body, add Footer with JSDoc +- `card/index.ts` — verify re-exports + +**Verify:** `import { Card } from "@commercetools/nimbus"` provides Root, +Header, Body, Footer + +## - [x] Task 9: Update stories + +**File:** `packages/nimbus/src/components/card/card.stories.tsx` + +- Replace all `Card.Content` with `Card.Body` +- Replace `cardPadding`, `borderStyle`, `elevation`, `backgroundStyle` with + `variant` and `size` +- Add stories demonstrating Card.Footer +- Add story for Header + Body + Footer combination +- Add story for Body-only (verify padding) +- Add story for Header + Body without Footer +- Showcase all variant values (outlined, elevated, filled, plain) +- Showcase all size values (sm, md, lg) +- Add play functions verifying rendering and padding behavior + +**Verify:** `pnpm test:dev packages/nimbus/src/components/card/card.stories.tsx` + +## - [x] Task 10: Update docs spec + +**File:** `packages/nimbus/src/components/card/card.docs.spec.tsx` + +- Replace all `Card.Content` with `Card.Body` +- Replace old variant props with `variant` and `size` +- Add test case for Card with Footer +- Add test case for Body-only card +- Verify all combinations render expected content + +**Verify:** +`pnpm test:dev packages/nimbus/src/components/card/card.docs.spec.tsx` + +## - [x] Task 11: Update MDX documentation + +**Files:** `card.mdx`, `card.dev.mdx`, `card.guidelines.mdx`, `card.a11y.mdx` + +- Replace all `Card.Content` references with `Card.Body` +- Replace old variant prop references with `variant` and `size` +- Document Card.Footer in API reference +- Update examples to show Header + Body + Footer pattern +- Document padding behavior (CSS-driven, adapts to present parts) +- Document variant options with visual examples + +## - [x] Task 12: Build and full test + +- `pnpm --filter @commercetools/nimbus build` +- `pnpm test:dev` (storybook + unit tests against source) +- `pnpm --filter @commercetools/nimbus typecheck` + +## - [x] Task 13: Add @react-aria/utils dependency + +**Files:** + +- `pnpm-workspace.yaml` — add `"@react-aria/utils": 3.33.1` to `react:` catalog +- `packages/nimbus/package.json` — add `"@react-aria/utils": "catalog:react"` to + dependencies + +Run `pnpm install`. Verify no version conflicts. + +**Verify:** `pnpm --filter @commercetools/nimbus typecheck` + +## - [x] Task 14: Implement slot-based ARIA wiring in Card.Root + +**File:** `packages/nimbus/src/components/card/components/card.root.tsx` + +- Import `useSlotId` from `@react-aria/utils` +- Import `Provider`, `HeadingContext`, `TextContext` from + `react-aria-components` +- Call `useSlotId()` twice: once for titleId, once for descriptionId +- Derive conditional ARIA props (`role="article"`, `aria-labelledby`, + `aria-describedby`) when at least one slot is detected +- Wrap children in + `` +- Pass ARIA props to CardRootSlot + +**Verify:** `pnpm --filter @commercetools/nimbus typecheck` + +## - [x] Task 15: Add slot-based accessibility stories + +**File:** `packages/nimbus/src/components/card/card.stories.tsx` + +- Add `Heading` to imports from `@commercetools/nimbus` +- Add story: SlotBasedAccessibility (both title + description slots) +- Add story: WithoutSlots (regression test: no role on plain cards) +- Add story: TitleSlotOnly (partial slot usage) +- All stories must have play functions with ARIA attribute assertions + +**Verify:** +`pnpm test:dev packages/nimbus/src/components/card/card.stories.tsx` + +## - [x] Task 16: Update docs spec with slot examples + +**File:** `packages/nimbus/src/components/card/card.docs.spec.tsx` + +- Add `Heading`, `Text` to imports +- Add "Slot-based accessibility" describe block +- Test automatic `aria-labelledby` wiring with `Heading slot="title"` +- Test `aria-describedby` wiring with `Text slot="description"` +- Test no-role behavior without slots + +**Verify:** +`pnpm test:dev packages/nimbus/src/components/card/card.docs.spec.tsx` + +## - [x] Task 17: Update MDX documentation + +**Files:** + +- `card.a11y.mdx` — rewrite with slot-based ARIA guidance, behavior table, code + examples +- `card.dev.mdx` — add "Accessible cards" section with live example, update + accessibility notes + +## - [x] Task 18: Build and full test + +- `pnpm --filter @commercetools/nimbus build` +- `pnpm test:dev` (storybook + unit tests against source) +- `pnpm --filter @commercetools/nimbus typecheck` +- Verify no regressions in Heading, Text, or Combobox +- Verify no regressions in other components diff --git a/packages/nimbus/package.json b/packages/nimbus/package.json index a68d431fb..5065d7020 100644 --- a/packages/nimbus/package.json +++ b/packages/nimbus/package.json @@ -47,6 +47,7 @@ "@github-ui/storybook-addon-performance-panel": "catalog:tooling", "@internationalized/string": "catalog:react", "@react-aria/interactions": "catalog:react", + "@react-aria/utils": "catalog:react", "dequal": "catalog:utils", "escape-html": "^1.0.3", "is-hotkey": "^0.2.0", diff --git a/packages/nimbus/src/components/card/card.a11y.mdx b/packages/nimbus/src/components/card/card.a11y.mdx index fdfaac0c4..58e27ad86 100644 --- a/packages/nimbus/src/components/card/card.a11y.mdx +++ b/packages/nimbus/src/components/card/card.a11y.mdx @@ -9,10 +9,54 @@ Accessibility ensures that digital content and functionality are usable by everyone, including people with disabilities, by addressing visual, auditory, cognitive, and physical limitations. +### Automatic ARIA wiring + +Card supports automatic ARIA wiring via React Aria's slot system. When you place +a `` or `` inside a Card, the +root element automatically gains `role="article"` along with `aria-labelledby` +and/or `aria-describedby` pointing to those elements. + +```jsx live +const App = () => ( + + + Sprint Summary + + + + The team completed 85% of planned story points this sprint. + + + + + + +) +``` + +### Slot behavior + +| Slots present | `role` | `aria-labelledby` | `aria-describedby` | +|---|---|---|---| +| `Heading slot="title"` + `Text slot="description"` | `article` | points to Heading | points to Text | +| `Heading slot="title"` only | `article` | points to Heading | — | +| `Text slot="description"` only | `article` | — | points to Text | +| No slots | — | — | — | + +Cards without slot-prop children remain plain `
` elements with no ARIA role +or attributes. A `` or `` without a `slot` prop is not affected. + +### Manual labeling + +If slot-based labeling is not suitable, pass `aria-label` directly to +`Card.Root`: + ```jsx live const App = () => ( - - + + + Content with manual aria-label. + ) ``` @@ -33,8 +77,6 @@ const App = () => ( adequate. - **Meaningful sequence:** Ensure the content within the card is presented in a logical order. -- **Labels or instructions:** Provide clear labels or instructions for form - elements within cards. - **Semantic HTML:** Use semantic HTML to ensure compatibility with assistive technologies. - **Avoid complex interactive cards:** Avoid making the entire card a single diff --git a/packages/nimbus/src/components/card/card.dev.mdx b/packages/nimbus/src/components/card/card.dev.mdx index 1199d76dc..de8ee7691 100644 --- a/packages/nimbus/src/components/card/card.dev.mdx +++ b/packages/nimbus/src/components/card/card.dev.mdx @@ -14,15 +14,15 @@ import { Box, Card, Link, type CardProps } from '@commercetools/nimbus'; ### Basic usage -The Card component uses a compound pattern to structure content. You must wrap `Card.Header` and `Card.Content` within `Card.Root`. +The Card component uses a compound pattern to structure content. Wrap `Card.Header`, `Card.Body`, and `Card.Footer` within `Card.Root`. ```jsx live-dev const App = () => ( Card Title - + This is the main card content. - + ) ``` @@ -31,24 +31,24 @@ const App = () => ( ### Size options -The `cardPadding` prop controls the internal spacing of the card. Available sizes are `sm`, `md`, and `lg`. +The `size` prop controls the internal padding and gap of the card. Available sizes are `sm`, `md`, and `lg`. ```jsx live-dev const App = () => ( - - Small Padding - Compact content spacing. + + Small + Compact content spacing. - - Medium Padding - Standard content spacing (default). + + Medium + Standard content spacing (default). - - Large Padding - Spacious content layout. + + Large + Spacious content layout. ) @@ -56,29 +56,74 @@ const App = () => ( ### Visual variants -Customize the card's appearance using `borderStyle`, `elevation`, and `backgroundStyle`. +The `variant` prop controls the card's visual treatment. ```jsx live-dev const App = () => ( - + Outlined (Default) - Standard bordered look. + Standard bordered look. - + Elevated - Shadow depth without border. + Shadow depth without border. - - Muted Background - Subtle background color for differentiation. + + Filled + Muted background for differentiation. + + + + Plain + Minimal visual treatment. ) ``` +### Card with footer + +Use `Card.Footer` for actions and metadata placed below the body content. + +```jsx live-dev +const App = () => ( + + Project Summary + + The project is on track for the next milestone. + + + + + +) +``` + +### Accessible cards + +Use `` and `` to automatically wire `aria-labelledby` and `aria-describedby` on the card. The card gains `role="article"` only when these slots are used. + +```jsx live-dev +const App = () => ( + + + Sprint Summary + + + + The team completed 85% of planned story points this sprint. + + + + + + +) +``` + ### Interactive cards When making a card interactive, avoid placing `onClick` directly on the `Card.Root`. Instead, wrap the card in an anchor tag or use proper ARIA roles to ensure accessibility. @@ -95,11 +140,11 @@ const App = () => ( _hover={{ textDecoration: 'none' }} aria-label="Navigate to details" > - + Navigational Card - + This entire card acts as a link using the system Link component. - + @@ -117,11 +162,11 @@ const App = () => ( cursor="pointer" aria-label="Trigger action" > - + Action Card - + Behaves like a button with keyboard support (Tab, Enter, Space). - + @@ -132,10 +177,12 @@ const App = () => ( ## Accessibility -The `Card` component handles most accessibility requirements internally, rendering semantic `div` elements by default. It acts as a generic container. +The `Card` component supports automatic ARIA wiring via React Aria's slot system. -- **Headings**: Ensure the content within `Card.Header` follows your page's heading hierarchy (e.g., use `Heading` component or `h3`, `h4` tags). -- **Interactive Cards**: If a card is interactive (clickable), do not attach `onClick` directly to the card div if possible. Instead, wrap the card in a link or button, or ensure proper ARIA roles (`role="button"`) and keyboard handling (`tabIndex={0}`, Enter/Space key listeners) are implemented. +- **Automatic ARIA wiring**: Place `` inside the card for automatic `aria-labelledby`. Place `` for `aria-describedby`. The card gains `role="article"` only when these slots are used. Without slots, the card remains a plain `
`. +- **Manual labeling**: Pass `aria-label` directly to `Card.Root` when slot-based labeling is not suitable. +- **Headings**: Ensure the content within `Card.Header` follows your page's heading hierarchy (e.g., use `Heading` component with appropriate `as` prop). +- **Interactive Cards**: If a card is interactive (clickable), do not attach `onClick` directly to the card div. Instead, wrap the card in a link or button, or ensure proper ARIA roles (`role="button"`) and keyboard handling (`tabIndex={0}`, Enter/Space key listeners) are implemented. If your use case requires tracking and analytics for this component, it is good practice to add a **persistent**, **unique** id to the component: @@ -145,7 +192,7 @@ const PERSISTENT_ID = "project-summary-card"; export const Example = () => ( Summary - ... + ... ); ``` @@ -167,4 +214,3 @@ These examples demonstrate how to test your implementation when using Card withi ## Resources - [Storybook](https://nimbus-storybook.vercel.app/?path=/docs/components-card--docs) - diff --git a/packages/nimbus/src/components/card/card.docs.spec.tsx b/packages/nimbus/src/components/card/card.docs.spec.tsx index e57bce9ef..793fdc037 100644 --- a/packages/nimbus/src/components/card/card.docs.spec.tsx +++ b/packages/nimbus/src/components/card/card.docs.spec.tsx @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { render, screen } from "@testing-library/react"; -import { Card, NimbusProvider } from "@commercetools/nimbus"; +import { Card, Heading, Text, NimbusProvider } from "@commercetools/nimbus"; /** * @docs-section basic-rendering @@ -14,7 +14,7 @@ describe("Card - Basic rendering", () => { Project X - Status: Active + Status: Active ); @@ -35,15 +35,90 @@ describe("Card - Basic rendering", () => { expect(screen.getByText("Card Title")).toBeInTheDocument(); }); - it("renders with content only", () => { + it("renders with body only", () => { render( - This is the main content. + This is the main content. ); expect(screen.getByText("This is the main content.")).toBeInTheDocument(); }); + + it("renders with footer", () => { + render( + + + Title + Content + Footer actions + + + ); + + expect(screen.getByText("Title")).toBeInTheDocument(); + expect(screen.getByText("Content")).toBeInTheDocument(); + expect(screen.getByText("Footer actions")).toBeInTheDocument(); + }); + + it("renders with variant and size props", () => { + render( + + + Elevated card + + + ); + + expect(screen.getByText("Elevated card")).toBeInTheDocument(); + }); +}); + +/** + * @docs-section slot-based-accessibility + * @docs-title Slot-Based Accessibility + * @docs-description Using Heading and Text slots for automatic ARIA wiring + * @docs-order 2 + */ +describe("Card - Slot-based accessibility", () => { + it("renders accessible card with title and description slots", () => { + render( + + + + + Product Details + + + + + Overview of the product's key features. + + + + + ); + + expect(screen.getByText("Product Details")).toBeInTheDocument(); + expect(screen.getByText(/key features/)).toBeInTheDocument(); + }); + + it("has no role or ARIA attributes without slots", () => { + render( + + + Title + Content + + + ); + + const card = screen.getByTestId("card-plain"); + + expect(card).not.toHaveAttribute("role"); + expect(card).not.toHaveAttribute("aria-labelledby"); + expect(card).not.toHaveAttribute("aria-describedby"); + }); }); diff --git a/packages/nimbus/src/components/card/card.figma.tsx b/packages/nimbus/src/components/card/card.figma.tsx index cf5c1971c..6ee479a96 100644 --- a/packages/nimbus/src/components/card/card.figma.tsx +++ b/packages/nimbus/src/components/card/card.figma.tsx @@ -1,10 +1,10 @@ import figma from "@figma/code-connect/react"; import { Card } from "./card"; -// --- Card content → Card.Content --- +// --- Card content → Card.Body --- // NOTE: Skipped INSTANCE_SWAP "image" → no matching code prop "image" figma.connect( - Card.Content, + Card.Body, "https://www.figma.com/design/AvtPX6g7OGGCRvNlatGOIY/NIMBUS-design-system?node-id=266-482", { props: { @@ -20,10 +20,10 @@ figma.connect( <> {props.leadingElement} {props.header} - + {props.instance} {props.children} - + ), } @@ -36,13 +36,13 @@ figma.connect( { props: { children: figma.children("*"), - borderStyle: figma.enum("Outlined", { Yes: "outlined", No: "none" }), - elevation: figma.enum("Elevated", { Yes: "elevated", No: "none" }), + variant: figma.enum("Elevated", { + Yes: "elevated" as const, + No: "outlined" as const, + }), }, example: (props) => ( - - {props.children} - + {props.children} ), } ); diff --git a/packages/nimbus/src/components/card/card.guidelines.mdx b/packages/nimbus/src/components/card/card.guidelines.mdx index 333046b3c..00ab47b4d 100644 --- a/packages/nimbus/src/components/card/card.guidelines.mdx +++ b/packages/nimbus/src/components/card/card.guidelines.mdx @@ -92,8 +92,8 @@ clear headings and short descriptions. ```jsx live const App = () => ( - - + + discount template illustration @@ -112,7 +112,7 @@ const App = () => ( - + ); ``` @@ -127,8 +127,8 @@ const App = () => ( ```jsx live const App = () => ( - - + + discount template illustration @@ -166,7 +166,7 @@ const App = () => ( - + ); ``` @@ -189,8 +189,8 @@ interaction. ```jsx live const App = () => ( - - + + ( - + ); ``` @@ -228,8 +228,8 @@ const App = () => ( const App = () => ( {/* Main Card */} - - + + ( - + {/* Bottom Row Cards */} {/* Products Card */} {/* Half width approx */} - + ( Products - + {/* Discounts Card */} {/* Half width approx */} - + ( Discounts - + diff --git a/packages/nimbus/src/components/card/card.recipe.ts b/packages/nimbus/src/components/card/card.recipe.ts index 09322df94..12eeb4298 100644 --- a/packages/nimbus/src/components/card/card.recipe.ts +++ b/packages/nimbus/src/components/card/card.recipe.ts @@ -5,74 +5,83 @@ import { defineSlotRecipe } from "@chakra-ui/react/styled-system"; * Defines the styling variants and base styles using Chakra UI's recipe system. */ export const cardRecipe = defineSlotRecipe({ - slots: ["root", "header", "content"], + slots: ["root", "header", "body", "footer"], className: "nimbus-card", base: { root: { colorPalette: "slate", - display: "inline-flex", + display: "flex", + flexDirection: "column", alignItems: "flex-start", borderRadius: "300", focusVisibleRing: "outside", }, + header: { + p: "var(--card-spacing)", + width: "100%", + }, + body: { + p: "var(--card-spacing)", + width: "100%", + // When directly preceded by header, collapse top padding (header's + // bottom padding provides the gap). When a non-slot element like + // Separator sits between them, both slots keep full padding for + // visually balanced spacing around the element. + ".nimbus-card__header + &": { pt: 0 }, + }, + footer: { + p: "var(--card-spacing)", + width: "100%", + // Same adjacent-sibling collapsing as body — see comment above. + ".nimbus-card__header + &": { pt: 0 }, + ".nimbus-card__body + &": { pt: 0 }, + }, }, variants: { - cardPadding: { + size: { sm: { - root: { - padding: "200", - }, + root: { "--card-spacing": "spacing.300" }, }, md: { - root: { - padding: "400", - }, + root: { "--card-spacing": "spacing.400" }, }, lg: { - root: { - padding: "600", - }, + root: { "--card-spacing": "spacing.600" }, }, }, - borderStyle: { - none: {}, + variant: { outlined: { root: { border: "solid-25", - borderColor: "colorPalette.3", + borderColor: "colorPalette.6", + backgroundColor: "bg", }, }, - }, - elevation: { - none: {}, elevated: { root: { shadow: "1", + backgroundColor: "bg", }, }, - }, - backgroundStyle: { - default: { + filled: { root: { - backgroundColor: "bg", + backgroundColor: "colorPalette.2", }, }, - muted: { + plain: { root: { - backgroundColor: "colorPalette.2", + backgroundColor: "bg", }, }, }, }, defaultVariants: { - cardPadding: "md", - borderStyle: "outlined", - elevation: "none", - backgroundStyle: "default", + size: "md", + variant: "outlined", }, }); diff --git a/packages/nimbus/src/components/card/card.slots.tsx b/packages/nimbus/src/components/card/card.slots.tsx index 80667f6a2..72e8cd83b 100644 --- a/packages/nimbus/src/components/card/card.slots.tsx +++ b/packages/nimbus/src/components/card/card.slots.tsx @@ -1,7 +1,8 @@ import { createSlotRecipeContext } from "@chakra-ui/react/styled-system"; import type { SlotComponent } from "../../type-utils/slot-types"; import type { - CardContentSlotProps, + CardBodySlotProps, + CardFooterSlotProps, CardHeaderSlotProps, CardRootSlotProps, } from "./card.types"; @@ -20,5 +21,8 @@ export const CardRoot: SlotComponent = export const CardHeader: SlotComponent = withContext("div", "header"); -export const CardContent: SlotComponent = - withContext("div", "content"); +export const CardBody: SlotComponent = + withContext("div", "body"); + +export const CardFooter: SlotComponent = + withContext("div", "footer"); diff --git a/packages/nimbus/src/components/card/card.stories.tsx b/packages/nimbus/src/components/card/card.stories.tsx index eea7b2df6..72781506f 100644 --- a/packages/nimbus/src/components/card/card.stories.tsx +++ b/packages/nimbus/src/components/card/card.stories.tsx @@ -1,11 +1,22 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { Card, type CardProps, Stack } from "@commercetools/nimbus"; +import { + Card, + type CardProps, + Stack, + Text, + Heading, + Button, + Separator, +} from "@commercetools/nimbus"; import { within, expect } from "storybook/test"; -const cardPaddings: CardProps["cardPadding"][] = ["sm", "md", "lg"]; -const elevations: CardProps["elevation"][] = ["none", "elevated"]; -const borderStyles: CardProps["borderStyle"][] = ["none", "outlined"]; -const backgroundStyles: CardProps["backgroundStyle"][] = ["default", "muted"]; +const sizes: CardProps["size"][] = ["sm", "md", "lg"]; +const variants: CardProps["variant"][] = [ + "outlined", + "elevated", + "filled", + "plain", +]; const meta: Meta = { title: "Components/Card", @@ -28,10 +39,8 @@ export const Base: Story = { args: { children: "Demo Card", "data-testid": "test-card", - cardPadding: "md", - backgroundStyle: "default", - borderStyle: "none", - elevation: "none", + variant: "outlined", + size: "md", }, play: async ({ canvasElement, args, step }) => { const canvas = within(canvasElement); @@ -48,121 +57,361 @@ export const Base: Story = { }; /** - * Card padding + * Sizes + * Demonstrates sm, md, lg padding and gap scaling */ -export const CardPaddings: Story = { - render: (args) => { +export const Sizes: Story = { + render: () => { return ( - - {cardPaddings.map((cardPadding) => { - return ( - - - - Padding size: {cardPadding as string} - - -

- Lorem ipsum dolor sit amet, consectetur adipiscing elit. -

-
-
-
- ); - })} + + {sizes.map((size) => ( + + + Size: {size as string} + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + + + + + + + ))} ); }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); - args: {}, + await step("All sizes render", async () => { + for (const size of sizes) { + const card = canvas.getByTestId(`card-size-${size as string}`); + await expect(card).toBeInTheDocument(); + } + }); + }, }; /** - * Showcase Configurations + * Variants + * Demonstrates outlined, elevated, filled, plain visual treatments */ -export const Configurations: Story = { - render: (args) => { +export const Variants: Story = { + render: () => { return ( - - {borderStyles.map((border) => { - return ( - - {elevations.map((shadow) => { - return backgroundStyles.map((background) => { - return ( - - ); - }); - })} - - ); - })} + + {variants.map((variant) => ( + + + Variant: {variant as string} + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + + + + ))} ); }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); - args: { - children: ( - <> - - Card title - - -

Lorem ipsum dolor sit amet, consectetur adipiscing elit.

-
- - ), + await step("All variants render", async () => { + for (const variant of variants) { + const card = canvas.getByTestId(`card-variant-${variant as string}`); + await expect(card).toBeInTheDocument(); + } + }); }, }; /** - * Without compound components used by the consumer + * Part combinations + * Side-by-side comparison of realistic part combinations across all sizes, + * without and with dividers: Body only, Header + Body, Header + Body + Footer */ -export const WithoutCompound: Story = { - render: (args) => { +export const PartCombinations: Story = { + render: () => { + const combinations = [ + { label: "Body only", hasHeader: false, hasFooter: false }, + { label: "Header + Body", hasHeader: true, hasFooter: false }, + { label: "Header + Body + Footer", hasHeader: true, hasFooter: true }, + ]; + return ( - - {borderStyles.map((border) => { - return ( - - {elevations.map((shadow) => { - return ( + + {combinations.map(({ label, hasHeader, hasFooter }) => ( + + {label} + + + Without dividers + {sizes.map((size) => ( - ); - })} + key={size as string} + variant="outlined" + size={size} + data-testid={`card-${label}-${size as string}`} + > + {hasHeader && ( + + Header ({size as string}) + + )} + + Body content for the {size as string} size. + + {hasFooter && ( + + + + + + + )} + + ))} + + + With dividers + {sizes.map((size) => ( + + {hasHeader && ( + <> + + + Header ({size as string}) + + + + + )} + + Body content for the {size as string} size. + + {hasFooter && ( + <> + + + + + + + + + )} + + ))} + - ); - })} + + ))} ); }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); - args: { - children: ( -
- I'm some other flexible content, outside of the compound component -
- ), + await step("All combinations render across sizes", async () => { + for (const size of sizes) { + await expect( + canvas.getByTestId(`card-Body only-${size as string}`) + ).toBeInTheDocument(); + await expect( + canvas.getByTestId(`card-Header + Body-${size as string}`) + ).toBeInTheDocument(); + await expect( + canvas.getByTestId(`card-Header + Body + Footer-${size as string}`) + ).toBeInTheDocument(); + } + }); + + await step("Divided variants render", async () => { + for (const size of sizes) { + await expect( + canvas.getByTestId(`card-Body only-divided-${size as string}`) + ).toBeInTheDocument(); + await expect( + canvas.getByTestId(`card-Header + Body-divided-${size as string}`) + ).toBeInTheDocument(); + await expect( + canvas.getByTestId( + `card-Header + Body + Footer-divided-${size as string}` + ) + ).toBeInTheDocument(); + } + }); + }, +}; + +/** + * Without compound components + * Demonstrates free-form content inside Card.Root + */ +export const WithoutCompound: Story = { + render: () => { + return ( + +
+ I'm some other flexible content, outside of the compound component +
+
+ ); + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Renders free-form content", async () => { + await expect(canvas.getByTestId("card-freeform")).toHaveTextContent( + "I'm some other flexible content" + ); + }); + }, +}; + +/** + * Slot-based accessibility + * Demonstrates automatic ARIA wiring when Heading slot="title" and + * Text slot="description" are used inside the card + */ +export const SlotBasedAccessibility: Story = { + render: () => ( + + + + Project Overview + + + + + This project tracks the quarterly goals for the design system team. + + + + + + + ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const card = canvas.getByTestId("card-a11y"); + + await step("Card has role='article' when slots are used", async () => { + await expect(card).toHaveAttribute("role", "article"); + }); + + await step("Card has aria-labelledby pointing to Heading", async () => { + const heading = canvas.getByText("Project Overview"); + await expect(card.getAttribute("aria-labelledby")).toBe(heading.id); + }); + + await step("Card has aria-describedby pointing to Text", async () => { + const description = canvas.getByText(/quarterly goals/); + await expect(card.getAttribute("aria-describedby")).toBe(description.id); + }); + }, +}; + +/** + * Without slots + * Verifies that cards without slot props remain plain divs with no ARIA role + */ +export const WithoutSlots: Story = { + render: () => ( + + + Regular Title + + + Content without slot props. + + + ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const card = canvas.getByTestId("card-no-slots"); + + await step("Card has no role when slots are not used", async () => { + await expect(card).not.toHaveAttribute("role"); + }); + + await step("Card has no aria-labelledby", async () => { + await expect(card).not.toHaveAttribute("aria-labelledby"); + }); + + await step("Card has no aria-describedby", async () => { + await expect(card).not.toHaveAttribute("aria-describedby"); + }); + }, +}; + +/** + * Title slot only + * Demonstrates using only the title slot without a description slot + */ +export const TitleSlotOnly: Story = { + render: () => ( + + + + Title Only Card + + + + Body content without slot prop. + + + ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const card = canvas.getByTestId("card-title-only"); + + await step("Card gets role='article' with title slot only", async () => { + await expect(card).toHaveAttribute("role", "article"); + }); + + await step("Card has aria-labelledby but no aria-describedby", async () => { + await expect(card).toHaveAttribute("aria-labelledby"); + await expect(card).not.toHaveAttribute("aria-describedby"); + }); }, }; diff --git a/packages/nimbus/src/components/card/card.tsx b/packages/nimbus/src/components/card/card.tsx index 08e25b155..b5a98e324 100644 --- a/packages/nimbus/src/components/card/card.tsx +++ b/packages/nimbus/src/components/card/card.tsx @@ -1,4 +1,4 @@ -import { CardRoot, CardHeader, CardContent } from "./components"; +import { CardRoot, CardHeader, CardBody, CardFooter } from "./components"; /** * Card @@ -13,7 +13,8 @@ import { CardRoot, CardHeader, CardContent } from "./components"; * ```tsx * * Card Title - * Card content goes here + * Card content goes here + * Card actions * * ``` */ @@ -21,15 +22,15 @@ export const Card = { /** * # Card.Root * - * The root component that provides context and styling for the card. - * Must wrap all card parts (Header, Content) to coordinate their behavior. - * Accepts styling variants for padding, border, elevation, and background. + * The root component that provides styling for the card. + * Must wrap all card parts (Header, Body, Footer). + * Accepts `variant` and `size` props for visual treatment and spacing. * * @example * ```tsx - * + * * Title - * Content + * Content * * ``` */ @@ -38,40 +39,56 @@ export const Card = { * # Card.Header * * The header section of the card, typically containing the card title - * or primary heading. Automatically positioned above the card content - * in a consistent layout. + * or primary heading. Renders directly in DOM order. * * @example * ```tsx * * Card Title - * Content here + * Content here * * ``` */ Header: CardHeader, /** - * # Card.Content + * # Card.Body * * The main content area of the card. Contains the primary information, - * body text, or interactive elements. Automatically positioned below - * the card header in a consistent layout. + * body text, or interactive elements. * * @example * ```tsx * * Title - * + * * This is the main card content. - * + * * * ``` */ - Content: CardContent, + Body: CardBody, + /** + * # Card.Footer + * + * The footer section of the card for actions and metadata. + * + * @example + * ```tsx + * + * Title + * Content + * + * + * + * + * ``` + */ + Footer: CardFooter, }; export { CardRoot as _CardRoot, CardHeader as _CardHeader, - CardContent as _CardContent, + CardBody as _CardBody, + CardFooter as _CardFooter, }; diff --git a/packages/nimbus/src/components/card/card.types.ts b/packages/nimbus/src/components/card/card.types.ts index fbfd88691..44cf28719 100644 --- a/packages/nimbus/src/components/card/card.types.ts +++ b/packages/nimbus/src/components/card/card.types.ts @@ -10,14 +10,10 @@ import type { // ============================================================ type CardRecipeProps = { - /** Internal padding variant for the card content */ - cardPadding?: SlotRecipeProps<"nimbusCard">["cardPadding"]; - /** Border style variant (outline, none, etc.) */ - borderStyle?: SlotRecipeProps<"nimbusCard">["borderStyle"]; - /** Elevation shadow level for the card */ - elevation?: SlotRecipeProps<"nimbusCard">["elevation"]; - /** Background style variant (white, gray, etc.) */ - backgroundStyle?: SlotRecipeProps<"nimbusCard">["backgroundStyle"]; + /** Controls padding and gap scaling. */ + size?: SlotRecipeProps<"nimbusCard">["size"]; + /** Controls visual treatment (border, shadow, background). */ + variant?: SlotRecipeProps<"nimbusCard">["variant"]; } & UnstyledProp; // ============================================================ @@ -28,24 +24,35 @@ export type CardRootSlotProps = HTMLChakraProps<"div", CardRecipeProps>; export type CardHeaderSlotProps = HTMLChakraProps<"div">; -export type CardContentSlotProps = HTMLChakraProps<"div">; +export type CardBodySlotProps = HTMLChakraProps<"div">; + +export type CardFooterSlotProps = HTMLChakraProps<"div">; // ============================================================ // MAIN PROPS // ============================================================ +/** Props for the Card.Root component. */ export type CardProps = OmitInternalProps & { children?: React.ReactNode; ref?: React.Ref; [key: `data-${string}`]: unknown; }; +/** Props for the Card.Header component. */ export type CardHeaderProps = OmitInternalProps & { children?: React.ReactNode; ref?: React.Ref; }; -export type CardContentProps = OmitInternalProps & { +/** Props for the Card.Body component. */ +export type CardBodyProps = OmitInternalProps & { + children?: React.ReactNode; + ref?: React.Ref; +}; + +/** Props for the Card.Footer component. */ +export type CardFooterProps = OmitInternalProps & { children?: React.ReactNode; ref?: React.Ref; }; diff --git a/packages/nimbus/src/components/card/components/card.body.tsx b/packages/nimbus/src/components/card/components/card.body.tsx new file mode 100644 index 000000000..ac73e9730 --- /dev/null +++ b/packages/nimbus/src/components/card/components/card.body.tsx @@ -0,0 +1,19 @@ +import { CardBody as CardBodySlot } from "../card.slots"; +import type { CardBodyProps } from "../card.types"; + +/** + * Card.Body - The main content area of the card + * + * @supportsStyleProps + */ +export const CardBody = (props: CardBodyProps) => { + const { ref: forwardedRef, children, ...restProps } = props; + + return ( + + {children} + + ); +}; + +CardBody.displayName = "Card.Body"; diff --git a/packages/nimbus/src/components/card/components/card.content.tsx b/packages/nimbus/src/components/card/components/card.content.tsx deleted file mode 100644 index 2f1d6f8ca..000000000 --- a/packages/nimbus/src/components/card/components/card.content.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useContext, useEffect } from "react"; -import { CardContent as CardContentSlot } from "../card.slots"; -import { CardContext } from "./card.root"; -import type { CardContentProps } from "../card.types"; -import { extractStyleProps } from "@/utils"; - -/** - * Card.Content - The main content area of the card - * - * @supportsStyleProps - */ -export const CardContent = ({ children, ...props }: CardContentProps) => { - const context = useContext(CardContext); - - // Standard pattern: Extract and forward style props - const [styleProps, functionalProps] = extractStyleProps(props); - - useEffect(() => { - if (context) { - const slotElement = ( - - {children} - - ); - // Register it with the parent - context.setContent(slotElement); - - // On unmount, remove it - return () => context.setContent(null); - } - }, [children, styleProps, functionalProps, context]); - - return null; -}; - -CardContent.displayName = "Card.Content"; diff --git a/packages/nimbus/src/components/card/components/card.footer.tsx b/packages/nimbus/src/components/card/components/card.footer.tsx new file mode 100644 index 000000000..645968423 --- /dev/null +++ b/packages/nimbus/src/components/card/components/card.footer.tsx @@ -0,0 +1,19 @@ +import { CardFooter as CardFooterSlot } from "../card.slots"; +import type { CardFooterProps } from "../card.types"; + +/** + * Card.Footer - The footer section for actions and metadata + * + * @supportsStyleProps + */ +export const CardFooter = (props: CardFooterProps) => { + const { ref: forwardedRef, children, ...restProps } = props; + + return ( + + {children} + + ); +}; + +CardFooter.displayName = "Card.Footer"; diff --git a/packages/nimbus/src/components/card/components/card.header.tsx b/packages/nimbus/src/components/card/components/card.header.tsx index 44d7629aa..d8c12f76c 100644 --- a/packages/nimbus/src/components/card/components/card.header.tsx +++ b/packages/nimbus/src/components/card/components/card.header.tsx @@ -1,35 +1,19 @@ -import { useContext, useEffect } from "react"; import { CardHeader as CardHeaderSlot } from "../card.slots"; -import { CardContext } from "./card.root"; import type { CardHeaderProps } from "../card.types"; -import { extractStyleProps } from "@/utils"; /** * Card.Header - The header section of the card * * @supportsStyleProps */ -export const CardHeader = ({ children, ...props }: CardHeaderProps) => { - const context = useContext(CardContext); +export const CardHeader = (props: CardHeaderProps) => { + const { ref: forwardedRef, children, ...restProps } = props; - // Standard pattern: Extract and forward style props - const [styleProps, functionalProps] = extractStyleProps(props); - - useEffect(() => { - if (context) { - const slotElement = ( - - {children} - - ); - // Register it with the parent - context.setHeader(slotElement); - - // On unmount, remove it - return () => context.setHeader(null); - } - }, [children, styleProps, functionalProps, context]); - - return null; + return ( + + {children} + + ); }; + CardHeader.displayName = "Card.Header"; diff --git a/packages/nimbus/src/components/card/components/card.root.tsx b/packages/nimbus/src/components/card/components/card.root.tsx index 2f9961128..d2a1234d2 100644 --- a/packages/nimbus/src/components/card/components/card.root.tsx +++ b/packages/nimbus/src/components/card/components/card.root.tsx @@ -1,62 +1,58 @@ -import { createContext, useMemo, useState, type ReactNode } from "react"; import { useSlotRecipe } from "@chakra-ui/react/styled-system"; +import { useSlotId } from "@react-aria/utils"; +import { + Provider, + HeadingContext, + TextContext, + DEFAULT_SLOT, +} from "react-aria-components"; import { CardRoot as CardRootSlot } from "../card.slots"; import type { CardProps } from "../card.types"; -import { Stack } from "../../stack"; -import { extractStyleProps } from "@/utils"; - -type CardContextValue = { - setHeader: (header: React.ReactNode) => void; - setContent: (content: React.ReactNode) => void; -}; - -export const CardContext = createContext( - undefined -); /** - * Card.Root - The root component that provides context and styling for the card + * Card.Root - The root component that provides context and styling for the card. + * + * When a `` or `` is placed + * inside, the card automatically gains `role="article"` with `aria-labelledby` + * and/or `aria-describedby` pointing to those elements. * * @supportsStyleProps */ export const CardRoot = ({ ref, children, ...props }: CardProps) => { - // Standard pattern: First split recipe variants const recipe = useSlotRecipe({ key: "nimbusCard" }); - const [recipeProps, restRecipeProps] = recipe.splitVariantProps(props); + const [recipeProps, restProps] = recipe.splitVariantProps(props); - // Standard pattern: Second extract style props from remaining - const [styleProps, functionalProps] = extractStyleProps(restRecipeProps); + const titleId = useSlotId(); + const descriptionId = useSlotId(); - const [headerNode, setHeader] = useState(null); - const [contentNode, setContent] = useState(null); - - // Memoize the context value so we don't cause unnecessary re-renders - const contextValue = useMemo( - () => ({ - setHeader, - setContent, - }), - [setHeader, setContent] - ); + const hasSlots = !!titleId || !!descriptionId; + const ariaProps = hasSlots + ? { + role: "article" as const, + "aria-labelledby": titleId || undefined, + "aria-describedby": descriptionId || undefined, + } + : {}; return ( - - - {/* Always render them in this order/layout to protect consumers */} - - {headerNode} - {contentNode} - - - {/* Render all consumer sub-components, including our own */} + + {children} - + ); }; diff --git a/packages/nimbus/src/components/card/components/index.ts b/packages/nimbus/src/components/card/components/index.ts index a00324cec..6407c9225 100644 --- a/packages/nimbus/src/components/card/components/index.ts +++ b/packages/nimbus/src/components/card/components/index.ts @@ -1,3 +1,4 @@ export { CardRoot } from "./card.root"; export { CardHeader } from "./card.header"; -export { CardContent } from "./card.content"; +export { CardBody } from "./card.body"; +export { CardFooter } from "./card.footer"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b3be17b3..9dae50eed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,9 @@ catalogs: '@react-aria/optimize-locales-plugin': specifier: 1.2.0 version: 1.2.0 + '@react-aria/utils': + specifier: 3.33.1 + version: 3.33.1 '@types/react': specifier: ^19.2.14 version: 19.2.14 @@ -553,6 +556,9 @@ importers: '@react-aria/interactions': specifier: 3.28.0 version: 3.28.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@react-aria/utils': + specifier: catalog:react + version: 3.33.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) dequal: specifier: catalog:utils version: 2.0.3 @@ -2459,6 +2465,26 @@ packages: '@react-aria/optimize-locales-plugin@1.2.0': resolution: {integrity: sha512-pYg1IPksrQVLDcUWvqOt8Isdd6YytW0YhN6Ga6PdYxamqObL8aDB4g3qlFTaQReXaRPFMEo+Wepo8Vt3FBwhDA==} + '@react-aria/ssr@3.9.10': + resolution: {integrity: sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==} + engines: {node: '>= 12'} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@react-aria/utils@3.33.1': + resolution: {integrity: sha512-kIx1Sj6bbAT0pdqCegHuPanR9zrLn5zMRiM7LN12rgRf55S19ptd9g3ncahArifYTRkfEU9VIn+q0HjfMqS9/w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@react-stately/flags@3.1.2': + resolution: {integrity: sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==} + + '@react-stately/utils@3.11.0': + resolution: {integrity: sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + '@react-types/shared@3.34.0': resolution: {integrity: sha512-gp6xo/s2lX54AlTjOiqwDnxA7UW79BNvI9dB9pr3LZTzRKCd1ZA+ZbgKw/ReIiWuvvVw/8QFJpnqeeFyLocMcQ==} peerDependencies: @@ -9378,6 +9404,31 @@ snapshots: dependencies: unplugin: 1.16.1 + '@react-aria/ssr@3.9.10(react@19.2.0)': + dependencies: + '@swc/helpers': 0.5.17 + react: 19.2.0 + + '@react-aria/utils@3.33.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@react-aria/ssr': 3.9.10(react@19.2.0) + '@react-stately/flags': 3.1.2 + '@react-stately/utils': 3.11.0(react@19.2.0) + '@react-types/shared': 3.34.0(react@19.2.0) + '@swc/helpers': 0.5.17 + clsx: 2.1.1 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + '@react-stately/flags@3.1.2': + dependencies: + '@swc/helpers': 0.5.17 + + '@react-stately/utils@3.11.0(react@19.2.0)': + dependencies: + '@swc/helpers': 0.5.17 + react: 19.2.0 + '@react-types/shared@3.34.0(react@19.2.0)': dependencies: react: 19.2.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0076c9481..33db1de7c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -68,6 +68,7 @@ catalogs: react-aria-components: 1.17.0 react-stately: 3.46.0 "@react-aria/interactions": 3.28.0 + "@react-aria/utils": 3.33.1 "@react-aria/optimize-locales-plugin": 1.2.0 "@internationalized/date": ^3.12.1 "@internationalized/string": ^3.2.8