diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f7a2b16..c9e54557 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,43 @@ We are following [Semantic Versioning](https://semver.org/spec/v2.0.0.html), as ## Monorepo Package Releases (`toolkit-v*`, `react-v*`) +### 2026-03-25 + +#### @ourfuturehealth/toolkit 4.7.0 (`toolkit-v4.7.0`) + +##### Added + +- Dedicated `card-callout` and `card-do-dont` toolkit components aligned to the current Card family structure +- Dismissible-with-image Card examples in the docs site and examples index +- Responsive `gap-x`, `gap-y`, and `gap` spacing helpers for flex/grid component work + +##### Changed + +- Realigned the base `card` component to the current Figma structure across basic, dismissible, clickable, clickable-action, clickable-numeric, and icon-led variants +- Updated the Card family after the v4.5.0 spacing and typography hard cut, including responsive token alignment and shared labelled-panel spacing +- Aligned the dismissible Card hit-zone with the latest Figma corner-target model +- Updated Card trailing icons to be neutral by default and support an explicit colour override for monochrome icons +- Preserved legacy toolkit APIs such as `warningCallout()`, `list()`, and old Card inputs as deprecated compatibility paths for existing consumers +- Updated Card-family documentation and examples to prefer the new Card family macros and options +- Refined Card-family docs clarity across site docs, macro options, toolkit READMEs, and Storybook-facing guidance + +#### @ourfuturehealth/react-components 0.5.0 (`react-v0.5.0`) + +##### Added + +- New Card family components using the current API only: + - `Card` + - `CardCallout` + - `CardDoDont` +- Storybook coverage for base Card variants, Card / Callout, and Card / Do & Don’t +- Unit and accessibility coverage for the Card family + +##### Changed + +- Bundled the toolkit icon sprite for React and Storybook consumers so Card icons render without a separately hosted `/assets/icons/icon-sprite.svg` +- Refined Card-family Storybook docs, controls behaviour, and examples for easier manual QA +- Updated Card icon stories to expose glyph and colour controls that match the current component behavior + ### 2026-03-24 #### @ourfuturehealth/toolkit 4.6.0 (`toolkit-v4.6.0`) diff --git a/UPGRADING.md b/UPGRADING.md index bddf0513..ec542fca 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -8,6 +8,7 @@ This guide provides detailed migration instructions for upgrading between versio | Version | Date | Breaking Changes | Migration Complexity | | ------------------------------------------------------- | ------------- | --------------------- | ------------------------------------- | +| [v4.7.0 / React v0.5.0](#upgrading-to-v470--react-v050) | March 2026 | Card family realignment | 🟡 Medium - API migration recommended | | [v4.6.0 / React v0.4.0](#upgrading-to-v460--react-v040) | March 2026 | Tag default + naming | 🟡 Medium - Search/replace recommended | | [v4.5.0](#upgrading-to-v450) | March 2026 | Spacing and typography API changes | 🟡 Medium - Replace legacy APIs and recheck overrides | | [v4.3.0 / React v0.2.0](#upgrading-to-v430--react-v020) | March 2026 | Button variant naming | 🟡 Medium - Find/replace required | @@ -16,6 +17,56 @@ This guide provides detailed migration instructions for upgrading between versio --- +## Upgrading to v4.7.0 / React v0.5.0 + +**Released:** March 2026 +**Affected packages:** + +- `@ourfuturehealth/toolkit` v4.7.0+ +- `@ourfuturehealth/react-components` v0.5.0+ + +### Breaking Changes + +None. + +### Card Family Realignment + +The Card family has been aligned to the current design-system split: + +- `card` remains the base component +- `warning-callout` has moved to `card-callout` +- `do-dont-list` has moved to `card-do-dont` + +#### Toolkit consumers + +Existing toolkit consumers should continue to work without immediate code changes: + +- `warningCallout()` still renders, but it is deprecated +- `list()` still renders, but it is deprecated +- legacy `card` inputs such as `clickable`, `feature`, `type`, and `HTML` still render, but they are deprecated + +For new work, migrate to the new APIs: + +| Deprecated toolkit API | Preferred API | +| ---------------------- | ------------- | +| `warningCallout()` | `cardCallout({ variant: 'warning', ... })` | +| `list({ type: 'tick'|'cross' })` | `cardDoDont({ type: 'do'|'dont', ... })` | +| `card({ clickable: true })` | `card({ variant: 'clickable' })` | +| `cardWithIcon()` | `card({ icon: { ... } })` | +| `card({ HTML: ... })` | `card({ descriptionHtml: ... })` | + +#### React consumers + +React now exposes the Card family directly: + +- `Card` +- `CardCallout` +- `CardDoDont` + +The nested `Card` `tag` prop uses the public React `Tag` API, so tag content is passed with `children` plus optional Tag props such as `variant` and `className`. + +--- + ## Upgrading to v4.6.0 / React v0.4.0 **Released:** March 2026 @@ -119,7 +170,7 @@ After updating your code, verify: ## Upgrading to v4.5.0 -**Released:** March 2026 +**Released:** March 2026 **Affected packages:** - `@ourfuturehealth/toolkit` v4.5.0+ @@ -466,7 +517,7 @@ Install from specific release tag (recommended): ```json { "dependencies": { - "@ourfuturehealth/toolkit": "github:ourfuturehealth/design-system-toolkit#toolkit-v4.0.0:packages/toolkit" + "@ourfuturehealth/toolkit": "github:ourfuturehealth/design-system-toolkit#toolkit-v4.5.0:packages/toolkit" } } ``` @@ -668,7 +719,7 @@ Server-side rendering templates for generating HTML: - **Packages can be released independently** - Tag format: - Toolkit: `toolkit-v*` (e.g., `toolkit-v4.0.0`) - - React: `react-v*` (e.g., `react-v0.0.1`) + - React: `react-v*` (e.g., `react-v0.2.0`) ### Installing Individual Packages @@ -679,7 +730,7 @@ Each package in the monorepo can be installed independently: ```json { "dependencies": { - "@ourfuturehealth/toolkit": "github:ourfuturehealth/design-system-toolkit#toolkit-v4.0.0:packages/toolkit" + "@ourfuturehealth/toolkit": "github:ourfuturehealth/design-system-toolkit#toolkit-v4.5.0:packages/toolkit" } } ``` @@ -689,7 +740,7 @@ Each package in the monorepo can be installed independently: ```json { "dependencies": { - "@ourfuturehealth/react-components": "github:ourfuturehealth/design-system-toolkit#react-v0.0.1:packages/react-components" + "@ourfuturehealth/react-components": "github:ourfuturehealth/design-system-toolkit#react-v0.5.0:packages/react-components" } } ``` diff --git a/docs/consuming-react-components.md b/docs/consuming-react-components.md index f61f0de2..84d15fc9 100644 --- a/docs/consuming-react-components.md +++ b/docs/consuming-react-components.md @@ -1,5 +1,3 @@ -# WORK IN PROGRESS (Needs to be updated) - # Consuming Our Future Health React Components This guide explains how to consume the `@ourfuturehealth/react-components` package in your React applications. @@ -19,18 +17,18 @@ Currently, the React components are not published to npm registry. Install direc ### Using pnpm (recommended) ```bash -pnpm add @ourfuturehealth/react-components@github:ourfuturehealth/design-system-toolkit#react-v0.0.1:packages/react-components +pnpm add @ourfuturehealth/react-components@github:ourfuturehealth/design-system-toolkit#react-v0.5.0:packages/react-components ``` ### Using npm ```bash -npm install @ourfuturehealth/react-components@github:ourfuturehealth/design-system-toolkit#react-v0.0.1:packages/react-components +npm install @ourfuturehealth/react-components@github:ourfuturehealth/design-system-toolkit#react-v0.5.0:packages/react-components ``` ### Version Pinning -- **Production**: Use specific version tags (e.g., `#react-v0.0.1`) +- **Production**: Use specific version tags (e.g., `#react-v0.5.0`) - **Development**: You can use `#main:packages/react-components` but ensure your lockfile pins a specific commit **package.json example:** @@ -38,7 +36,7 @@ npm install @ourfuturehealth/react-components@github:ourfuturehealth/design-syst ```json { "dependencies": { - "@ourfuturehealth/react-components": "github:ourfuturehealth/design-system-toolkit#react-v0.0.1:packages/react-components", + "@ourfuturehealth/react-components": "github:ourfuturehealth/design-system-toolkit#react-v0.5.0:packages/react-components", "react": "^19.2.4", "react-dom": "^19.2.4" } @@ -112,10 +110,13 @@ To add a new custom React theme stylesheet export, follow `docs/theming/adding-a The React components package currently provides the following components: -- `Button` - Call-to-action buttons -- `TextInput` - Text input fields - -**More components coming soon!** We're actively developing additional React wrappers for the design system toolkit components. +- `Button` - Call-to-action buttons and links +- `TextInput` - Text input fields with hint and error support +- `ErrorSummary` - Page-level validation summaries with linked errors +- `Tag` - Status tags aligned with toolkit Tag variants +- `Card` - Content presentation cards for summaries, status, and next steps +- `CardCallout` - Feedback-style callout cards for informational, warning, success, and error messages +- `CardDoDont` - Positive and negative recommendation lists For complete component documentation and live examples, run Storybook: diff --git a/docs/prompts/README.md b/docs/prompts/README.md new file mode 100644 index 00000000..a6938f85 --- /dev/null +++ b/docs/prompts/README.md @@ -0,0 +1,22 @@ +# Prompt Workflows + +Use these prompts as a staged workflow when updating a component. + +## Recommended Order + +1. `component-update-template-prompt.md` + Use for the full implementation workflow: Figma analysis, toolkit work, React parity, tests, Storybook, and documentation. +2. `component-validation-qa-template-prompt.md` + Use once implementation is mostly done and you want an interactive manual QA pass with exact URLs and step-by-step validation. +3. `component-pr-readiness-template-prompt.md` + Use after QA passes, or when you want the final repo-wide cleanup, release-doc refresh, commit prep, and branch handoff. + +## Why These Are Separate + +The implementation prompt and the two finish-up prompts ask the agent to work in different modes: + +- implementation work should stay focused on design analysis and code changes +- interactive QA should pause after each step and wait for pass/fail feedback +- PR-readiness work should keep moving and clean up repo-wide surfaces without stopping after every check + +Keeping them separate makes the workflow easier to follow and reduces prompt ambiguity. diff --git a/docs/prompts/component-pr-readiness-template-prompt.md b/docs/prompts/component-pr-readiness-template-prompt.md index ac8ec3b6..38610834 100644 --- a/docs/prompts/component-pr-readiness-template-prompt.md +++ b/docs/prompts/component-pr-readiness-template-prompt.md @@ -53,6 +53,19 @@ Workflow I want you to follow: - confirm deprecated compatibility paths exist only where intended - confirm toolkit vs React consumer expectations are documented clearly - confirm Storybook controls policy and prop docs are still coherent after any late changes + - confirm no interactive single-component stories are still relying on raw JSON controls for stable nested props when clearer story-specific controls would be more usable + - confirm no story is exposing controls for values the component visibly ignores or overrides + - if the component was touched by a spacing/typography token migration, do a final spot-check for same-number static-token substitutions where Figma expected responsive tokens + - do a final spot-check for accidental semantic-element inheritance (`p`, `ul`, `li`, `h*`, `a`) that may have reintroduced wrong spacing or typography + - confirm release/version metadata is internally consistent wherever this branch claims a release number or tag: + - `packages/*/package.json` version fields + - `CHANGELOG.md` + - `UPGRADING.md` + - release/versioning strategy docs + - PR title/body if it mentions planned release versions or tags + - if docs claim a package release version or tag that is not reflected in the relevant `package.json`, treat that as a PR-readiness failure and fix it before sign-off + - if the component was touched by a spacing/typography token migration, do a final spot-check for same-number static-token substitutions where Figma expected responsive tokens + - do a final spot-check for accidental semantic-element inheritance (`p`, `ul`, `li`, `h*`, `a`) that may have reintroduced wrong spacing or typography - confirm release/version metadata is internally consistent wherever this branch claims a release number or tag: - `packages/*/package.json` version fields - `CHANGELOG.md` diff --git a/docs/prompts/component-update-template-prompt.md b/docs/prompts/component-update-template-prompt.md index d551f2ed..9b81b663 100644 --- a/docs/prompts/component-update-template-prompt.md +++ b/docs/prompts/component-update-template-prompt.md @@ -71,6 +71,14 @@ After implementation is mostly done, move to the dedicated validation and PR-rea - Typography tokens - Interactive states (hover, focus, active, disabled) - Accessibility annotations +- Build a **token translation table** for the component before implementation: + - list each meaningful subelement (container, header/label, body, list, helper text, link/action, icon gap, hit target, etc.) + - record the exact Figma token used for spacing, typography, radius, border, and icon sizing + - mark each token as `static` or `responsive` + - record the actual mobile / tablet / desktop values when the token is responsive +- Do not assume that a same-number static token is equivalent to a responsive Figma token + - example: `ofh/space/vertical/16` is **not** automatically the same as `$ofh-size-16` + - example: `paragraph/md` in Figma should map to the responsive typography mixin, not rely on inherited browser or global text styles ### 2. Current Implementation Review @@ -82,6 +90,10 @@ After implementation is mostly done, move to the dedicated validation and PR-rea - Review `{component-name}.js` (if exists) - behavior - Review `README.md` - documentation completeness - Review `tests/integration/{component-name}.test.js` (if exists) +- Review how the component interacts with **global semantic element styles** + - check `p`, `ul`, `ol`, `li`, `h1-h6`, `a`, `button`, and similar elements used inside the component + - identify where the component is intentionally relying on global typography or list spacing + - identify where those inherited styles must be overridden to match Figma exactly **React (`packages/react-components/src/components/{ComponentName}/`):** @@ -162,12 +174,24 @@ Review the entire component implementation against design system standards: - [ ] All border-radius values → Check against `$ofh-radius-*` tokens - [ ] All border widths → Check against `$ofh-stroke-weight-*` tokens - [ ] All shadow values → Check against `$ofh-shadow-*` tokens +- [ ] For every spacing and typography token in Figma, map it to the **correct code primitive**: + - responsive spacing helper + - responsive typography mixin + - static size token + - iconography token +- [ ] Do not replace responsive Figma tokens with same-number static tokens just because the desktop value matches +- [ ] Audit invisible layout/hit-area spacing too, not just visible padding and icon size +- [ ] Audit semantic-element inheritance: + - check whether global `ul > li`, `p`, `h*`, or link styles are adding margins/typography the component did not ask for + - add explicit overrides when Figma requires component-specific spacing or typography **Responsive Pattern Audit:** - [ ] Manual media queries for spacing → Check if `@include ofh-responsive-padding/margin()` can be used - [ ] Manual media queries for typography → Check if responsive typography mixin handles this - [ ] Inconsistent breakpoint usage → Check against `$ofh-breakpoints` +- [ ] When a responsive helper **cannot** express the exact Figma values, document why and use explicit breakpoint rules intentionally +- [ ] Verify every responsive token hotspot at mobile, tablet, and desktop instead of only checking the desktop screenshot **Focus State Audit:** @@ -430,6 +454,7 @@ it('should be keyboard accessible', async () => { - ✅ Proper argTypes documentation - ✅ Component description from design system - ✅ Auto-generated prop table (via TypeScript) +- ✅ Story controls that are ergonomic and honest about what the component actually supports **Story Pattern:** @@ -524,10 +549,21 @@ Review the component's user-facing documentation surfaces and make sure they exp - Controls rule: - keep controls enabled for `interactive single-component example` stories where the controls map cleanly to the rendered output - disable controls for `showcase/comparison` or `behavior/demo` stories when controls would be misleading or do not control the rendered output meaningfully +- For structured or nested props, do not default to raw JSON editing when a clearer control model is available + - examples: `tag`, `icon`, `dismissButton`, `actionLink`, `metadataItems` + - when the story only needs a stable subset of that object shape, add story-only args such as `tagText`, `tagVariant`, `iconName`, `iconSize`, `actionHref`, or similar and map them to the real prop in `render` + - hide the raw object control for that story when the story-only controls are the intended interaction path + - only keep raw object editing visible when the JSON shape itself is what consumers need to learn +- Use the most specific control type available for constrained values + - `select`, `radio`, `boolean`, `text`, or `number` instead of generic object editors whenever the value set is finite or easy to model +- Do not expose controls for prop fields that the component visually ignores or overrides + - example: if a component slot forces a fixed icon size or color, do not expose a misleading size or tone control for that story unless the story is explicitly demonstrating that constraint - Check for misleading cases such as: - `All variants` stories showing one prop panel that does not affect the displayed variants - `Keyboard navigation` stories showing controls that do not apply to the demo content - multi-example stories where the controls affect none of the rendered examples + - nested object props that require raw JSON editing even though the story only needs a text/select/boolean subset + - controls for values that appear editable in Storybook but do not produce any visual or behavioral change in the rendered story **Prop documentation clarity review (MANDATORY):** @@ -562,6 +598,8 @@ Review the component's user-facing documentation surfaces and make sure they exp **Output required before moving to QA:** - Confirm that each story has an intentional controls policy +- Confirm that structured props are not exposed as raw JSON when a clearer story-specific control model would be more usable +- Confirm that no story exposes controls for values the component visibly ignores or overrides - Confirm that prop descriptions are written in plain language, not just implementation language - Confirm that Storybook docs, site docs, macro options, and README describe the same API consistently @@ -589,10 +627,14 @@ Before moving to the validation prompt, answer these checks explicitly: - [ ] Are any Storybook controls misleading for any story? - [ ] Does every story have an intentional controls policy? +- [ ] Are any nested or structured props still exposed as raw JSON even though the story could offer clearer text/select/boolean controls instead? +- [ ] Do any story controls expose values that the component visually ignores or overrides? - [ ] Are `heading`, `headingLevel`, and any HTML-overrides explained clearly where relevant? - [ ] Are advanced props such as `classes`, `className`, `attributes`, and `ref` clearly described as advanced/integration props where appropriate? - [ ] Do Storybook docs, site docs, macro options, and README describe the same API consistently? - [ ] Are showcase/demo stories clearly non-interactive where appropriate? +- [ ] Has every meaningful spacing/typography token from Figma been checked against the actual mobile / tablet / desktop values in code? +- [ ] Have semantic-element defaults (`p`, `ul`, `li`, `h*`, `a`) been checked so the component is not accidentally inheriting the wrong margins or typography? If any answer is "no", fix it before moving to the QA prompt. @@ -630,6 +672,7 @@ pnpm storybook 3. Test with screen reader (if complex component) 4. Visual comparison with Figma specs 5. Test in `example-react-consumer-app` +6. For components using responsive spacing or typography tokens, spot-check mobile, tablet, and desktop values in DevTools for the highest-risk subelements ### Documentation Review diff --git a/docs/prompts/component-validation-qa-template-prompt.md b/docs/prompts/component-validation-qa-template-prompt.md index 2a1dbc96..f3b15a94 100644 --- a/docs/prompts/component-validation-qa-template-prompt.md +++ b/docs/prompts/component-validation-qa-template-prompt.md @@ -37,6 +37,7 @@ Workflow I want you to follow: - exact Storybook docs/story URLs - a short pass/fail table - any Figma comparison notes I should keep in mind + - any breakpoint-specific token expectations I should keep in mind for mobile / tablet / desktop 4. Then walk me through that QA script one step at a time. 5. After each step, stop and wait for my response in the format: - `pass` @@ -58,6 +59,9 @@ Important constraints: - If Storybook examples are misleading or make validation harder, improve them. - If docs/examples are missing what is needed to validate behavior, improve them too. - Treat misleading Storybook controls or vague prop documentation as implementation misses to be fixed before QA is considered complete. +- Treat raw JSON controls for stable nested props as implementation misses when the story could reasonably offer clearer text/select/boolean controls instead. +- Treat controls for values the component visibly ignores or overrides as implementation misses to be fixed before QA is considered complete. +- Treat responsive token mismatches or accidental inherited element styles (`p`, `ul`, `li`, `h*`, `a`) as implementation misses to be fixed before QA is considered complete. - If implementation used a temporary internal adapter because a dependency was missing, call that out clearly during QA and include the affected surfaces in the validation script. - Include exact URLs for every QA step. - Keep the flow interactive. Do not skip ahead after giving me a step. diff --git a/docs/release-versioning-strategy.md b/docs/release-versioning-strategy.md index 56198a42..47df42ff 100644 --- a/docs/release-versioning-strategy.md +++ b/docs/release-versioning-strategy.md @@ -87,8 +87,10 @@ This table is a visual aid for pre-monorepo vs post-monorepo release identificat | 11 | `toolkit-v4.4.0` | `4.4.0` | N/A | Monorepo | Released | | 12 | `react-v0.3.0` | N/A | `0.3.0` | Monorepo | Released | | 13 | `toolkit-v4.5.0` | `4.5.0` | N/A | Monorepo | Released | -| 14 | `toolkit-v4.6.0` | `4.6.0` | N/A | Monorepo | Planned in this branch | -| 15 | `react-v0.4.0` | N/A | `0.4.0` | Monorepo | Planned in this branch | +| 14 | `toolkit-v4.6.0` | `4.6.0` | N/A | Monorepo | Released | +| 15 | `react-v0.4.0` | N/A | `0.4.0` | Monorepo | Released | +| 16 | `toolkit-v4.7.0` | `4.7.0` | N/A | Monorepo | Planned in this branch | +| 17 | `react-v0.5.0` | N/A | `0.5.0` | Monorepo | Planned in this branch | ## Release Output Expectations diff --git a/packages/react-components/README.md b/packages/react-components/README.md index e47e2245..6fae8f97 100644 --- a/packages/react-components/README.md +++ b/packages/react-components/README.md @@ -7,19 +7,27 @@ React component library for the OFH Design System. Install from the monorepo using a package-specific Git tag and subdirectory. ```bash -pnpm add @ourfuturehealth/react-components@github:ourfuturehealth/design-system-toolkit#react-v0.0.1:packages/react-components +pnpm add @ourfuturehealth/react-components@github:ourfuturehealth/design-system-toolkit#react-v0.5.0:packages/react-components ``` or ```bash -npm install @ourfuturehealth/react-components@github:ourfuturehealth/design-system-toolkit#react-v0.0.1:packages/react-components +npm install @ourfuturehealth/react-components@github:ourfuturehealth/design-system-toolkit#react-v0.5.0:packages/react-components ``` ## Usage ```tsx -import { Button, ErrorSummary, Tag, TextInput } from '@ourfuturehealth/react-components'; +import { + Button, + Card, + CardCallout, + CardDoDont, + ErrorSummary, + Tag, + TextInput, +} from '@ourfuturehealth/react-components'; import '@ourfuturehealth/react-components/styles/participant'; function App() { @@ -36,6 +44,9 @@ function App() { /> Beta + + + ); @@ -92,6 +103,45 @@ An error summary component for page-level validation messages. - `attributes`: object - `idPrefix`: string +### Card + +A content-presentation card for summary, status, and next-step content. + +**Props:** + +- `variant`: 'basic' | 'clickable' +- `heading`, `headingHtml`, `headingLevel` +- `description`, `descriptionHtml` +- `icon`, `dismissButton`, `number`, `tag` +- `metadataItems`, `helperText`, `helperHtml`, `actionLink` +- `imgURL`, `imgALT` + +`tag` uses the React `Tag` component API, so nested tag content is passed with `children` plus optional Tag props such as `variant` and `className`. + +### CardCallout + +A feedback-style card for contextual info, warning, success, and error content. + +**Props:** + +- `variant`: 'info' | 'warning' | 'success' | 'error' +- `heading`, `headingHtml`, `headingLevel` +- `text` or `html` + +### CardDoDont + +A card for short do and don’t recommendation lists. + +**Props:** + +- `type`: 'do' | 'dont' +- `heading`, `headingLevel` +- `items` + +### Icons + +React components bundle the toolkit icon sprite automatically. Consumers do not need to copy `/assets/icons/icon-sprite.svg` into their own app to render Card icons. + ### Tag A status tag component with a simple React API that maps to the toolkit variants. diff --git a/packages/react-components/package.json b/packages/react-components/package.json index 71347589..7b783249 100644 --- a/packages/react-components/package.json +++ b/packages/react-components/package.json @@ -1,6 +1,6 @@ { "name": "@ourfuturehealth/react-components", - "version": "0.4.0", + "version": "0.5.0", "type": "module", "description": "React component library for OFH Design System", "packageManager": "pnpm@10.29.2", diff --git a/packages/react-components/src/components/Button/Button.stories.tsx b/packages/react-components/src/components/Button/Button.stories.tsx index 084d67d8..64dfd58a 100644 --- a/packages/react-components/src/components/Button/Button.stories.tsx +++ b/packages/react-components/src/components/Button/Button.stories.tsx @@ -9,7 +9,7 @@ const meta: Meta = { docs: { description: { component: - 'A flexible button component based on the OFH Design System with multiple variants and states. Can render as a button or anchor element.', + 'A flexible button component based on the OFH Design System with multiple variants and states. If `href` is provided, the component renders as an anchor instead of a button. The `variant` changes the visual prominence only, not the semantic element.', }, }, }, @@ -25,22 +25,52 @@ const meta: Meta = { 'text', 'text-inverted', ], - description: 'Visual style variant of the button', + description: + 'Changes the visual style and prominence of the button. It does not change whether the component renders as a button or link.', }, children: { control: 'text', - description: 'Button content/text', + description: 'Visible label content for the button or link.', }, href: { control: 'text', - description: 'URL to navigate to (renders as anchor tag)', + description: + 'Navigation destination. When this is set, the component renders as an anchor (``) instead of a button.', + }, + type: { + control: 'select', + options: ['button', 'submit', 'reset'], + description: + 'Button type for real ` @@ -115,13 +155,6 @@ export const AllVariants: Story = { ), - parameters: { - docs: { - description: { - story: 'All available button variants in the OFH Design System.', - }, - }, - }, globals: { backgrounds: { value: 'dark' }, }, @@ -146,6 +179,16 @@ export const AsLink: Story = { // Link variants showcase export const AllLinkVariants: Story = { + parameters: { + controls: { + disable: true, + }, + docs: { + description: { + story: 'All button variants rendered as links with href attributes.', + }, + }, + }, render: () => (
), - parameters: { - docs: { - description: { - story: 'All button variants rendered as links with href attributes.', - }, - }, - }, globals: { backgrounds: { value: 'dark' }, }, @@ -182,6 +218,17 @@ export const AllLinkVariants: Story = { // Keyboard navigation demo export const KeyboardNavigation: Story = { + parameters: { + controls: { + disable: true, + }, + docs: { + description: { + story: + 'Demonstration of keyboard accessibility. All buttons are keyboard navigable and follow standard interaction patterns.', + }, + }, + }, render: () => (

@@ -196,18 +243,21 @@ export const KeyboardNavigation: Story = {

), +}; + +// Form usage example +export const InForm: Story = { parameters: { + controls: { + disable: true, + }, docs: { description: { story: - 'Demonstration of keyboard accessibility. All buttons are keyboard navigable and follow standard interaction patterns.', + 'Example of buttons used in a form context with submit and cancel actions.', }, }, }, -}; - -// Form usage example -export const InForm: Story = { render: () => (
{ @@ -239,12 +289,4 @@ export const InForm: Story = {
), - parameters: { - docs: { - description: { - story: - 'Example of buttons used in a form context with submit and cancel actions.', - }, - }, - }, }; diff --git a/packages/react-components/src/components/Card/Card.stories.tsx b/packages/react-components/src/components/Card/Card.stories.tsx new file mode 100644 index 00000000..abb680dd --- /dev/null +++ b/packages/react-components/src/components/Card/Card.stories.tsx @@ -0,0 +1,554 @@ +import type { ComponentProps } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import iconManifest from '@ourfuturehealth/toolkit/assets/icons/manifest.json'; +import { Card } from './Card'; +import type { TagVariant } from '../Tag'; + +type CardStoryArgs = ComponentProps & { + tagText?: string; + tagVariant?: TagVariant; + iconName?: string; + iconColor?: string; + metadataItem1Icon?: string; + metadataItem1Text?: string; + metadataItem2Icon?: string; + metadataItem2Text?: string; + metadataItem3Icon?: string; + metadataItem3Text?: string; + actionLinkText?: string; + actionLinkHref?: string; +}; + +const tagVariantOptions: TagVariant[] = [ + 'neutral', + 'brand', + 'blue', + 'green', + 'yellow', + 'red', +]; + +const iconNameOptions = iconManifest.icons.map(({ name }) => name); + +const renderCard = ({ + tagText, + tagVariant = 'blue', + iconName, + iconColor, + metadataItem1Icon, + metadataItem1Text, + metadataItem2Icon, + metadataItem2Text, + metadataItem3Icon, + metadataItem3Text, + actionLinkText, + actionLinkHref, + dismissButton, + tag, + icon, + metadataItems, + actionLink, + ...args +}: CardStoryArgs) => { + const resolvedTag = + tagText !== undefined + ? tagText + ? { children: tagText, variant: tagVariant } + : undefined + : tag; + const resolvedIcon = iconName + ? { + ...icon, + name: iconName, + size: 32 as const, + color: iconColor, + } + : icon; + const resolvedMetadataItems = + metadataItem1Text !== undefined || + metadataItem2Text !== undefined || + metadataItem3Text !== undefined + ? [ + metadataItem1Text + ? { + icon: metadataItem1Icon ?? 'FmdGoodOutlined', + text: metadataItem1Text, + } + : null, + metadataItem2Text + ? { + icon: metadataItem2Icon ?? 'CalendarTodayOutlined', + text: metadataItem2Text, + } + : null, + metadataItem3Text + ? { + icon: metadataItem3Icon ?? 'AccessTime', + text: metadataItem3Text, + } + : null, + ].filter((item) => item !== null) + : metadataItems; + const resolvedActionLink = + actionLinkText !== undefined || actionLinkHref !== undefined + ? actionLinkText && actionLinkHref + ? { + ...actionLink, + text: actionLinkText, + href: actionLinkHref, + } + : undefined + : actionLink; + + return ( +
+ +
+ ); +}; + +const meta: Meta = { + title: 'Components/Card/Basic', + component: Card, + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'Use a card to present short, scannable summaries of content, status or next steps. `headingLevel` changes the semantic heading tag used for the card title, but does not change the visual styling on its own. The React component mirrors the toolkit Card family markup and classes.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + variant: { + control: 'select', + options: ['basic', 'clickable'], + description: + 'Changes the card behavior. `basic` is a static content card. `clickable` keeps a real link inside the card and expands the card hit area to that primary link.', + }, + heading: { + control: 'text', + description: + 'Main heading content for the card. This is usually the most prominent text and can include a link when `href` is provided.', + }, + headingHtml: { + control: false, + description: + 'Trusted HTML to render inside the heading. When this is provided, it replaces `heading`.', + table: { + category: 'Advanced', + }, + }, + headingClasses: { + control: false, + description: + 'Extra classes for the heading element. Use this when you need to change the visual heading size while keeping the same semantic `headingLevel`.', + table: { + category: 'Advanced', + }, + }, + headingLevel: { + control: 'select', + options: [2, 3, 4, 5, 6], + description: + 'Changes the semantic heading element for the title, for example `h2` or `h3`. This helps the card fit the page heading hierarchy, but does not change the visual appearance by itself.', + }, + description: { + control: 'text', + description: 'Plain text body copy shown below the heading.', + }, + descriptionHtml: { + control: false, + description: + 'Trusted HTML content for the card body. When this is provided, it replaces `description`.', + table: { + category: 'Advanced', + }, + }, + href: { + control: 'text', + description: + 'Primary link destination. In clickable cards, this makes the heading link the main interactive target for the whole card.', + }, + icon: { + control: 'object', + description: + 'Optional trailing icon shown to the right of the card content. The card keeps this slot at a fixed 32px size. Monochrome icons can be tinted with `icon.color`, while icons with baked-in fills keep their own colours.', + }, + dismissButton: { + control: false, + description: + 'Optional dismiss button configuration. This renders a close button in the card top-right corner. The label is announced to assistive technology, but does not change the visible icon.', + }, + number: { + control: 'text', + description: + 'Large numeric value used in dashboard-style cards where the number is the main message.', + }, + tag: { + control: 'object', + description: + 'Optional contextual tag shown above the body copy. This uses the React `Tag` API, for example `children`, `variant`, and `className`.', + }, + metadataItems: { + control: 'object', + description: + 'Optional metadata rows with an icon and text, used for supporting details like location, date, or reading time.', + }, + helperText: { + control: 'text', + description: + 'Supporting helper text shown after the main body and metadata.', + }, + helperHtml: { + control: false, + description: + 'Trusted HTML helper content shown after the main body and metadata. When this is provided, it replaces `helperText`.', + table: { + category: 'Advanced', + }, + }, + actionLink: { + control: 'object', + description: + 'Optional secondary action link shown at the bottom of the card. In clickable numeric cards without `href`, this can act as the primary link target.', + }, + imgURL: { + control: 'text', + description: 'Optional image shown at the top of the card.', + }, + imgALT: { + control: 'text', + description: + 'Alternative text for the image. Use an empty string when the image is decorative.', + }, + classes: { + control: false, + description: + 'Toolkit-parity alias for adding extra classes to the root element. In React-only usage, prefer `className`.', + table: { + category: 'Advanced', + }, + }, + className: { + control: false, + description: + 'Adds extra classes to the root card element for layout or integration hooks. It does not change the built-in variants by itself.', + table: { + category: 'Advanced', + }, + }, + ref: { + control: false, + description: + 'React ref for the root `
` element. Use this only when you need direct access to the rendered DOM node.', + table: { + category: 'Advanced', + }, + }, + }, + args: { + heading: 'Card heading', + description: 'Card description', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = { + args: { + heading: 'If you need help now, but it’s not an emergency', + description: + 'Go to 111.nhs.uk or call 111 for urgent help that does not need emergency care.', + }, + render: (args) => ( +
+ +
+ ), +}; + +export const BasicDismissible: Story = { + args: { + heading: 'Update available', + description: 'A newer version of this content is available for review.', + dismissButton: { + label: 'Dismiss update message', + }, + }, + render: renderCard, +}; + +export const BasicDismissibleWithImage: Story = { + args: { + heading: 'Updated guidance available', + description: + 'A newer version of this guidance is available. Review the latest content when you are ready.', + imgURL: + 'https://assets.nhs.uk/prod/images/A_0218_exercise-main_FKW1X7.width-690.jpg', + imgALT: '', + dismissButton: { + label: 'Dismiss guidance update', + }, + }, + render: renderCard, +}; + +export const BasicWithIcon: Story = { + args: { + heading: 'Profile complete', + description: 'You’ve completed all the required profile details.', + iconName: 'Done', + iconColor: '#00725F', + }, + argTypes: { + icon: { + control: false, + table: { + disable: true, + }, + }, + iconName: { + control: 'select', + options: iconNameOptions, + description: + 'Glyph name for the fixed 32px trailing icon slot.', + }, + iconColor: { + control: 'color', + description: + 'Colour applied to monochrome icons in the trailing slot. Icons with baked-in fills may ignore it.', + }, + }, + parameters: { + docs: { + description: { + story: + 'Use the `icon` prop to add a supporting icon to a basic card. Success is one common use, but the same pattern can support other short icon-led messages too. This story lets you swap the glyph and tint monochrome icons, while the Card component keeps the trailing icon slot at its built-in 32px size.', + }, + }, + }, + render: renderCard, +}; + +export const Clickable: Story = { + args: { + variant: 'clickable', + href: '#card-clickable', + heading: 'Introduction to care and support', + description: + 'A quick guide for people who have care and support needs and their carers.', + }, + render: (args) => ( +
+ +
+ ), +}; + +export const ClickableAction: Story = { + args: { + variant: 'clickable', + href: '#card-action', + heading: 'Introduction to care and support', + tagText: 'New', + tagVariant: 'blue', + description: + 'A quick guide for people who have care and support needs and their carers.', + metadataItem1Icon: 'FmdGoodOutlined', + metadataItem1Text: 'Online', + metadataItem2Icon: 'CalendarTodayOutlined', + metadataItem2Text: 'Updated today', + metadataItem3Icon: 'AccessTime', + metadataItem3Text: '5 minute read', + helperText: 'Recommended for new participants.', + iconName: 'ArrowCircleRightColour', + iconColor: '#FFC62C', + }, + argTypes: { + tag: { + control: false, + table: { + disable: true, + }, + }, + tagText: { + control: 'text', + description: 'Text content for the supporting tag.', + }, + tagVariant: { + control: 'select', + options: tagVariantOptions, + description: 'Visual style variant for the tag.', + }, + icon: { + control: false, + table: { + disable: true, + }, + }, + metadataItems: { + control: false, + table: { + disable: true, + }, + }, + metadataItem1Icon: { + control: 'select', + options: iconNameOptions, + description: 'Icon for the first metadata row.', + }, + metadataItem1Text: { + control: 'text', + description: 'Text for the first metadata row.', + }, + metadataItem2Icon: { + control: 'select', + options: iconNameOptions, + description: 'Icon for the second metadata row.', + }, + metadataItem2Text: { + control: 'text', + description: 'Text for the second metadata row.', + }, + metadataItem3Icon: { + control: 'select', + options: iconNameOptions, + description: 'Icon for the third metadata row.', + }, + metadataItem3Text: { + control: 'text', + description: 'Text for the third metadata row.', + }, + iconName: { + control: 'select', + options: iconNameOptions, + description: + 'Trailing icon glyph from the toolkit icon set.', + }, + iconColor: { + control: 'color', + description: + 'Colour applied to monochrome trailing icons. Icons with baked-in fills, such as `ArrowCircleRightColour`, may ignore it.', + }, + }, + parameters: { + docs: { + description: { + story: + 'This story exposes simpler controls for the nested tag, metadata rows, and trailing icon props. The tag can be edited as text plus variant, the metadata rows can be edited without raw JSON, and the trailing icon control lets you change the glyph and tint monochrome icons while the Card component keeps that slot at its built-in 32px size.', + }, + }, + }, + render: renderCard, +}; + +export const ClickableNumeric: Story = { + args: { + variant: 'clickable', + number: '12', + actionLinkText: 'Open tasks', + actionLinkHref: '#card-numeric', + }, + parameters: { + docs: { + description: { + story: + 'This story uses simple text controls for the numeric action link instead of exposing the nested `actionLink` object directly.', + }, + }, + }, + argTypes: { + actionLink: { + control: false, + table: { + disable: true, + }, + }, + actionLinkText: { + control: 'text', + description: 'Link text for the numeric card action.', + }, + actionLinkHref: { + control: 'text', + description: 'Destination for the numeric card action link.', + }, + }, + render: ({ + actionLinkText, + actionLinkHref, + actionLink, + ...args + }) => { + const resolvedActionLink = + actionLinkText && actionLinkHref + ? { + ...actionLink, + text: actionLinkText, + href: actionLinkHref, + } + : undefined; + + return ( +
+ +
+ ); + }, +}; + +export const WithImage: Story = { + args: { + variant: 'clickable', + href: '#card-image', + heading: 'Exercise', + description: + 'Programmes, workouts and tips to get you moving and improve your fitness and wellbeing.', + imgURL: + 'https://assets.nhs.uk/prod/images/A_0218_exercise-main_FKW1X7.width-690.jpg', + imgALT: '', + }, + render: (args) => ( +
+ +
+ ), +}; + +export const KeyboardNavigation: Story = { + parameters: { + controls: { + disable: true, + }, + docs: { + description: { + story: + 'Clickable cards keep a real focusable link, and secondary controls remain independently focusable and operable.', + }, + }, + }, + render: () => ( +
+ + +
+ ), +}; diff --git a/packages/react-components/src/components/Card/Card.test.tsx b/packages/react-components/src/components/Card/Card.test.tsx new file mode 100644 index 00000000..06465821 --- /dev/null +++ b/packages/react-components/src/components/Card/Card.test.tsx @@ -0,0 +1,182 @@ +import { createRef } from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { axe } from 'vitest-axe'; +import { describe, expect, it, vi } from 'vitest'; +import { Card } from './Card'; + +describe('Card', () => { + it('renders a basic card with heading and description', () => { + render(); + + expect(screen.getByText('Card heading')).toBeInTheDocument(); + expect(screen.getByText('Card description')).toBeInTheDocument(); + expect(document.querySelector('.ofh-card')).toHaveClass('ofh-card'); + }); + + it('adds the dismissible content modifier when a dismiss button is present', () => { + render( + , + ); + + expect(document.querySelector('.ofh-card__content')).toHaveClass( + 'ofh-card__content--dismissible', + ); + }); + + it('does not add the dismissible content modifier when an image is present', () => { + render( + , + ); + + expect(document.querySelector('.ofh-card__content')).not.toHaveClass( + 'ofh-card__content--dismissible', + ); + }); + + it('proxies clicks from the card surface to the primary link', async () => { + const user = userEvent.setup(); + + render( + , + ); + + const card = document.querySelector('.ofh-card'); + const link = screen.getByRole('link', { name: 'Clickable heading' }); + const handleClick = vi.fn((event: Event) => event.preventDefault()); + + link.addEventListener('click', handleClick); + await user.click(card as HTMLElement); + + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('does not proxy clicks from nested interactive elements', async () => { + const user = userEvent.setup(); + + render( + , + ); + + const primaryLink = screen.getByRole('link', { name: 'Clickable heading' }); + const dismissButton = screen.getByRole('button', { name: 'Dismiss card' }); + const primaryClick = vi.fn((event: Event) => event.preventDefault()); + const dismissClick = vi.fn(); + + primaryLink.addEventListener('click', primaryClick); + dismissButton.addEventListener('click', dismissClick); + + await user.click(dismissButton); + + expect(dismissClick).toHaveBeenCalledTimes(1); + expect(primaryClick).not.toHaveBeenCalled(); + }); + + it('uses the action link as the primary target when no href is provided', async () => { + const user = userEvent.setup(); + + render( + , + ); + + const card = document.querySelector('.ofh-card'); + const link = screen.getByRole('link', { name: 'Open tasks' }); + const handleClick = vi.fn((event: Event) => event.preventDefault()); + + link.addEventListener('click', handleClick); + await user.click(card as HTMLElement); + + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('renders icon, tag, metadata and helper text', () => { + render( + , + ); + + expect(screen.getByText('New')).toHaveClass('ofh-tag', 'ofh-tag--blue'); + expect(screen.getByText('5 minute read')).toBeInTheDocument(); + expect(screen.getByText('Updated today')).toBeInTheDocument(); + expect( + screen.getByText('Recommended for new participants.'), + ).toBeInTheDocument(); + expect(document.querySelector('.ofh-card__icon')).toBeInTheDocument(); + }); + + it('applies a custom colour to monochrome trailing icons', () => { + render( + , + ); + + expect(document.querySelector('.ofh-card__icon')).toHaveStyle({ + color: 'rgb(0, 114, 95)', + }); + }); + + it('forwards ref to the card root element', () => { + const ref = createRef(); + + render(); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + expect(ref.current).toHaveClass('ofh-card'); + }); + + it('has no accessibility violations for a clickable card', async () => { + const { container } = render( + , + ); + + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); +}); diff --git a/packages/react-components/src/components/Card/Card.tsx b/packages/react-components/src/components/Card/Card.tsx new file mode 100644 index 00000000..5e556851 --- /dev/null +++ b/packages/react-components/src/components/Card/Card.tsx @@ -0,0 +1,287 @@ +import React from 'react'; +import { + getHeadingTag, + joinClasses, + type HeadingLevel, + type OfhIconProps, +} from '../../internal/ofhUtils'; +import { Tag, type TagProps } from '../Tag'; +import { OfhIcon } from '../../internal/OfhIcon'; + +const interactiveSelector = + 'a, button, input, select, textarea, summary, [role="button"], [role="link"]'; + +export interface CardDismissButton { + label?: string; + attributes?: React.ButtonHTMLAttributes; +} + +export interface CardMetadataItem { + icon: string; + text: React.ReactNode; + size?: 16 | 24 | 32; +} + +export interface CardActionLink { + text: React.ReactNode; + href: string; + attributes?: React.AnchorHTMLAttributes; +} + +export type CardTag = Omit; +export type CardIcon = OfhIconProps; + +export interface CardProps + extends Omit, 'children' | 'ref'> { + variant?: 'basic' | 'clickable'; + heading?: React.ReactNode; + headingHtml?: string; + headingClasses?: string; + headingLevel?: HeadingLevel; + href?: string; + description?: React.ReactNode; + descriptionHtml?: string; + icon?: CardIcon; + dismissButton?: CardDismissButton; + number?: React.ReactNode; + tag?: CardTag; + metadataItems?: CardMetadataItem[]; + helperText?: React.ReactNode; + helperHtml?: string; + actionLink?: CardActionLink; + imgURL?: string; + imgALT?: string; + classes?: string; + ref?: React.Ref; +} + +export const Card = ({ + variant = 'basic', + heading, + headingHtml, + headingClasses = '', + headingLevel, + href, + description, + descriptionHtml, + icon, + dismissButton, + number, + tag, + metadataItems, + helperText, + helperHtml, + actionLink, + imgURL, + imgALT = '', + classes = '', + className = '', + ref, + onClick, + ...props +}: CardProps) => { + const isClickable = variant === 'clickable'; + const hasTrailingContent = Boolean(icon); + const contentHtml = descriptionHtml; + const dismissButtonAttributes = dismissButton?.attributes ?? {}; + const dismissButtonClassName = joinClasses( + 'ofh-card__dismiss', + dismissButtonAttributes.className, + ); + + const handleCardClick: React.MouseEventHandler = (event) => { + onClick?.(event); + + if (!isClickable || event.defaultPrevented) { + return; + } + + if (!(event.target instanceof Element)) { + return; + } + + const primaryLink = event.currentTarget.querySelector( + '[data-ofh-card-primary-link], .ofh-card__primary-link, .ofh-card__link', + ); + + if (primaryLink === null) { + return; + } + + const interactiveAncestor = event.target.closest(interactiveSelector); + + if (interactiveAncestor && interactiveAncestor !== primaryLink) { + return; + } + + if (interactiveAncestor === primaryLink) { + return; + } + + primaryLink.click(); + }; + + const cardClasses = joinClasses( + 'ofh-card', + isClickable && 'ofh-card--clickable', + hasTrailingContent && 'ofh-card__with-icon', + classes, + className, + ); + + const contentClasses = joinClasses( + 'ofh-card__content', + hasTrailingContent && 'ofh-card__content--with-aside', + dismissButton && !imgURL && 'ofh-card__content--dismissible', + ); + + const renderHeadingContent = () => { + if (headingHtml) { + return
; + } + + if (!heading) { + return null; + } + + const headingClassName = joinClasses( + 'ofh-card__heading', + headingClasses, + ); + + return React.createElement( + getHeadingTag(headingLevel, 2), + { className: headingClassName }, + href ? ( + + {heading} + + ) : ( + heading + ), + ); + }; + + const renderDescription = () => { + if (contentHtml) { + return
; + } + + if (!description) { + return null; + } + + return

{description}

; + }; + + return ( +
+ {imgURL ? {imgALT} : null} + +
+
+ {renderHeadingContent()} + + {number ?

{number}

: null} + + {tag ? : null} + + {renderDescription()} + + {metadataItems?.length ? ( +
+ {metadataItems.map((item, index) => ( +
+ + {item.text} +
+ ))} +
+ ) : null} + + {helperHtml ? ( +
+ ) : helperText ? ( +

{helperText}

+ ) : null} + + {actionLink ? ( +

+ + {actionLink.text} + +

+ ) : null} +
+ + {icon ? ( +
+ +
+ ) : null} + +
+ + {dismissButton ? ( + + ) : null} +
+ ); +}; + +Card.displayName = 'Card'; diff --git a/packages/react-components/src/components/Card/index.ts b/packages/react-components/src/components/Card/index.ts new file mode 100644 index 00000000..b2723e03 --- /dev/null +++ b/packages/react-components/src/components/Card/index.ts @@ -0,0 +1,9 @@ +export { Card } from './Card'; +export type { + CardActionLink, + CardDismissButton, + CardIcon, + CardMetadataItem, + CardProps, + CardTag, +} from './Card'; diff --git a/packages/react-components/src/components/CardCallout/CardCallout.stories.tsx b/packages/react-components/src/components/CardCallout/CardCallout.stories.tsx new file mode 100644 index 00000000..b409ca2c --- /dev/null +++ b/packages/react-components/src/components/CardCallout/CardCallout.stories.tsx @@ -0,0 +1,167 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { CardCallout } from './CardCallout'; + +const meta: Meta = { + title: 'Components/Card/Callout', + component: CardCallout, + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'Use Card / Callout to highlight contextual information such as informational, warning, success or error messages. `heading` changes the colored label text. `headingLevel` changes the semantic heading tag used for that label, but does not change the visual styling.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + variant: { + control: 'select', + options: ['info', 'error', 'success', 'warning'], + description: + 'Changes the callout color scheme to match the type of message: informational, warning, success, or error.', + }, + heading: { + control: 'text', + description: + 'Text shown in the colored label block at the top of the callout.', + }, + headingHtml: { + control: false, + description: + 'Trusted HTML to render inside the label. When this is provided, it replaces `heading`.', + table: { + category: 'Advanced', + }, + }, + headingLevel: { + control: 'select', + options: [2, 3, 4, 5, 6], + description: + 'Changes the semantic heading element for the label, for example `h2` or `h3`. This helps the callout fit the page heading hierarchy, but does not change the visual appearance.', + }, + html: { + control: false, + description: + 'Trusted HTML content for the callout body. When this is provided, it replaces `text`.', + table: { + category: 'Advanced', + }, + }, + text: { + control: 'text', + description: 'Plain text body content shown inside the callout.', + }, + classes: { + control: false, + description: + 'Toolkit-parity alias for adding extra classes to the root element. In React-only usage, prefer `className`.', + table: { + category: 'Advanced', + }, + }, + className: { + control: false, + description: + 'Adds extra classes to the root callout element for layout or integration hooks. It does not change the built-in variants by itself.', + table: { + category: 'Advanced', + }, + }, + ref: { + control: false, + description: + 'React ref for the root `
` element. Use this only when you need direct access to the rendered DOM node.', + table: { + category: 'Advanced', + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Info: Story = { + args: { + heading: 'Information', + variant: 'info', + text: 'This is additional context to help the user understand the next step.', + }, + render: (args) => ( +
+ +
+ ), +}; + +export const Warning: Story = { + args: { + heading: 'Warning', + variant: 'warning', + text: 'Check this information before you continue.', + }, + render: (args) => ( +
+ +
+ ), +}; + +export const Success: Story = { + args: { + heading: 'Success', + variant: 'success', + text: 'Your details have been saved successfully.', + }, + render: (args) => ( +
+ +
+ ), +}; + +export const Error: Story = { + args: { + heading: 'Error', + variant: 'error', + text: 'There is a problem with the information in this section.', + }, + render: (args) => ( +
+ +
+ ), +}; + +export const AllVariants: Story = { + parameters: { + controls: { + disable: true, + }, + }, + render: () => ( +
+ + + + +
+ ), +}; diff --git a/packages/react-components/src/components/CardCallout/CardCallout.test.tsx b/packages/react-components/src/components/CardCallout/CardCallout.test.tsx new file mode 100644 index 00000000..4022a274 --- /dev/null +++ b/packages/react-components/src/components/CardCallout/CardCallout.test.tsx @@ -0,0 +1,62 @@ +import { createRef } from 'react'; +import { render, screen } from '@testing-library/react'; +import { axe } from 'vitest-axe'; +import { describe, expect, it } from 'vitest'; +import { CardCallout } from './CardCallout'; + +describe('CardCallout', () => { + it('renders the warning variant with body content', () => { + render( + , + ); + + expect(screen.getByText('Warning')).toBeInTheDocument(); + expect( + screen.getByText('Check this information before you continue.'), + ).toBeInTheDocument(); + expect(document.querySelector('.ofh-card-callout')).toHaveClass( + 'ofh-card-callout--warning', + ); + expect(document.querySelector('.ofh-card-callout__spacer')).toBeInTheDocument(); + expect(document.querySelector('.ofh-card-callout__body')).toBeInTheDocument(); + }); + + it('renders heading HTML when provided', () => { + render( + , + ); + + expect(screen.getByText('School, nursery or work')).toBeInTheDocument(); + }); + + it('forwards ref to the root element', () => { + const ref = createRef(); + + render(); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + expect(ref.current).toHaveClass('ofh-card-callout'); + }); + + it('has no accessibility violations', async () => { + const { container } = render( + , + ); + + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); +}); diff --git a/packages/react-components/src/components/CardCallout/CardCallout.tsx b/packages/react-components/src/components/CardCallout/CardCallout.tsx new file mode 100644 index 00000000..d76790b3 --- /dev/null +++ b/packages/react-components/src/components/CardCallout/CardCallout.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { + getHeadingTag, + joinClasses, + type HeadingLevel, +} from '../../internal/ofhUtils'; + +export interface CardCalloutProps + extends Omit, 'children' | 'ref'> { + heading: React.ReactNode; + headingHtml?: string; + headingLevel?: HeadingLevel; + variant?: 'info' | 'error' | 'success' | 'warning'; + html?: string; + text?: React.ReactNode; + classes?: string; + ref?: React.Ref; +} + +export const CardCallout = ({ + heading, + headingHtml, + headingLevel, + variant = 'info', + html, + text, + classes = '', + className = '', + ref, + ...props +}: CardCalloutProps) => { + return ( +
+ {React.createElement( + getHeadingTag(headingLevel, 3), + { className: 'ofh-card-callout__label' }, + headingHtml ? ( + + ) : ( + heading + ), + )} +
+ +
+ ); +}; + +CardCallout.displayName = 'CardCallout'; diff --git a/packages/react-components/src/components/CardCallout/index.ts b/packages/react-components/src/components/CardCallout/index.ts new file mode 100644 index 00000000..54177011 --- /dev/null +++ b/packages/react-components/src/components/CardCallout/index.ts @@ -0,0 +1,2 @@ +export { CardCallout } from './CardCallout'; +export type { CardCalloutProps } from './CardCallout'; diff --git a/packages/react-components/src/components/CardDoDont/CardDoDont.stories.tsx b/packages/react-components/src/components/CardDoDont/CardDoDont.stories.tsx new file mode 100644 index 00000000..6dabf77f --- /dev/null +++ b/packages/react-components/src/components/CardDoDont/CardDoDont.stories.tsx @@ -0,0 +1,159 @@ +import type { ComponentProps } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { CardDoDont } from './CardDoDont'; + +type CardDoDontStoryArgs = ComponentProps & { + itemsText?: string; +}; + +const defaultDoItems = [ + 'cover blisters that are likely to burst with a soft plaster or dressing', + 'wash your hands before touching a burst blister', + 'allow the fluid to drain before covering it', + 'keep the area clean and dry while it heals', +]; + +const defaultDontItems = [ + 'burst a blister yourself', + 'peel the skin off a burst blister', + 'wear the shoes or use the equipment that caused your blister until it heals', + 'ignore signs that it may be infected', +]; + +const renderCardDoDont = ({ itemsText, items, ...args }: CardDoDontStoryArgs) => { + const resolvedItems = + itemsText !== undefined + ? itemsText + .split('\n') + .map((item) => item.trim()) + .filter(Boolean) + .map((item) => ({ item })) + : items; + + return ( +
+ +
+ ); +}; + +const meta: Meta = { + title: 'Components/Card/Do & Don’t', + component: CardDoDont, + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'Use Card / Do & Don’t to give users short, actionable recommendations that are easier to scan as positive and negative lists. `heading` changes the navy label text. `headingLevel` changes the semantic heading tag used for that label, but does not change the visual styling.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + type: { + control: 'select', + options: ['do', 'dont'], + description: 'List type.', + }, + heading: { + control: 'text', + description: + 'Optional label text shown in the navy heading block. Defaults to `Do` or `Don’t` based on `type`.', + }, + headingLevel: { + control: 'select', + options: [2, 3, 4, 5, 6], + description: + 'Changes the semantic heading element for the label, for example `h2` or `h3`. This helps the component fit the page heading hierarchy, but does not change the visual appearance.', + }, + items: { + control: 'object', + description: 'Array of list items rendered in the card body.', + }, + itemsText: { + control: 'text', + description: + 'List items as newline-separated text for this story. Each non-empty line becomes one bullet.', + }, + classes: { + control: false, + description: + 'Toolkit-parity alias for adding extra classes to the root element. In React-only usage, prefer `className`.', + table: { + category: 'Advanced', + }, + }, + className: { + control: false, + description: + 'Adds extra classes to the root element for layout or integration hooks. It does not change the built-in `do` or `dont` styling by itself.', + table: { + category: 'Advanced', + }, + }, + ref: { + control: false, + description: + 'React ref for the root `
` element. Use this only when you need direct access to the rendered DOM node.', + table: { + category: 'Advanced', + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Do: Story = { + args: { + type: 'do', + itemsText: defaultDoItems.join('\n'), + }, + argTypes: { + items: { + control: false, + table: { + disable: true, + }, + }, + }, + render: renderCardDoDont, +}; + +export const Dont: Story = { + args: { + type: 'dont', + itemsText: defaultDontItems.join('\n'), + }, + argTypes: { + items: { + control: false, + table: { + disable: true, + }, + }, + }, + render: renderCardDoDont, +}; + +export const BothLists: Story = { + parameters: { + controls: { + disable: true, + }, + }, + render: () => ( +
+ ({ item }))} + /> + ({ item }))} + /> +
+ ), +}; diff --git a/packages/react-components/src/components/CardDoDont/CardDoDont.test.tsx b/packages/react-components/src/components/CardDoDont/CardDoDont.test.tsx new file mode 100644 index 00000000..e368d693 --- /dev/null +++ b/packages/react-components/src/components/CardDoDont/CardDoDont.test.tsx @@ -0,0 +1,64 @@ +import { createRef } from 'react'; +import { render, screen } from '@testing-library/react'; +import { axe } from 'vitest-axe'; +import { describe, expect, it } from 'vitest'; +import { CardDoDont } from './CardDoDont'; + +describe('CardDoDont', () => { + it('renders a do list with the default heading', () => { + render( + , + ); + + expect(screen.getByText('Do')).toBeInTheDocument(); + expect( + screen.getByText('cover blisters that are likely to burst'), + ).toBeInTheDocument(); + expect(document.querySelector('.ofh-card-do-dont__spacer')).toBeInTheDocument(); + expect(document.querySelector('.ofh-card-do-dont__body')).toBeInTheDocument(); + }); + + it('renders a don’t list without the legacy prefix by default', () => { + render( + , + ); + + expect(screen.getByText('Don’t')).toBeInTheDocument(); + expect(screen.getByText('burst a blister yourself')).toBeInTheDocument(); + expect( + screen.queryByText('do not burst a blister yourself'), + ).not.toBeInTheDocument(); + }); + + it('forwards ref to the root element', () => { + const ref = createRef(); + + render(); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + expect(ref.current).toHaveClass('ofh-card-do-dont'); + }); + + it('has no accessibility violations', async () => { + const { container } = render( + , + ); + + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); +}); diff --git a/packages/react-components/src/components/CardDoDont/CardDoDont.tsx b/packages/react-components/src/components/CardDoDont/CardDoDont.tsx new file mode 100644 index 00000000..9d731d5e --- /dev/null +++ b/packages/react-components/src/components/CardDoDont/CardDoDont.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { + getHeadingTag, + joinClasses, + type HeadingLevel, +} from '../../internal/ofhUtils'; +import { OfhIcon } from '../../internal/OfhIcon'; + +export interface CardDoDontItem { + item: React.ReactNode; +} + +export interface CardDoDontProps + extends Omit, 'children' | 'ref'> { + type?: 'do' | 'dont'; + heading?: React.ReactNode; + headingLevel?: HeadingLevel; + items: CardDoDontItem[]; + classes?: string; + ref?: React.Ref; +} + +export const CardDoDont = ({ + type = 'do', + heading, + headingLevel, + items, + classes = '', + className = '', + ref, + ...props +}: CardDoDontProps) => { + const resolvedHeading = heading ?? (type === 'dont' ? 'Don’t' : 'Do'); + const iconName = type === 'dont' ? 'Close' : 'Done'; + + return ( +
+ {React.createElement( + getHeadingTag(headingLevel, 3), + { className: 'ofh-card-do-dont__label' }, + resolvedHeading, + )} +
+ +
+ ); +}; + +CardDoDont.displayName = 'CardDoDont'; diff --git a/packages/react-components/src/components/CardDoDont/index.ts b/packages/react-components/src/components/CardDoDont/index.ts new file mode 100644 index 00000000..0961dade --- /dev/null +++ b/packages/react-components/src/components/CardDoDont/index.ts @@ -0,0 +1,2 @@ +export { CardDoDont } from './CardDoDont'; +export type { CardDoDontItem, CardDoDontProps } from './CardDoDont'; diff --git a/packages/react-components/src/components/ErrorSummary/ErrorSummary.stories.tsx b/packages/react-components/src/components/ErrorSummary/ErrorSummary.stories.tsx index 56259ac3..7bc5545e 100644 --- a/packages/react-components/src/components/ErrorSummary/ErrorSummary.stories.tsx +++ b/packages/react-components/src/components/ErrorSummary/ErrorSummary.stories.tsx @@ -62,7 +62,7 @@ const meta: Meta = { docs: { description: { component: - 'Use the Error Summary component to summarise validation errors at the top of a page and link each error back to the relevant answer.', + 'Use the Error Summary component to summarise validation errors at the top of a page and link each error back to the relevant answer. `titleHtml` replaces `titleText`, and `descriptionHtml` replaces `descriptionText`. Use `idPrefix` when you need more than one summary on the same page so each summary gets a unique heading id.', }, }, }, @@ -70,26 +70,34 @@ const meta: Meta = { argTypes: { titleText: { control: 'text', - description: 'Plain text heading content for the summary.', + description: + 'Plain text heading content for the summary. Ignored if `titleHtml` is provided.', }, titleHtml: { - control: 'text', + control: false, description: - 'Optional HTML heading content. When provided it takes precedence over `titleText`.', + 'Trusted HTML heading content. When provided it replaces `titleText`.', + table: { + category: 'Advanced', + }, }, descriptionText: { control: 'text', - description: 'Optional supporting text shown below the heading.', + description: + 'Optional supporting text shown below the heading. Ignored if `descriptionHtml` is provided.', }, descriptionHtml: { - control: 'text', + control: false, description: - 'Optional HTML description content. When provided it takes precedence over `descriptionText`.', + 'Trusted HTML description content. When provided it replaces `descriptionText`.', + table: { + category: 'Advanced', + }, }, errorList: { control: 'object', description: - 'List of linked or unlinked errors shown in the summary. Each item supports `href`, `text`, `html`, and `attributes`. In the interactive stories, keep each `href` aligned with the rendered field ids shown in the story.', + 'List of linked or unlinked errors shown in the summary. Each item supports `href`, `text`, `html`, and `attributes`. In stories that render linked fields, keep each `href` aligned with the field ids shown in the story.', table: { type: { summary: @@ -98,17 +106,41 @@ const meta: Meta = { }, }, classes: { - control: 'text', - description: 'Additional toolkit-style classes added to the root element.', + control: false, + description: + 'Toolkit-parity alias for adding extra classes to the root element. In React-only usage, prefer `className`.', + table: { + category: 'Advanced', + }, + }, + className: { + control: false, + description: + 'Adds extra classes to the root element for layout or integration hooks. It does not change the built-in summary styling by itself.', + table: { + category: 'Advanced', + }, }, attributes: { - control: 'object', - description: 'Additional HTML attributes added to the root element.', + control: false, + description: + 'Additional HTML attributes added to the root element, for example `data-*` or `aria-*` attributes.', + table: { + category: 'Advanced', + }, }, idPrefix: { control: 'text', description: - 'Optional prefix used to generate the title id referenced by `aria-labelledby`.', + 'Optional prefix used to generate the title id referenced by `aria-labelledby`. Use this when rendering more than one error summary on the same page.', + }, + ref: { + control: false, + description: + 'React ref for the root `
` element. Use this only when you need direct access to the rendered DOM node.', + table: { + category: 'Advanced', + }, }, }, args: { @@ -150,6 +182,17 @@ export const MultipleErrors: Story = { args: { errorList: multipleErrorsList, }, + parameters: { + controls: { + disable: true, + }, + docs: { + description: { + story: + 'Interactive example with two linked fields. If you edit `errorList`, keep the `href` values aligned with `#multiple-errors-first-name` and `#multiple-errors-last-name` to preserve the focus and scroll behaviour.', + }, + }, + }, render: (args) => (
@@ -168,14 +211,6 @@ export const MultipleErrors: Story = {
), - parameters: { - docs: { - description: { - story: - 'Interactive example with two linked fields. If you edit `errorList`, keep the `href` values aligned with `#multiple-errors-first-name` and `#multiple-errors-last-name` to preserve the focus and scroll behaviour.', - }, - }, - }, }; export const HtmlContent: Story = { @@ -185,6 +220,17 @@ export const HtmlContent: Story = { 'Review the highlighted answers and update each field before continuing.', errorList: htmlContentList, }, + parameters: { + controls: { + disable: true, + }, + docs: { + description: { + story: + 'Interactive example using HTML content for the title, description, and linked errors. Click directly on nested markup inside the error links to validate the nested-anchor behaviour.', + }, + }, + }, render: (args) => (
@@ -202,20 +248,23 @@ export const HtmlContent: Story = {
), +}; + +export const InForm: Story = { + args: { + errorList: inFormList, + }, parameters: { + controls: { + disable: true, + }, docs: { description: { story: - 'Interactive example using HTML content for the title, description, and linked errors. Click directly on nested markup inside the error links to validate the nested-anchor behaviour.', + 'A realistic form example showing the error summary paired with inline field errors and unaffected fields in the same form.', }, }, }, -}; - -export const InForm: Story = { - args: { - errorList: inFormList, - }, render: (args) => (
@@ -254,12 +303,4 @@ export const InForm: Story = {
), - parameters: { - docs: { - description: { - story: - 'A realistic form example showing the error summary paired with inline field errors and unaffected fields in the same form.', - }, - }, - }, }; diff --git a/packages/react-components/src/index.ts b/packages/react-components/src/index.ts index 42b3dc07..b5dd6fda 100644 --- a/packages/react-components/src/index.ts +++ b/packages/react-components/src/index.ts @@ -17,5 +17,21 @@ export type { TextInputProps } from './components/TextInput'; export { ErrorSummary } from './components/ErrorSummary'; export type { ErrorSummaryItem, ErrorSummaryProps } from './components/ErrorSummary'; +export { Card } from './components/Card'; +export type { + CardActionLink, + CardDismissButton, + CardIcon, + CardMetadataItem, + CardProps, + CardTag, +} from './components/Card'; + +export { CardCallout } from './components/CardCallout'; +export type { CardCalloutProps } from './components/CardCallout'; + +export { CardDoDont } from './components/CardDoDont'; +export type { CardDoDontItem, CardDoDontProps } from './components/CardDoDont'; + export { Tag } from './components/Tag'; export type { TagProps, TagVariant } from './components/Tag'; diff --git a/packages/react-components/src/internal/OfhIcon.tsx b/packages/react-components/src/internal/OfhIcon.tsx new file mode 100644 index 00000000..24cde501 --- /dev/null +++ b/packages/react-components/src/internal/OfhIcon.tsx @@ -0,0 +1,47 @@ +import defaultSpritePath from '@ourfuturehealth/toolkit/assets/icons/icon-sprite.svg?url'; +import type React from 'react'; +import { joinClasses, type OfhIconProps } from './ofhUtils'; + +export const OfhIcon = ({ + name, + size = 24, + title, + color, + classes = '', + attributes, + spritePath = defaultSpritePath, +}: OfhIconProps) => { + const iconSize = [16, 24, 32].includes(size) ? size : 24; + const iconAttributes = attributes ?? {}; + const iconStyle = + color || iconAttributes.style + ? { + ...(iconAttributes.style as React.CSSProperties | undefined), + ...(color ? { color } : {}), + } + : undefined; + const className = joinClasses( + 'ofh-icon', + 'ofh-icon--material', + `ofh-icon--${iconSize}`, + `ofh-icon--${name}`, + classes, + iconAttributes.className, + ); + + return ( + + {title ? {title} : null} + + + ); +}; diff --git a/packages/react-components/src/internal/ofhUtils.ts b/packages/react-components/src/internal/ofhUtils.ts new file mode 100644 index 00000000..09cf8c65 --- /dev/null +++ b/packages/react-components/src/internal/ofhUtils.ts @@ -0,0 +1,29 @@ +import type React from 'react'; + +export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6; +export type OfhIconSize = 16 | 24 | 32; + +export interface OfhIconProps { + name: string; + size?: OfhIconSize; + title?: string; + color?: string; + classes?: string; + attributes?: React.SVGAttributes; + spritePath?: string; +} + +export const joinClasses = ( + ...classes: Array +) => classes.filter(Boolean).join(' '); + +export const getHeadingTag = ( + level: number | undefined, + fallback: HeadingLevel, +) => { + const safeLevel = [1, 2, 3, 4, 5, 6].includes(level ?? fallback) + ? (level ?? fallback) + : fallback; + + return `h${safeLevel}` as keyof React.JSX.IntrinsicElements; +}; diff --git a/packages/react-components/src/test/setup.ts b/packages/react-components/src/test/setup.ts index 9009a5da..0bc027a2 100644 --- a/packages/react-components/src/test/setup.ts +++ b/packages/react-components/src/test/setup.ts @@ -1,4 +1,5 @@ import '@testing-library/jest-dom'; +import 'vitest-axe/extend-expect'; import { vi } from 'vitest'; // Mock .module.scss files diff --git a/packages/react-components/src/types/css-modules.d.ts b/packages/react-components/src/types/css-modules.d.ts index 64497a7e..67345001 100644 --- a/packages/react-components/src/types/css-modules.d.ts +++ b/packages/react-components/src/types/css-modules.d.ts @@ -7,3 +7,8 @@ declare module '*.module.css' { const classes: { readonly [key: string]: string }; export default classes; } + +declare module '*.svg?url' { + const url: string; + export default url; +} diff --git a/packages/react-components/tsconfig.json b/packages/react-components/tsconfig.json index 01d1727e..6951b0e7 100644 --- a/packages/react-components/tsconfig.json +++ b/packages/react-components/tsconfig.json @@ -19,6 +19,6 @@ "esModuleInterop": true, "allowSyntheticDefaultImports": true }, - "include": ["src/**/*"], + "include": ["src/**/*", "vitest.shims.d.ts"], "exclude": ["node_modules", "dist"] } diff --git a/packages/react-components/vitest.shims.d.ts b/packages/react-components/vitest.shims.d.ts index 7782f28d..7ca59a9d 100644 --- a/packages/react-components/vitest.shims.d.ts +++ b/packages/react-components/vitest.shims.d.ts @@ -1 +1,2 @@ -/// \ No newline at end of file +/// +/// diff --git a/packages/site/views/_includes/_side-nav.njk b/packages/site/views/_includes/_side-nav.njk index 8e2ca163..fdfcbb14 100644 --- a/packages/site/views/_includes/_side-nav.njk +++ b/packages/site/views/_includes/_side-nav.njk @@ -67,7 +67,6 @@ { title: "Action link", url: "/design-system/components/action-link" }, { title: "Back link", url: "/design-system/components/back-link" }, { title: "Breadcrumbs", url: "/design-system/components/breadcrumbs" }, - { title: "Card", url: "/design-system/components/card" }, { title: "Contents list", url: "/design-system/components/contents-list" }, { title: "Footer", url: "/design-system/components/footer" }, { title: "Header", url: "/design-system/components/header" }, @@ -76,31 +75,17 @@ { title: "Task list", url: "/design-system/components/task-list" } ] %} -{# Commenting out to preserve the original menu; one below removes 'Care cards' and 'Review date' -{% set contentPresentation = [ - { title: "Care cards", url: "/design-system/components/care-cards" }, - { title: "Details", url: "/design-system/components/details" }, - { title: "Do and Don't lists", url: "/design-system/components/do-and-dont-lists" }, - { title: "Expander", url: "/design-system/components/expander" }, - { title: "Images", url: "/design-system/components/images" }, - { title: "Inset text", url: "/design-system/components/inset-text" }, - { title: "Review date", url: "/design-system/components/review-date" }, - { title: "Summary list", url: "/design-system/components/summary-list" }, - { title: "Table", url: "/design-system/components/table" }, - { title: "Tag", url: "/design-system/components/tag" }, - { title: "Warning callout", url: "/design-system/components/warning-callout" } -] %} #} - {% set contentPresentation = [ + { title: "Card", url: "/design-system/components/card" }, + { title: "Card / Callout", url: "/design-system/components/card-callout" }, + { title: "Card / Do & Don’t", url: "/design-system/components/card-do-dont" }, { title: "Details", url: "/design-system/components/details" }, - { title: "Do and Don't lists", url: "/design-system/components/do-and-dont-lists" }, { title: "Expander", url: "/design-system/components/expander" }, { title: "Images", url: "/design-system/components/images" }, { title: "Inset text", url: "/design-system/components/inset-text" }, { title: "Summary list", url: "/design-system/components/summary-list" }, { title: "Table", url: "/design-system/components/table" }, - { title: "Tag", url: "/design-system/components/tag" }, - { title: "Warning callout", url: "/design-system/components/warning-callout" } + { title: "Tag", url: "/design-system/components/tag" } ] %} {% set formElements = [ @@ -119,13 +104,6 @@ { title: "Textarea", url: "/design-system/components/textarea" } ] %} -{# Old Patterns for Tasks and Page types #} -{# {% set askUsers = [ - { title: "Ask users for their NHS number", url: "/design-system/patterns/ask-users-for-their-nhs-number" }, - { title: "Help users decide when and where to get care (care cards)", url: "/design-system/patterns/help-users-decide-when-and-where-to-get-care" }, - { title: "Reassure users that a page is up to date", url: "/design-system/patterns/reassure-users-that-a-page-is-up-to-date" } -] %} #} - {% set pages = [ { title: "A to Z page", url: "/design-system/patterns/a-to-z-page" }, { title: "Mini-hub", url: "/design-system/patterns/mini-hub" }, diff --git a/packages/site/views/content/a-to-z-of-nhs-health-writing.njk b/packages/site/views/content/a-to-z-of-nhs-health-writing.njk index 71e32503..217a7316 100644 --- a/packages/site/views/content/a-to-z-of-nhs-health-writing.njk +++ b/packages/site/views/content/a-to-z-of-nhs-health-writing.njk @@ -326,7 +326,7 @@

don't or do not

We use "do not" instead of "don't".

Read why in the contractions section.

-

We use "Don't" in headings for Do and Don't lists. But we use "do not" for the commands in the list.

+

We use "Don't" in headings for Card / Do & Don’t. But we use "do not" for the commands in the list.

dosage

See dosage on our Numbers, measurements, dates and time page.

drowsy

diff --git a/packages/site/views/content/formatting-and-punctuation.njk b/packages/site/views/content/formatting-and-punctuation.njk index 3b68c343..07283f0c 100644 --- a/packages/site/views/content/formatting-and-punctuation.njk +++ b/packages/site/views/content/formatting-and-punctuation.njk @@ -187,7 +187,7 @@

We use bullet points in:

Lists with a lead-in line

diff --git a/packages/site/views/design-system/components/button/index.njk b/packages/site/views/design-system/components/button/index.njk index 0ff36209..4fce19c6 100644 --- a/packages/site/views/design-system/components/button/index.njk +++ b/packages/site/views/design-system/components/button/index.njk @@ -13,6 +13,10 @@ {% endblock %} {% block bodyContent %} +

Use the contained button for the main action on a page, and choose outlined, ghost or text variants for lower-emphasis actions.

+

Use href when the control takes a user to another page or location. Keep it as a real button when it submits a form or triggers an action on the current page, and set type explicitly for form buttons so the behaviour is clear.

+

Use the inverted variants only on dark backgrounds.

+

The Button component has 6 variants.

  • Contained
  • diff --git a/packages/site/views/design-system/components/buttons/macro-options.json b/packages/site/views/design-system/components/buttons/macro-options.json index bb205c6f..58fddb47 100644 --- a/packages/site/views/design-system/components/buttons/macro-options.json +++ b/packages/site/views/design-system/components/buttons/macro-options.json @@ -4,19 +4,19 @@ "name": "element", "type": "string", "required": false, - "description": "Whether to use an `input`, `button` or `a` element to create the button. In most cases you will not need to set this as it will be configured automatically if you use `href` or `html`." + "description": "Optional element override for the button. In most cases you do not need this because the macro will render an anchor when you pass `href`, or a button when you pass `text` or `html`." }, { "name": "text", "type": "string", "required": true, - "description": "If `html` is set, this is not required. Text for the button or link. If `html` is provided, the `text` argument will be ignored and `element` will be automatically set to `button` unless `href` is also set, or it has already been defined. This argument has no effect if `element` is set to `input`." + "description": "Plain text label for the button or link. Ignored if `html` is provided. This has no effect when `element` is set to `input`." }, { "name": "html", "type": "string", "required": true, - "description": "If `text` is set, this is not required. HTML for the button or link. If `html` is provided, the `text` argument will be ignored and `element` will be automatically set to `button` unless `href` is also set, or it has already been defined. This argument has no effect if `element` is set to `input`." + "description": "Trusted HTML label content for the button or link. When provided it replaces `text`. This has no effect when `element` is set to `input`." }, { "name": "name", @@ -46,19 +46,19 @@ "name": "href", "type": "string", "required": false, - "description": "The URL that the button should link to. If this is set, `element` will be automatically set to `a` if it has not already been defined." + "description": "Navigation destination. When provided, the macro renders an anchor unless you explicitly override `element`." }, { "name": "classes", "type": "string", "required": false, - "description": "Classes to add to the button component." + "description": "Extra classes added to the root element for layout or integration hooks." }, { "name": "attributes", "type": "object", "required": false, - "description": "HTML attributes (for example data attributes) to add to the button component." + "description": "Additional HTML attributes to add to the root element, for example `data-*` or `aria-*` attributes." } ] } diff --git a/packages/site/views/design-system/components/card-callout/default/index.njk b/packages/site/views/design-system/components/card-callout/default/index.njk new file mode 100644 index 00000000..94d98d25 --- /dev/null +++ b/packages/site/views/design-system/components/card-callout/default/index.njk @@ -0,0 +1,31 @@ +{% from 'card-callout/macro.njk' import cardCallout %} + +
    + {{ cardCallout({ + "heading": "Information", + "variant": "info", + "html": "

    This is additional context to help the user understand the next step.

    " + }) }} +
    + +
    + {{ cardCallout({ + "heading": "Error", + "variant": "error", + "html": "

    There is a problem with the information in this section.

    " + }) }} +
    + +
    + {{ cardCallout({ + "heading": "Success", + "variant": "success", + "html": "

    Your details have been saved successfully.

    " + }) }} +
    + +{{ cardCallout({ + "heading": "Warning", + "variant": "warning", + "html": "

    Check this information before you continue.

    " +}) }} diff --git a/packages/site/views/design-system/components/card-callout/index.njk b/packages/site/views/design-system/components/card-callout/index.njk new file mode 100644 index 00000000..d0982ece --- /dev/null +++ b/packages/site/views/design-system/components/card-callout/index.njk @@ -0,0 +1,40 @@ +{% set pageTitle = "Card / Callout" %} +{% set pageSection = "Design system" %} +{% set subSection = "Components" %} +{% set pageDescription = "Use Card / Callout to highlight contextual information such as informational, warning, success or error messages." %} +{% set theme = "Content presentation" %} +{% set dateUpdated = "March 2026" %} +{% set hideDescription = "true" %} + +{% extends "app-layout.njk" %} + +{% block breadcrumb %} + {% include "../_breadcrumb.njk" %} +{% endblock %} + +{% block bodyContent %} + + {{ designExample({ + group: "components", + item: "card-callout", + type: "default" + }) }} + +

    When to use Card / Callout

    +

    Use Card / Callout to highlight short, self-contained contextual information that users need to notice while scanning a page.

    + +

    Variants

    +

    Use the variant that matches the message:

    +
      +
    • Info for helpful context or signposting
    • +
    • Warning for information users should pay attention to before they continue
    • +
    • Success for positive confirmation or completed status
    • +
    • Error for serious or blocking problems
    • +
    + +

    How to use Card / Callout

    +

    Keep the heading short and specific. The body should be concise enough that users can understand it quickly without needing surrounding context.

    +

    The heading label is the text shown in the colored block at the top of the callout. Use headingLevel if you need that label to fit the page heading hierarchy. Changing headingLevel changes the HTML heading tag, not the visual design.

    +

    If the content is more instructional than contextual, consider whether Card / Do & Don’t or a standard Card is a better fit.

    + +{% endblock %} diff --git a/packages/site/views/design-system/components/card-callout/macro-options.json b/packages/site/views/design-system/components/card-callout/macro-options.json new file mode 100644 index 00000000..f09382da --- /dev/null +++ b/packages/site/views/design-system/components/card-callout/macro-options.json @@ -0,0 +1,52 @@ +{ + "params": [ + { + "name": "heading", + "type": "string", + "required": true, + "description": "Text shown in the colored label block for the Card / Callout. Ignored if `headingHtml` is provided." + }, + { + "name": "headingHtml", + "type": "string", + "required": false, + "description": "HTML to use inside the heading." + }, + { + "name": "headingLevel", + "type": "integer", + "required": false, + "description": "Optional semantic heading level for the label. Default: 3. This changes the HTML heading tag, not the visual styling." + }, + { + "name": "variant", + "type": "string", + "required": false, + "description": "Visual variant: `info`, `error`, `success` or `warning`. Default: `info`." + }, + { + "name": "html", + "type": "string", + "required": false, + "description": "HTML to use inside the callout body. When this is provided, it replaces `text`." + }, + { + "name": "text", + "type": "string", + "required": false, + "description": "Plain text to use inside the callout body. Ignored if `html` is provided." + }, + { + "name": "classes", + "type": "string", + "required": false, + "description": "Classes to add to the root Card / Callout element." + }, + { + "name": "attributes", + "type": "object", + "required": false, + "description": "HTML attributes to add to the root Card / Callout element, for example an `id`, `data-*` attribute or `aria-*` attribute." + } + ] +} diff --git a/packages/site/views/design-system/components/card-do-dont/default/index.njk b/packages/site/views/design-system/components/card-do-dont/default/index.njk new file mode 100644 index 00000000..373c4583 --- /dev/null +++ b/packages/site/views/design-system/components/card-do-dont/default/index.njk @@ -0,0 +1,39 @@ +{% from 'card-do-dont/macro.njk' import cardDoDont %} + +
    + {{ cardDoDont({ + "type": "do", + "items": [ + { + "item": "cover blisters that are likely to burst with a soft plaster or dressing" + }, + { + "item": "wash your hands before touching a burst blister" + }, + { + "item": "allow the fluid to drain before covering it" + }, + { + "item": "keep the area clean and dry while it heals" + } + ] + }) }} + + {{ cardDoDont({ + "type": "dont", + "items": [ + { + "item": "burst a blister yourself" + }, + { + "item": "peel the skin off a burst blister" + }, + { + "item": "wear the shoes or use the equipment that caused your blister until it heals" + }, + { + "item": "ignore signs that it may be infected" + } + ] + }) }} +
    diff --git a/packages/site/views/design-system/components/card-do-dont/index.njk b/packages/site/views/design-system/components/card-do-dont/index.njk new file mode 100644 index 00000000..ecd0dea6 --- /dev/null +++ b/packages/site/views/design-system/components/card-do-dont/index.njk @@ -0,0 +1,34 @@ +{% set pageTitle = "Card / Do & Don’t" %} +{% set pageSection = "Design system" %} +{% set subSection = "Components" %} +{% set pageDescription = "Use Card / Do & Don’t to give users short, scannable recommendations." %} +{% set theme = "Content presentation" %} +{% set dateUpdated = "March 2026" %} +{% set hideDescription = "true" %} + +{% extends "app-layout.njk" %} + +{% block breadcrumb %} + {% include "../_breadcrumb.njk" %} +{% endblock %} + +{% block bodyContent %} + + {{ designExample({ + group: "components", + item: "card-do-dont", + type: "default" + }) }} + +

    When to use Card / Do & Don’t

    +

    Use Card / Do & Don’t to give users short, actionable recommendations that are easier to scan as positive and negative lists.

    + +

    How to use Card / Do & Don’t

    +

    Keep each item brief. Dos should usually come before Don’ts, and each list should stay focused on one topic.

    +

    The heading label defaults to Do or Don’t based on the card type. Use heading if you need different label text, and use headingLevel if you need that label to fit the page heading hierarchy. Changing headingLevel changes the HTML heading tag, not the visual design.

    +

    If you only have a single point, consider whether a Card / Callout or Inset text would be clearer.

    + +

    Accessibility

    +

    Do not rely on the icons alone. The headings and list text must still make sense for users who do not perceive colour or icon shape.

    + +{% endblock %} diff --git a/packages/site/views/design-system/components/card-do-dont/macro-options.json b/packages/site/views/design-system/components/card-do-dont/macro-options.json new file mode 100644 index 00000000..5fff94e3 --- /dev/null +++ b/packages/site/views/design-system/components/card-do-dont/macro-options.json @@ -0,0 +1,54 @@ +{ + "params": [ + { + "name": "type", + "type": "string", + "required": false, + "description": "Visual type: `do` or `dont`. Default: `do`." + }, + { + "name": "heading", + "type": "string", + "required": false, + "description": "Optional label text shown in the navy heading block. Defaults to `Do` or `Don’t` from `type`." + }, + { + "name": "headingLevel", + "type": "integer", + "required": false, + "description": "Optional semantic heading level for the label. Default: 3. This changes the HTML heading tag, not the visual styling." + }, + { + "name": "items", + "type": "array", + "required": true, + "description": "Array of item objects.", + "params": [ + { + "name": "item", + "type": "string", + "required": true, + "description": "Text to use within each list item." + } + ] + }, + { + "name": "hidePrefix", + "type": "boolean", + "required": false, + "description": "Deprecated compatibility option. When type is `dont`, hides the legacy `do not` prefix. Omit this for new work." + }, + { + "name": "classes", + "type": "string", + "required": false, + "description": "Classes to add to the root Card / Do & Don’t element." + }, + { + "name": "attributes", + "type": "object", + "required": false, + "description": "HTML attributes to add to the root Card / Do & Don’t element, for example an `id`, `data-*` attribute or `aria-*` attribute." + } + ] +} diff --git a/packages/site/views/design-system/components/card/basic-dismissible-with-image/index.njk b/packages/site/views/design-system/components/card/basic-dismissible-with-image/index.njk new file mode 100644 index 00000000..171fc188 --- /dev/null +++ b/packages/site/views/design-system/components/card/basic-dismissible-with-image/index.njk @@ -0,0 +1,12 @@ +{% from 'card/macro.njk' import card %} + +{{ card({ + "imgURL": "https://assets.nhs.uk/prod/images/A_0218_exercise-main_FKW1X7.width-690.jpg", + "imgALT": "", + "heading": "Updated guidance available", + "headingLevel": "3", + "description": "A newer version of this guidance is available. Review the latest content when you are ready.", + "dismissButton": { + "label": "Dismiss guidance update" + } +}) }} diff --git a/packages/site/views/design-system/components/card/basic-dismissible/index.njk b/packages/site/views/design-system/components/card/basic-dismissible/index.njk new file mode 100644 index 00000000..18e1ab52 --- /dev/null +++ b/packages/site/views/design-system/components/card/basic-dismissible/index.njk @@ -0,0 +1,10 @@ +{% from 'card/macro.njk' import card %} + +{{ card({ + "heading": "Update available", + "headingLevel": "3", + "description": "A newer version of this content is available for review.", + "dismissButton": { + "label": "Dismiss update message" + } +}) }} diff --git a/packages/site/views/design-system/components/card/card-with-icon/index.njk b/packages/site/views/design-system/components/card/card-with-icon/index.njk index 02c7c2c6..8881727f 100644 --- a/packages/site/views/design-system/components/card/card-with-icon/index.njk +++ b/packages/site/views/design-system/components/card/card-with-icon/index.njk @@ -1,8 +1,12 @@ -{% from 'card/macro.njk' import cardWithIcon %} +{% from 'card/macro.njk' import card %} -{{ cardWithIcon({ - "heading": "Congratulations", - "headingLevel": "3", - "descriptionHtml": "

    You've completed all your tasks

    ", - "classes": "ofh-card__with-icon" +{{ card({ + "heading": "Profile complete", + "headingLevel": "3", + "description": "You’ve completed all the required profile details.", + "icon": { + "name": "Done", + "size": 32, + "color": "#00725F" + } }) }} diff --git a/packages/site/views/design-system/components/card/clickable-action/index.njk b/packages/site/views/design-system/components/card/clickable-action/index.njk new file mode 100644 index 00000000..d8b252dc --- /dev/null +++ b/packages/site/views/design-system/components/card/clickable-action/index.njk @@ -0,0 +1,32 @@ +{% from 'card/macro.njk' import card %} + +{{ card({ + "variant": "clickable", + "href": "#", + "heading": "Introduction to care and support", + "headingLevel": "3", + "tag": { + "text": "New", + "classes": "ofh-tag--blue" + }, + "description": "A quick guide for people who have care and support needs and their carers.", + "metadataItems": [ + { + "icon": "FmdGoodOutlined", + "text": "Online" + }, + { + "icon": "CalendarTodayOutlined", + "text": "Updated today" + }, + { + "icon": "AccessTime", + "text": "5 minute read" + } + ], + "helperText": "Recommended for new participants.", + "icon": { + "name": "ArrowCircleRightColour", + "size": 32 + } +}) }} diff --git a/packages/site/views/design-system/components/card/clickable-card-with-icon/index.njk b/packages/site/views/design-system/components/card/clickable-card-with-icon/index.njk index 17706a20..39cc1451 100644 --- a/packages/site/views/design-system/components/card/clickable-card-with-icon/index.njk +++ b/packages/site/views/design-system/components/card/clickable-card-with-icon/index.njk @@ -1,17 +1,13 @@ {% from 'card/macro.njk' import card %} -{% from 'action-link/macro.njk' import actionLink %} -{% call card({ - "href": "#", - "heading": "If you need help now, but it’s not an emergency", - "headingLevel": "3", - "descriptionHtml": "

    Go to 111.nhs.uk or call 111

    ", - "classes": "ofh-card__with-icon" -}) -%} - - - -{%- endcall %} +{{ card({ + "variant": "clickable", + "href": "#", + "heading": "If you need help now, but it’s not an emergency", + "headingLevel": "3", + "descriptionHtml": "

    Go to 111.nhs.uk or call 111

    ", + "icon": { + "name": "ArrowCircleRightColour", + "size": 32 + } +}) }} diff --git a/packages/site/views/design-system/components/card/clickable-numeric/index.njk b/packages/site/views/design-system/components/card/clickable-numeric/index.njk new file mode 100644 index 00000000..81d327dc --- /dev/null +++ b/packages/site/views/design-system/components/card/clickable-numeric/index.njk @@ -0,0 +1,10 @@ +{% from 'card/macro.njk' import card %} + +{{ card({ + "variant": "clickable", + "number": "12", + "actionLink": { + "text": "Open tasks", + "href": "#" + } +}) }} diff --git a/packages/site/views/design-system/components/card/clickable/index.njk b/packages/site/views/design-system/components/card/clickable/index.njk index c23d87cd..0e800180 100644 --- a/packages/site/views/design-system/components/card/clickable/index.njk +++ b/packages/site/views/design-system/components/card/clickable/index.njk @@ -1,8 +1,8 @@ {% from 'card/macro.njk' import card %} {{ card({ + "variant": "clickable", "href": "#", - "clickable": "true", "heading": "Introduction to care and support", "headingClasses": "ofh-heading-md", "description": "A quick guide for people who have care and support needs and their carers" diff --git a/packages/site/views/design-system/components/card/group-quarter/index.njk b/packages/site/views/design-system/components/card/group-quarter/index.njk index 92d78009..775a2bc0 100644 --- a/packages/site/views/design-system/components/card/group-quarter/index.njk +++ b/packages/site/views/design-system/components/card/group-quarter/index.njk @@ -3,26 +3,42 @@
    • {{ card({ - "clickable": "true", - "headingHtml": "

      91 Applicants

      Applicants" + "variant": "clickable", + "number": "91", + "actionLink": { + "text": "Applicants", + "href": "#" + } }) }}
    • {{ card({ - "clickable": "true", - "headingHtml": "

      23 Jobs

      Jobs" + "variant": "clickable", + "number": "23", + "actionLink": { + "text": "Jobs", + "href": "#" + } }) }}
    • {{ card({ - "clickable": "true", - "headingHtml": "

      8 Services

      Services" + "variant": "clickable", + "number": "8", + "actionLink": { + "text": "Services", + "href": "#" + } }) }}
    • {{ card({ - "clickable": "true", - "headingHtml": "

      33 Messages

      Messages" + "variant": "clickable", + "number": "33", + "actionLink": { + "text": "Messages", + "href": "#" + } }) }}
    diff --git a/packages/site/views/design-system/components/card/index.njk b/packages/site/views/design-system/components/card/index.njk index c329fd7f..3252646a 100644 --- a/packages/site/views/design-system/components/card/index.njk +++ b/packages/site/views/design-system/components/card/index.njk @@ -1,10 +1,10 @@ {% set pageTitle = "Card" %} {% set pageSection = "Design system" %} {% set subSection = "Components" %} -{% set pageDescription = "Use a card to give users a brief summary of content or a task, often with a link to more detail. You can display a card alongside other cards to group related content or tasks." %} -{% set theme = "Navigation" %} -{% set dateUpdated = "November 2021" %} -{% set backlog_issue_id = "159" %} +{% set pageDescription = "Use a card to present short, scannable summaries of content, status or next steps." %} +{% set theme = "Content presentation" %} +{% set dateUpdated = "March 2026" %} +{% set backlog_issue_id = "DSE-324" %} {% set hideDescription = "true" %} {% extends "app-layout.njk" %} @@ -16,11 +16,13 @@ {% block bodyContent %}

    When to use a card

    -

    Use a card to give users a brief summary of content or a task, often with a link to more detail. You can display a card alongside other cards to group related content or tasks.

    +

    Use a card to give users a short summary of content, status or a next step. Cards work well when people need to scan a page and decide what to do next.

    How it works

    -

    Cards should be easy to scan for relevant and actionable information. They can contain multiple elements, such as an image, title, text and links.

    -

    Cards are different from the pattern to help users decide when and where to get care (care cards).

    +

    Cards should stay concise and self-contained. They can include a heading, short supporting content, metadata, a tag, an action link or an icon.

    +

    Use headingLevel to fit the card heading into the page heading hierarchy. This changes the HTML heading tag, not the visual size. If you need a different visual heading size, use headingClasses.

    +

    Use Card / Callout for feedback-style messages and Card / Do & Don’t for short lists of recommendations.

    +

    Legacy feature-card and care-card options are deprecated compatibility APIs for existing toolkit consumers. Use the Card family APIs documented here for new work.

    Basic card

    @@ -30,7 +32,9 @@ type: "default" }) }} -

    Card with icon

    +

    Basic card with icon

    +

    Use the icon prop to add a supporting icon when it helps users understand the message more quickly. Success is one common use, but the same pattern also works for other short, icon-led cards.

    +

    By default the icon follows the card’s normal text colour. If you need a status-specific colour for a monochrome icon, set icon.color. Icons with baked-in fills keep their own colours.

    {{ designExample({ group: "components", @@ -48,13 +52,40 @@ type: "clickable" }) }} -

    Clickable card with icon

    -

    As above, with an icon at the right hand side of the card.

    +

    Basic dismissible card

    +

    Use a dismiss button only when removing the card from view is a meaningful user action.

    {{ designExample({ group: "components", item: "card", - type: "clickable-card-with-icon" + type: "basic-dismissible" + }) }} + +

    Dismissible card with an image

    +

    When a dismissible card also includes an image, the dismiss button should stay aligned with the image area while the content continues to wrap cleanly below it.

    + + {{ designExample({ + group: "components", + item: "card", + type: "basic-dismissible-with-image" + }) }} + +

    Clickable action card

    +

    Use tags, metadata and helper text to add context without making the card harder to scan.

    + + {{ designExample({ + group: "components", + item: "card", + type: "clickable-action" + }) }} + +

    Clickable numeric card

    +

    Numeric cards work well for dashboards and summary views where the number is the main message.

    + + {{ designExample({ + group: "components", + item: "card", + type: "clickable-numeric" }) }}

    Card with an image

    @@ -112,7 +143,13 @@ type: "heading-size" }) }} +

    Clickable cards

    +

    Clickable cards still need a clear primary link inside the card. Do not make multiple unrelated controls part of the same large hit area.

    +

    If a card contains secondary buttons or links, make sure they remain independently focusable and operable with the keyboard.

    +

    Research

    +

    Cards are a familiar pattern for helping people scan related content and choose a next step. Keep each card focused on one message so the layout supports that scanning behaviour.

    +

    When cards become dense or require multiple interactions, users have to work harder to understand where to click and what matters most.

    We have tested cards on the NHS website, Summary Care Record and NHS login help centre. We found that they helped users: