@awarebydefault/display-case 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +309 -0
- package/display-case.prompt.md +64 -0
- package/docs/ai-agents.md +126 -0
- package/docs/cli.md +99 -0
- package/docs/configuration.md +410 -0
- package/docs/documentation-panel.md +50 -0
- package/docs/examples/README.md +14 -0
- package/docs/examples/multi-variant.case.tsx +30 -0
- package/docs/examples/plain.case.tsx +22 -0
- package/docs/examples/tweak-control.placard.md +80 -0
- package/docs/examples/tweaks.case.tsx +39 -0
- package/docs/hierarchy.md +59 -0
- package/docs/quick-start.md +78 -0
- package/docs/style-engines.md +180 -0
- package/docs/testing.md +245 -0
- package/docs/theming.md +97 -0
- package/docs/tweaks.md +75 -0
- package/docs/writing-cases.md +144 -0
- package/docs/writing-placard-docs.md +194 -0
- package/package.json +113 -0
- package/skills/display-case-author-case/README.md +20 -0
- package/skills/display-case-author-case/SKILL.md +40 -0
- package/skills/display-case-author-placard-doc/README.md +24 -0
- package/skills/display-case-author-placard-doc/SKILL.md +65 -0
- package/skills/display-case-review/README.md +19 -0
- package/skills/display-case-review/SKILL.md +30 -0
- package/skills/display-case-snapshot/README.md +20 -0
- package/skills/display-case-snapshot/SKILL.md +29 -0
- package/src/checks/a11y-scanner.test.ts +240 -0
- package/src/checks/a11y-scanner.ts +410 -0
- package/src/checks/check-text.test.ts +53 -0
- package/src/checks/check-text.ts +78 -0
- package/src/checks/check.test.ts +194 -0
- package/src/checks/check.ts +473 -0
- package/src/checks/providers/pixelmatch-diff.test.ts +79 -0
- package/src/checks/providers/pixelmatch-diff.ts +30 -0
- package/src/checks/providers/playwright-driver.ts +104 -0
- package/src/checks/ssr-check.test.ts +73 -0
- package/src/checks/ssr-check.ts +96 -0
- package/src/checks/structure-check.cross-package.test.ts +165 -0
- package/src/checks/structure-check.test.ts +651 -0
- package/src/checks/structure-check.ts +988 -0
- package/src/checks/tokens-check.test.ts +159 -0
- package/src/checks/tokens-check.ts +162 -0
- package/src/cli.ts +218 -0
- package/src/commands/agents.test.ts +24 -0
- package/src/commands/agents.ts +28 -0
- package/src/commands/init-run.test.ts +123 -0
- package/src/commands/init.test.ts +63 -0
- package/src/commands/init.ts +412 -0
- package/src/commands/publish.test.ts +210 -0
- package/src/commands/publish.ts +292 -0
- package/src/core/affected.test.ts +99 -0
- package/src/core/affected.ts +144 -0
- package/src/core/catalog.test.ts +152 -0
- package/src/core/catalog.ts +92 -0
- package/src/core/discovery.test.ts +184 -0
- package/src/core/discovery.ts +250 -0
- package/src/core/manifest.ts +41 -0
- package/src/core/mdx-lite/__fixtures__/box-stub.tsx +7 -0
- package/src/core/mdx-lite/index.ts +393 -0
- package/src/core/mdx-lite/mdx-lite.test.ts +345 -0
- package/src/core/mdx-plugin.test.ts +60 -0
- package/src/core/mdx-plugin.ts +30 -0
- package/src/flow-step.test-d.ts +39 -0
- package/src/index.test.ts +100 -0
- package/src/index.ts +564 -0
- package/src/render/collect-styles.emotion.test.tsx +114 -0
- package/src/render/collect-styles.test.tsx +72 -0
- package/src/render/collect-styles.ts +33 -0
- package/src/render/documents.test.ts +184 -0
- package/src/render/documents.ts +88 -0
- package/src/render/render-node.test.tsx +160 -0
- package/src/render/render-node.tsx +133 -0
- package/src/render/ssr-primer.test.tsx +25 -0
- package/src/render/ssr-primer.tsx +54 -0
- package/src/render/ssr-render.test.tsx +142 -0
- package/src/render/ssr-render.tsx +63 -0
- package/src/render/ssr-shell.test.tsx +57 -0
- package/src/render/ssr-shell.tsx +54 -0
- package/src/server/prod-server.ts +237 -0
- package/src/server/server.test.ts +117 -0
- package/src/server/server.ts +1039 -0
- package/src/style-engine.test-d.ts +37 -0
- package/src/testing/test-helpers.ts +27 -0
- package/src/types/pixelmatch.d.ts +12 -0
- package/src/ui/browser-entry.tsx +51 -0
- package/src/ui/chrome.css +485 -0
- package/src/ui/design-system/README.md +88 -0
- package/src/ui/design-system/components/controls/Button.case.tsx +52 -0
- package/src/ui/design-system/components/controls/Button.css +89 -0
- package/src/ui/design-system/components/controls/Button.placard.md +14 -0
- package/src/ui/design-system/components/controls/Button.test.tsx +45 -0
- package/src/ui/design-system/components/controls/Button.tsx +41 -0
- package/src/ui/design-system/components/controls/IconButton.case.tsx +52 -0
- package/src/ui/design-system/components/controls/IconButton.css +67 -0
- package/src/ui/design-system/components/controls/IconButton.placard.md +13 -0
- package/src/ui/design-system/components/controls/IconButton.test.tsx +39 -0
- package/src/ui/design-system/components/controls/IconButton.tsx +47 -0
- package/src/ui/design-system/components/controls/Input.case.tsx +50 -0
- package/src/ui/design-system/components/controls/Input.css +52 -0
- package/src/ui/design-system/components/controls/Input.placard.md +12 -0
- package/src/ui/design-system/components/controls/Input.test.tsx +43 -0
- package/src/ui/design-system/components/controls/Input.tsx +45 -0
- package/src/ui/design-system/components/controls/Select.case.tsx +48 -0
- package/src/ui/design-system/components/controls/Select.css +44 -0
- package/src/ui/design-system/components/controls/Select.placard.md +15 -0
- package/src/ui/design-system/components/controls/Select.test.tsx +57 -0
- package/src/ui/design-system/components/controls/Select.tsx +58 -0
- package/src/ui/design-system/components/controls/SelectMenu.case.tsx +100 -0
- package/src/ui/design-system/components/controls/SelectMenu.css +72 -0
- package/src/ui/design-system/components/controls/SelectMenu.placard.md +18 -0
- package/src/ui/design-system/components/controls/SelectMenu.test.tsx +66 -0
- package/src/ui/design-system/components/controls/SelectMenu.tsx +377 -0
- package/src/ui/design-system/components/index.ts +66 -0
- package/src/ui/design-system/components/primer-specimen/ColorRamp.case.tsx +44 -0
- package/src/ui/design-system/components/primer-specimen/ColorRamp.placard.md +15 -0
- package/src/ui/design-system/components/primer-specimen/ColorRamp.tsx +51 -0
- package/src/ui/design-system/components/primer-specimen/DefinitionList.case.tsx +38 -0
- package/src/ui/design-system/components/primer-specimen/DefinitionList.placard.md +15 -0
- package/src/ui/design-system/components/primer-specimen/DefinitionList.tsx +41 -0
- package/src/ui/design-system/components/primer-specimen/FontFamilies.case.tsx +24 -0
- package/src/ui/design-system/components/primer-specimen/FontFamilies.placard.md +12 -0
- package/src/ui/design-system/components/primer-specimen/FontFamilies.tsx +41 -0
- package/src/ui/design-system/components/primer-specimen/GlyphGrid.case.tsx +27 -0
- package/src/ui/design-system/components/primer-specimen/GlyphGrid.placard.md +13 -0
- package/src/ui/design-system/components/primer-specimen/GlyphGrid.tsx +34 -0
- package/src/ui/design-system/components/primer-specimen/LayoutMock.case.tsx +36 -0
- package/src/ui/design-system/components/primer-specimen/LayoutMock.placard.md +7 -0
- package/src/ui/design-system/components/primer-specimen/LayoutMock.tsx +36 -0
- package/src/ui/design-system/components/primer-specimen/SpacingScale.case.tsx +20 -0
- package/src/ui/design-system/components/primer-specimen/SpacingScale.placard.md +12 -0
- package/src/ui/design-system/components/primer-specimen/SpacingScale.tsx +33 -0
- package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.case.tsx +56 -0
- package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.placard.md +17 -0
- package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.tsx +45 -0
- package/src/ui/design-system/components/primer-specimen/StatusList.case.tsx +17 -0
- package/src/ui/design-system/components/primer-specimen/StatusList.placard.md +16 -0
- package/src/ui/design-system/components/primer-specimen/StatusList.tsx +39 -0
- package/src/ui/design-system/components/primer-specimen/SwatchGrid.case.tsx +26 -0
- package/src/ui/design-system/components/primer-specimen/SwatchGrid.placard.md +15 -0
- package/src/ui/design-system/components/primer-specimen/SwatchGrid.tsx +42 -0
- package/src/ui/design-system/components/primer-specimen/TypeScale.case.tsx +23 -0
- package/src/ui/design-system/components/primer-specimen/TypeScale.placard.md +14 -0
- package/src/ui/design-system/components/primer-specimen/TypeScale.tsx +34 -0
- package/src/ui/design-system/components/primer-specimen/WeightSpecimen.case.tsx +28 -0
- package/src/ui/design-system/components/primer-specimen/WeightSpecimen.placard.md +15 -0
- package/src/ui/design-system/components/primer-specimen/WeightSpecimen.tsx +46 -0
- package/src/ui/design-system/components/primer-specimen/index.ts +31 -0
- package/src/ui/design-system/components/primer-specimen/styles.css +476 -0
- package/src/ui/design-system/components/shell/A11yPage.case.tsx +237 -0
- package/src/ui/design-system/components/shell/A11yPage.placard.md +15 -0
- package/src/ui/design-system/components/shell/CaseTemplate.case.tsx +32 -0
- package/src/ui/design-system/components/shell/CaseTemplate.placard.md +5 -0
- package/src/ui/design-system/components/shell/CasesPage.case.tsx +141 -0
- package/src/ui/design-system/components/shell/CasesPage.placard.md +12 -0
- package/src/ui/design-system/components/shell/PrimerPage.case.tsx +22 -0
- package/src/ui/design-system/components/shell/PrimerPage.placard.md +3 -0
- package/src/ui/design-system/components/shell/PrimerTemplate.case.tsx +22 -0
- package/src/ui/design-system/components/shell/PrimerTemplate.placard.md +5 -0
- package/src/ui/design-system/components/shell/ShellView.case.tsx +57 -0
- package/src/ui/design-system/components/shell/ShellView.placard.md +5 -0
- package/src/ui/design-system/components/shell/ShellView.tsx +678 -0
- package/src/ui/design-system/components/shell/shell-fixtures.tsx +727 -0
- package/src/ui/design-system/components/showcase/A11yBadge.case.tsx +46 -0
- package/src/ui/design-system/components/showcase/A11yBadge.css +27 -0
- package/src/ui/design-system/components/showcase/A11yBadge.placard.md +11 -0
- package/src/ui/design-system/components/showcase/A11yBadge.test.tsx +31 -0
- package/src/ui/design-system/components/showcase/A11yBadge.tsx +41 -0
- package/src/ui/design-system/components/showcase/A11yPanel.case.tsx +121 -0
- package/src/ui/design-system/components/showcase/A11yPanel.css +198 -0
- package/src/ui/design-system/components/showcase/A11yPanel.placard.md +19 -0
- package/src/ui/design-system/components/showcase/A11yPanel.test.tsx +81 -0
- package/src/ui/design-system/components/showcase/A11yPanel.tsx +144 -0
- package/src/ui/design-system/components/showcase/Chip.case.tsx +48 -0
- package/src/ui/design-system/components/showcase/Chip.css +51 -0
- package/src/ui/design-system/components/showcase/Chip.placard.md +13 -0
- package/src/ui/design-system/components/showcase/Chip.test.tsx +46 -0
- package/src/ui/design-system/components/showcase/Chip.tsx +54 -0
- package/src/ui/design-system/components/showcase/Eyebrow.case.tsx +30 -0
- package/src/ui/design-system/components/showcase/Eyebrow.css +16 -0
- package/src/ui/design-system/components/showcase/Eyebrow.placard.md +10 -0
- package/src/ui/design-system/components/showcase/Eyebrow.test.tsx +38 -0
- package/src/ui/design-system/components/showcase/Eyebrow.tsx +29 -0
- package/src/ui/design-system/components/showcase/FlowNav.case.tsx +35 -0
- package/src/ui/design-system/components/showcase/FlowNav.css +29 -0
- package/src/ui/design-system/components/showcase/FlowNav.placard.md +13 -0
- package/src/ui/design-system/components/showcase/FlowNav.test.tsx +48 -0
- package/src/ui/design-system/components/showcase/FlowNav.tsx +58 -0
- package/src/ui/design-system/components/showcase/ImpactTag.case.tsx +19 -0
- package/src/ui/design-system/components/showcase/ImpactTag.css +36 -0
- package/src/ui/design-system/components/showcase/ImpactTag.placard.md +14 -0
- package/src/ui/design-system/components/showcase/ImpactTag.test.tsx +40 -0
- package/src/ui/design-system/components/showcase/ImpactTag.tsx +35 -0
- package/src/ui/design-system/components/showcase/NavItem.case.tsx +86 -0
- package/src/ui/design-system/components/showcase/NavItem.css +111 -0
- package/src/ui/design-system/components/showcase/NavItem.placard.md +13 -0
- package/src/ui/design-system/components/showcase/NavItem.test.tsx +65 -0
- package/src/ui/design-system/components/showcase/NavItem.tsx +95 -0
- package/src/ui/design-system/components/showcase/RenderAddress.case.tsx +21 -0
- package/src/ui/design-system/components/showcase/RenderAddress.css +35 -0
- package/src/ui/design-system/components/showcase/RenderAddress.placard.md +7 -0
- package/src/ui/design-system/components/showcase/RenderAddress.test.tsx +26 -0
- package/src/ui/design-system/components/showcase/RenderAddress.tsx +43 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.case.tsx +84 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.css +61 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.placard.md +21 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.test.tsx +81 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.tsx +75 -0
- package/src/ui/design-system/components/showcase/Sidebar.case.tsx +67 -0
- package/src/ui/design-system/components/showcase/Sidebar.css +6 -0
- package/src/ui/design-system/components/showcase/Sidebar.placard.md +14 -0
- package/src/ui/design-system/components/showcase/Sidebar.test.tsx +32 -0
- package/src/ui/design-system/components/showcase/Sidebar.tsx +30 -0
- package/src/ui/design-system/components/showcase/Stage.case.tsx +51 -0
- package/src/ui/design-system/components/showcase/Stage.css +91 -0
- package/src/ui/design-system/components/showcase/Stage.placard.md +15 -0
- package/src/ui/design-system/components/showcase/Stage.test.tsx +84 -0
- package/src/ui/design-system/components/showcase/Stage.tsx +97 -0
- package/src/ui/design-system/components/showcase/TweaksPanel.case.tsx +81 -0
- package/src/ui/design-system/components/showcase/TweaksPanel.css +169 -0
- package/src/ui/design-system/components/showcase/TweaksPanel.placard.md +20 -0
- package/src/ui/design-system/components/showcase/TweaksPanel.tsx +230 -0
- package/src/ui/design-system/components/showcase/Wordmark.case.tsx +42 -0
- package/src/ui/design-system/components/showcase/Wordmark.css +31 -0
- package/src/ui/design-system/components/showcase/Wordmark.placard.md +10 -0
- package/src/ui/design-system/components/showcase/Wordmark.test.tsx +22 -0
- package/src/ui/design-system/components/showcase/Wordmark.tsx +22 -0
- package/src/ui/design-system/primer-specimens/brand.tsx +26 -0
- package/src/ui/design-system/primer-specimens/colors.tsx +83 -0
- package/src/ui/design-system/primer-specimens/components.tsx +308 -0
- package/src/ui/design-system/primer-specimens/foundations.tsx +71 -0
- package/src/ui/design-system/primer-specimens/index.ts +25 -0
- package/src/ui/design-system/primer-specimens/showcase.tsx +68 -0
- package/src/ui/design-system/primer-specimens/spacing.tsx +101 -0
- package/src/ui/design-system/primer-specimens/type.tsx +75 -0
- package/src/ui/design-system/primer.mdx +236 -0
- package/src/ui/design-system/styles.css +14 -0
- package/src/ui/design-system/tokens/colors.css +172 -0
- package/src/ui/design-system/tokens/fonts.css +18 -0
- package/src/ui/design-system/tokens/spacing.css +48 -0
- package/src/ui/design-system/tokens/typography.css +49 -0
- package/src/ui/markdown.test.tsx +54 -0
- package/src/ui/markdown.tsx +19 -0
- package/src/ui/primer-mount.tsx +76 -0
- package/src/ui/primer.css +175 -0
- package/src/ui/primer.tsx +277 -0
- package/src/ui/render-mount.tsx +284 -0
- package/src/ui/shell-core.test.ts +340 -0
- package/src/ui/shell-core.ts +295 -0
- package/src/ui/shell.tsx +60 -0
- package/src/ui/test-ids.ts +53 -0
- package/src/ui/use-shell.ts +1230 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import type { A11yViolation } from '../../../../index'
|
|
3
|
+
import { DcTestIds } from '../../../test-ids'
|
|
4
|
+
import { IconButton } from '../controls/IconButton'
|
|
5
|
+
import { Eyebrow } from './Eyebrow'
|
|
6
|
+
import { ImpactTag, impactRank } from './ImpactTag'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Display Case — A11yPanel
|
|
10
|
+
* The stage's accessibility verdict for the variant on the stage. Four states:
|
|
11
|
+
* `'pending'` while a scan is in flight (a calm, pulsing "Scanning…" bar),
|
|
12
|
+
* `'unavailable'` when the scan prerequisite can't run, an empty array for a
|
|
13
|
+
* clean pass (green bar), or the violations (danger bar + a collapsible list,
|
|
14
|
+
* ordered worst-first with an {@link ImpactTag}). Only the violations state has a
|
|
15
|
+
* body to expand/collapse — the others say everything in the single header bar.
|
|
16
|
+
* Pass `onRescan` to show the ⟳ re-scan control.
|
|
17
|
+
*
|
|
18
|
+
* Height-capped + internally scrollable, with a sticky header so the verdict and
|
|
19
|
+
* controls never scroll away. Padding lives on the head and body (not the
|
|
20
|
+
* section) so the sticky header's background spans edge-to-edge while scrolling.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface A11yPanelProps {
|
|
24
|
+
/** The active variant's verdict: `'pending'`, `'unavailable'`, `[]` (clean),
|
|
25
|
+
* or the violations. */
|
|
26
|
+
violations: A11yViolation[] | 'pending' | 'unavailable'
|
|
27
|
+
/** How the resolved verdict animates in. `'cascade'` (the verdict just resolved
|
|
28
|
+
* from a live scan): violation rows fade + rise with a per-row stagger.
|
|
29
|
+
* `'all'` (default — already scanned, e.g. navigated to a cached variant):
|
|
30
|
+
* the verdict fades in at once, like the stage and tweaks panel. */
|
|
31
|
+
reveal?: 'cascade' | 'all'
|
|
32
|
+
/** Re-run the audit for the viewed variant. When omitted (a static exhibit
|
|
33
|
+
* that doesn't wire it), the ⟳ re-scan control is hidden. */
|
|
34
|
+
onRescan?: () => void
|
|
35
|
+
/** Snap the accent/status colour instead of easing it. Set while the panel is
|
|
36
|
+
* mid-navigation crossfade (faded out) so the new exhibit's verdict colour is
|
|
37
|
+
* in place before it fades back in, rather than easing the old colour across
|
|
38
|
+
* the fade. The colour ease stays on for in-place state changes. */
|
|
39
|
+
instantColor?: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function A11yPanel({
|
|
43
|
+
violations,
|
|
44
|
+
reveal = 'all',
|
|
45
|
+
onRescan,
|
|
46
|
+
instantColor,
|
|
47
|
+
}: A11yPanelProps) {
|
|
48
|
+
// Show/hide is a local UI concern (no need to persist like the docs panel).
|
|
49
|
+
// Only the violations list collapses; the other states are a single bar.
|
|
50
|
+
const [open, setOpen] = useState(true)
|
|
51
|
+
const list = Array.isArray(violations) ? violations : []
|
|
52
|
+
const pending = violations === 'pending'
|
|
53
|
+
const unavailable = violations === 'unavailable'
|
|
54
|
+
const passing = Array.isArray(violations) && violations.length === 0
|
|
55
|
+
const collapsible = list.length > 0
|
|
56
|
+
let state: 'pending' | 'unavailable' | 'pass' | 'fail'
|
|
57
|
+
if (pending) state = 'pending'
|
|
58
|
+
else if (unavailable) state = 'unavailable'
|
|
59
|
+
else if (passing) state = 'pass'
|
|
60
|
+
else state = 'fail'
|
|
61
|
+
let status: string
|
|
62
|
+
if (pending) status = 'Scanning…'
|
|
63
|
+
else if (unavailable) status = 'Unavailable'
|
|
64
|
+
else if (passing) status = 'Passes WCAG A/AA'
|
|
65
|
+
else status = `${list.length} violation${list.length === 1 ? '' : 's'}`
|
|
66
|
+
// Worst first, then most-affected first — so the most urgent fixes lead.
|
|
67
|
+
const sorted = collapsible
|
|
68
|
+
? [...list].sort(
|
|
69
|
+
(a, b) =>
|
|
70
|
+
impactRank(a.impact) - impactRank(b.impact) || b.nodes - a.nodes,
|
|
71
|
+
)
|
|
72
|
+
: []
|
|
73
|
+
return (
|
|
74
|
+
<section
|
|
75
|
+
className="dcui-a11y"
|
|
76
|
+
data-testid={DcTestIds.a11yPanel}
|
|
77
|
+
data-state={state}
|
|
78
|
+
data-reveal={reveal}
|
|
79
|
+
data-instant-color={instantColor ? 'true' : undefined}
|
|
80
|
+
data-open={collapsible && open ? 'true' : undefined}
|
|
81
|
+
aria-label="Accessibility">
|
|
82
|
+
<div className="dcui-a11y-head">
|
|
83
|
+
<Eyebrow>Accessibility</Eyebrow>
|
|
84
|
+
<div className="dcui-a11y-head-right">
|
|
85
|
+
<span className="dcui-a11y-status">{status}</span>
|
|
86
|
+
{/* Re-scan the viewed variant. Hidden while a scan is in flight, and
|
|
87
|
+
absent entirely when no handler is wired. */}
|
|
88
|
+
{onRescan && !pending && (
|
|
89
|
+
<IconButton
|
|
90
|
+
glyph="⟳"
|
|
91
|
+
variant="bare"
|
|
92
|
+
size="sm"
|
|
93
|
+
data-testid={DcTestIds.a11yRescan}
|
|
94
|
+
label="Re-scan accessibility"
|
|
95
|
+
onClick={onRescan}
|
|
96
|
+
/>
|
|
97
|
+
)}
|
|
98
|
+
{/* Only the violations list is collapsible — the other states are a
|
|
99
|
+
single self-explaining bar with no body to toggle. */}
|
|
100
|
+
{collapsible && (
|
|
101
|
+
<IconButton
|
|
102
|
+
glyph={open ? '▾' : '▸'}
|
|
103
|
+
variant="bare"
|
|
104
|
+
size="sm"
|
|
105
|
+
data-testid={DcTestIds.a11yToggle}
|
|
106
|
+
aria-expanded={open}
|
|
107
|
+
label={
|
|
108
|
+
open
|
|
109
|
+
? 'Hide accessibility details'
|
|
110
|
+
: 'Show accessibility details'
|
|
111
|
+
}
|
|
112
|
+
onClick={() => setOpen((o) => !o)}
|
|
113
|
+
/>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
{/* Always render the wrapper so the 0fr→1fr height transition fires when a
|
|
118
|
+
scan resolves to violations; the list mounts inside only when there are
|
|
119
|
+
violations to show. */}
|
|
120
|
+
<div className="dcui-a11y-collapse">
|
|
121
|
+
{collapsible && (
|
|
122
|
+
<ul className="dcui-a11y-list">
|
|
123
|
+
{sorted.map((v, i) => (
|
|
124
|
+
<li
|
|
125
|
+
key={v.id}
|
|
126
|
+
className="dcui-a11y-item"
|
|
127
|
+
data-testid={DcTestIds.a11yViolation(v.id)}
|
|
128
|
+
// Stagger the cascade; reduced-motion drops the animation, so this
|
|
129
|
+
// delay has no effect there (everything appears at once).
|
|
130
|
+
style={{ animationDelay: `${i * 100}ms` }}>
|
|
131
|
+
{v.impact && <ImpactTag impact={v.impact} />}
|
|
132
|
+
<code className="dcui-a11y-id">{v.id}</code>
|
|
133
|
+
<span className="dcui-a11y-help">{v.help}</span>
|
|
134
|
+
<span className="dcui-a11y-nodes">
|
|
135
|
+
{v.nodes} node{v.nodes === 1 ? '' : 's'}
|
|
136
|
+
</span>
|
|
137
|
+
</li>
|
|
138
|
+
))}
|
|
139
|
+
</ul>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
</section>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { defineCases, tweak } from '@awarebydefault/display-case'
|
|
2
|
+
import { Chip } from './Chip'
|
|
3
|
+
|
|
4
|
+
export default defineCases(
|
|
5
|
+
'Chip',
|
|
6
|
+
{
|
|
7
|
+
Playground: {
|
|
8
|
+
tweaks: {
|
|
9
|
+
label: tweak.text('atom'),
|
|
10
|
+
variant: tweak.choice(['default', 'accent', 'solid'], 'default'),
|
|
11
|
+
current: tweak.boolean(false),
|
|
12
|
+
withIndex: tweak.boolean(false),
|
|
13
|
+
index: tweak.number(1),
|
|
14
|
+
clickable: tweak.boolean(false),
|
|
15
|
+
},
|
|
16
|
+
render: (t) => (
|
|
17
|
+
<Chip
|
|
18
|
+
variant={t.variant as 'default' | 'accent' | 'solid'}
|
|
19
|
+
current={t.current}
|
|
20
|
+
index={t.withIndex ? t.index : undefined}
|
|
21
|
+
onClick={t.clickable ? () => {} : undefined}>
|
|
22
|
+
{t.label}
|
|
23
|
+
</Chip>
|
|
24
|
+
),
|
|
25
|
+
},
|
|
26
|
+
Variants: () => (
|
|
27
|
+
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
28
|
+
<Chip>atom</Chip>
|
|
29
|
+
<Chip variant="accent">accent</Chip>
|
|
30
|
+
<Chip variant="solid">solid</Chip>
|
|
31
|
+
</div>
|
|
32
|
+
),
|
|
33
|
+
Steps: () => (
|
|
34
|
+
<div style={{ display: 'flex', gap: 8 }}>
|
|
35
|
+
<Chip index={1} onClick={() => {}}>
|
|
36
|
+
Request link
|
|
37
|
+
</Chip>
|
|
38
|
+
<Chip index={2} current onClick={() => {}}>
|
|
39
|
+
Check email
|
|
40
|
+
</Chip>
|
|
41
|
+
<Chip index={3} onClick={() => {}}>
|
|
42
|
+
Signed in
|
|
43
|
+
</Chip>
|
|
44
|
+
</div>
|
|
45
|
+
),
|
|
46
|
+
},
|
|
47
|
+
{ level: 'atom' },
|
|
48
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
.dcui-chip {
|
|
2
|
+
display: inline-flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
gap: var(--dc-space-2);
|
|
5
|
+
font-family: var(--dc-font-mono);
|
|
6
|
+
font-size: var(--dc-text-xs);
|
|
7
|
+
font-weight: var(--dc-weight-medium);
|
|
8
|
+
line-height: 1;
|
|
9
|
+
color: var(--dc-fg-muted);
|
|
10
|
+
background: var(--dc-bg-subtle);
|
|
11
|
+
border: 1px solid var(--dc-border);
|
|
12
|
+
border-radius: var(--dc-radius-full);
|
|
13
|
+
padding: 0.3125rem var(--dc-space-4);
|
|
14
|
+
white-space: nowrap;
|
|
15
|
+
}
|
|
16
|
+
button.dcui-chip {
|
|
17
|
+
cursor: pointer;
|
|
18
|
+
transition:
|
|
19
|
+
border-color var(--dc-transition-fast),
|
|
20
|
+
color var(--dc-transition-fast),
|
|
21
|
+
background var(--dc-transition-fast);
|
|
22
|
+
}
|
|
23
|
+
button.dcui-chip:hover {
|
|
24
|
+
color: var(--dc-fg);
|
|
25
|
+
border-color: var(--dc-border-strong);
|
|
26
|
+
}
|
|
27
|
+
button.dcui-chip:focus-visible {
|
|
28
|
+
outline: 2px solid var(--dc-focus-ring);
|
|
29
|
+
outline-offset: 1px;
|
|
30
|
+
}
|
|
31
|
+
.dcui-chip[data-variant="accent"] {
|
|
32
|
+
color: var(--dc-brand);
|
|
33
|
+
background: var(--dc-brand-subtle);
|
|
34
|
+
border-color: var(--dc-brand);
|
|
35
|
+
}
|
|
36
|
+
.dcui-chip[data-variant="solid"] {
|
|
37
|
+
color: var(--dc-ink-fg);
|
|
38
|
+
background: var(--dc-ink);
|
|
39
|
+
border-color: var(--dc-ink);
|
|
40
|
+
}
|
|
41
|
+
.dcui-chip[aria-current="true"] {
|
|
42
|
+
color: var(--dc-brand);
|
|
43
|
+
border-color: var(--dc-brand);
|
|
44
|
+
background: var(--dc-brand-subtle);
|
|
45
|
+
}
|
|
46
|
+
.dcui-chip-index {
|
|
47
|
+
color: var(--dc-fg-subtle);
|
|
48
|
+
}
|
|
49
|
+
.dcui-chip[aria-current="true"] .dcui-chip-index {
|
|
50
|
+
color: var(--dc-brand);
|
|
51
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
**Chip** — a small pill for hierarchy levels (atom, molecule…), flow steps, tweak tokens, and counts; reach for it to tag or label one item compactly. Static by default; pass `onClick` and it becomes a button.
|
|
2
|
+
|
|
3
|
+
```tsx
|
|
4
|
+
<Chip>atom</Chip>
|
|
5
|
+
<Chip variant="accent">accent</Chip>
|
|
6
|
+
<Chip index={2} current onClick={() => goto('check-email')}>Check email</Chip>
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Variants: `default` (muted) · `accent` (marigold outline, soft fill) · `solid` (filled ink, for the one chip that must stand out against the others). `current` is the marigold "active" state; `index` prepends a dimmed mono number.
|
|
10
|
+
|
|
11
|
+
For a multi-step stepper, reach for `FlowNav`.
|
|
12
|
+
|
|
13
|
+
Pass `label` for the accessible name: with `onClick` (button form) it becomes `aria-label`; without (span form) it becomes the `title`. `onClick` emits nothing — the caller already knows which chip it wired.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import { Chip } from './Chip'
|
|
4
|
+
|
|
5
|
+
describe('Chip', () => {
|
|
6
|
+
test('renders as a static span by default with the label as a title', () => {
|
|
7
|
+
const html = renderToStaticMarkup(<Chip label="atom">atom</Chip>)
|
|
8
|
+
expect(html).toContain('<span')
|
|
9
|
+
expect(html).not.toContain('<button')
|
|
10
|
+
expect(html).toContain('title="atom"')
|
|
11
|
+
expect(html).toContain('data-variant="default"')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('becomes a button when an onClick handler is given', () => {
|
|
15
|
+
const html = renderToStaticMarkup(
|
|
16
|
+
<Chip label="Step 1" onClick={() => {}}>
|
|
17
|
+
Step 1
|
|
18
|
+
</Chip>,
|
|
19
|
+
)
|
|
20
|
+
expect(html).toContain('<button')
|
|
21
|
+
expect(html).toContain('type="button"')
|
|
22
|
+
expect(html).toContain('aria-label="Step 1"')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('marks the current chip with aria-current', () => {
|
|
26
|
+
const current = renderToStaticMarkup(<Chip current>x</Chip>)
|
|
27
|
+
const plain = renderToStaticMarkup(<Chip>x</Chip>)
|
|
28
|
+
expect(current).toContain('aria-current="true"')
|
|
29
|
+
expect(plain).not.toContain('aria-current')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('renders a leading index slot when provided', () => {
|
|
33
|
+
const html = renderToStaticMarkup(<Chip index={2}>Configure</Chip>)
|
|
34
|
+
expect(html).toContain('class="dcui-chip-index"')
|
|
35
|
+
expect(html).toContain('>2<')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('reflects the variant', () => {
|
|
39
|
+
expect(renderToStaticMarkup(<Chip variant="accent">x</Chip>)).toContain(
|
|
40
|
+
'data-variant="accent"',
|
|
41
|
+
)
|
|
42
|
+
expect(renderToStaticMarkup(<Chip variant="solid">x</Chip>)).toContain(
|
|
43
|
+
'data-variant="solid"',
|
|
44
|
+
)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Display Case — Chip
|
|
5
|
+
* A small pill for hierarchy levels (atom, molecule…), flow steps, tweak
|
|
6
|
+
* tokens, and counts. Static by default; pass `onClick` and it becomes a button
|
|
7
|
+
* (used for flow-step selection).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type ChipVariant = 'default' | 'accent' | 'solid'
|
|
11
|
+
|
|
12
|
+
export interface ChipProps {
|
|
13
|
+
variant?: ChipVariant
|
|
14
|
+
current?: boolean
|
|
15
|
+
/** Leading index/number (mono, dimmed) — used by flow-step chips. */
|
|
16
|
+
index?: ReactNode
|
|
17
|
+
/** When set, the chip renders as a button. */
|
|
18
|
+
onClick?: () => void
|
|
19
|
+
label?: string
|
|
20
|
+
children?: ReactNode
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function Chip({
|
|
24
|
+
variant = 'default',
|
|
25
|
+
current = false,
|
|
26
|
+
index,
|
|
27
|
+
onClick,
|
|
28
|
+
label,
|
|
29
|
+
children,
|
|
30
|
+
}: ChipProps) {
|
|
31
|
+
const inner = (
|
|
32
|
+
<>
|
|
33
|
+
{index != null ? <span className="dcui-chip-index">{index}</span> : null}
|
|
34
|
+
{children}
|
|
35
|
+
</>
|
|
36
|
+
)
|
|
37
|
+
const common = {
|
|
38
|
+
className: 'dcui-chip',
|
|
39
|
+
'data-variant': variant,
|
|
40
|
+
'aria-current': current ? ('true' as const) : undefined,
|
|
41
|
+
}
|
|
42
|
+
if (onClick) {
|
|
43
|
+
return (
|
|
44
|
+
<button type="button" {...common} aria-label={label} onClick={onClick}>
|
|
45
|
+
{inner}
|
|
46
|
+
</button>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
return (
|
|
50
|
+
<span {...common} title={label}>
|
|
51
|
+
{inner}
|
|
52
|
+
</span>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { defineCases, tweak } from '@awarebydefault/display-case'
|
|
2
|
+
import { Eyebrow } from './Eyebrow'
|
|
3
|
+
|
|
4
|
+
export default defineCases(
|
|
5
|
+
'Eyebrow',
|
|
6
|
+
{
|
|
7
|
+
Playground: {
|
|
8
|
+
tweaks: {
|
|
9
|
+
label: tweak.text('Components'),
|
|
10
|
+
tone: tweak.choice(['muted', 'accent', 'strong'], 'muted'),
|
|
11
|
+
as: tweak.choice(['div', 'span', 'p'], 'div'),
|
|
12
|
+
},
|
|
13
|
+
render: (t) => (
|
|
14
|
+
<Eyebrow
|
|
15
|
+
tone={t.tone as 'muted' | 'accent' | 'strong'}
|
|
16
|
+
as={t.as as 'div' | 'span' | 'p'}>
|
|
17
|
+
{t.label}
|
|
18
|
+
</Eyebrow>
|
|
19
|
+
),
|
|
20
|
+
},
|
|
21
|
+
Tones: () => (
|
|
22
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
23
|
+
<Eyebrow>Components</Eyebrow>
|
|
24
|
+
<Eyebrow tone="accent">Tweaks</Eyebrow>
|
|
25
|
+
<Eyebrow tone="strong">Documentation</Eyebrow>
|
|
26
|
+
</div>
|
|
27
|
+
),
|
|
28
|
+
},
|
|
29
|
+
{ level: 'atom' },
|
|
30
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
.dcui-eyebrow {
|
|
2
|
+
font-family: var(--dc-font-mono);
|
|
3
|
+
font-size: var(--dc-text-xs);
|
|
4
|
+
font-weight: var(--dc-weight-medium);
|
|
5
|
+
letter-spacing: var(--dc-tracking-label);
|
|
6
|
+
text-transform: uppercase;
|
|
7
|
+
color: var(--dc-fg-muted);
|
|
8
|
+
line-height: var(--dc-leading-tight);
|
|
9
|
+
margin: 0;
|
|
10
|
+
}
|
|
11
|
+
.dcui-eyebrow[data-tone="accent"] {
|
|
12
|
+
color: var(--dc-brand);
|
|
13
|
+
}
|
|
14
|
+
.dcui-eyebrow[data-tone="strong"] {
|
|
15
|
+
color: var(--dc-fg);
|
|
16
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
**Eyebrow** — the signature section label: uppercase JetBrains Mono, wide tracking, muted; reach for it to mark a group header or panel title in the chrome.
|
|
2
|
+
|
|
3
|
+
```tsx
|
|
4
|
+
<Eyebrow>Components</Eyebrow>
|
|
5
|
+
<Eyebrow tone="accent">Tweaks</Eyebrow>
|
|
6
|
+
```
|
|
7
|
+
|
|
8
|
+
Renders a `<div>` by default. Override `as="span"` only when the label sits inline inside other text (a block `<div>` would break the flow).
|
|
9
|
+
|
|
10
|
+
Tones: `muted` (default) · `accent` (marigold) · `strong` (full ink).
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import { Eyebrow } from './Eyebrow'
|
|
4
|
+
|
|
5
|
+
describe('Eyebrow', () => {
|
|
6
|
+
test('renders a muted div by default', () => {
|
|
7
|
+
const html = renderToStaticMarkup(<Eyebrow>Tweaks</Eyebrow>)
|
|
8
|
+
expect(html).toContain('<div')
|
|
9
|
+
expect(html).toContain('class="dcui-eyebrow"')
|
|
10
|
+
expect(html).toContain('data-tone="muted"')
|
|
11
|
+
expect(html).toContain('Tweaks')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('renders with the requested element tag', () => {
|
|
15
|
+
const html = renderToStaticMarkup(<Eyebrow as="span">Docs</Eyebrow>)
|
|
16
|
+
expect(html).toContain('<span')
|
|
17
|
+
expect(html).not.toContain('<div')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('reflects the tone', () => {
|
|
21
|
+
expect(renderToStaticMarkup(<Eyebrow tone="accent">x</Eyebrow>)).toContain(
|
|
22
|
+
'data-tone="accent"',
|
|
23
|
+
)
|
|
24
|
+
expect(renderToStaticMarkup(<Eyebrow tone="strong">x</Eyebrow>)).toContain(
|
|
25
|
+
'data-tone="strong"',
|
|
26
|
+
)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('forwards arbitrary attributes to the element', () => {
|
|
30
|
+
const html = renderToStaticMarkup(
|
|
31
|
+
<Eyebrow id="section-label" title="hint">
|
|
32
|
+
x
|
|
33
|
+
</Eyebrow>,
|
|
34
|
+
)
|
|
35
|
+
expect(html).toContain('id="section-label"')
|
|
36
|
+
expect(html).toContain('title="hint"')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Display Case — Eyebrow
|
|
5
|
+
* The signature label: uppercase JetBrains Mono, wide tracking, muted. Marks
|
|
6
|
+
* every section in the chrome (group headers, panel titles, "Tweaks",
|
|
7
|
+
* "Documentation").
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type EyebrowTone = 'muted' | 'accent' | 'strong'
|
|
11
|
+
|
|
12
|
+
export interface EyebrowProps extends HTMLAttributes<HTMLElement> {
|
|
13
|
+
as?: 'div' | 'span' | 'p'
|
|
14
|
+
tone?: EyebrowTone
|
|
15
|
+
children?: ReactNode
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function Eyebrow({
|
|
19
|
+
as: Tag = 'div',
|
|
20
|
+
tone = 'muted',
|
|
21
|
+
children,
|
|
22
|
+
...rest
|
|
23
|
+
}: EyebrowProps) {
|
|
24
|
+
return (
|
|
25
|
+
<Tag className="dcui-eyebrow" data-tone={tone} {...rest}>
|
|
26
|
+
{children}
|
|
27
|
+
</Tag>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { defineCases, tweak } from '@awarebydefault/display-case'
|
|
2
|
+
import { FlowNav } from './FlowNav'
|
|
3
|
+
|
|
4
|
+
const steps = [
|
|
5
|
+
{ id: 'request-link', label: 'Request link' },
|
|
6
|
+
{ id: 'check-email', label: 'Check email' },
|
|
7
|
+
{ id: 'signed-in', label: 'Signed in' },
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
export default defineCases(
|
|
11
|
+
'FlowNav',
|
|
12
|
+
{
|
|
13
|
+
Playground: {
|
|
14
|
+
tweaks: {
|
|
15
|
+
active: tweak.choice(
|
|
16
|
+
['request-link', 'check-email', 'signed-in'],
|
|
17
|
+
'check-email',
|
|
18
|
+
),
|
|
19
|
+
},
|
|
20
|
+
render: (t) => (
|
|
21
|
+
<FlowNav steps={steps} activeId={t.active} onSelect={() => {}} />
|
|
22
|
+
),
|
|
23
|
+
},
|
|
24
|
+
Start: () => (
|
|
25
|
+
<FlowNav steps={steps} activeId="request-link" onSelect={() => {}} />
|
|
26
|
+
),
|
|
27
|
+
Middle: () => (
|
|
28
|
+
<FlowNav steps={steps} activeId="check-email" onSelect={() => {}} />
|
|
29
|
+
),
|
|
30
|
+
End: () => (
|
|
31
|
+
<FlowNav steps={steps} activeId="signed-in" onSelect={() => {}} />
|
|
32
|
+
),
|
|
33
|
+
},
|
|
34
|
+
{ level: 'molecule' },
|
|
35
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
.dcui-flownav {
|
|
2
|
+
display: flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
gap: var(--dc-space-6);
|
|
5
|
+
flex-wrap: wrap;
|
|
6
|
+
padding: var(--dc-space-4) var(--dc-space-6);
|
|
7
|
+
border: 1px solid var(--dc-border);
|
|
8
|
+
border-radius: var(--dc-radius-md);
|
|
9
|
+
background: var(--dc-bg-subtle);
|
|
10
|
+
}
|
|
11
|
+
/* Scope the steps reset under .dcui-flownav so the no-margin/no-padding list
|
|
12
|
+
wins over ambient prose styles (e.g. a Primer's .dc-primer ol rule), which
|
|
13
|
+
would otherwise add a bottom margin that pushes the chips off the bar's
|
|
14
|
+
centre line, out of vertical alignment with the Prev / Next rail. */
|
|
15
|
+
.dcui-flownav .dcui-flownav-steps {
|
|
16
|
+
display: flex;
|
|
17
|
+
align-items: center;
|
|
18
|
+
gap: var(--dc-space-3);
|
|
19
|
+
flex-wrap: wrap;
|
|
20
|
+
list-style: none;
|
|
21
|
+
margin: 0;
|
|
22
|
+
padding: 0;
|
|
23
|
+
flex: 1;
|
|
24
|
+
}
|
|
25
|
+
.dcui-flownav-rail {
|
|
26
|
+
margin-left: auto;
|
|
27
|
+
display: flex;
|
|
28
|
+
gap: var(--dc-space-3);
|
|
29
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
**FlowNav** — the stepper for multi-step flows: numbered step chips plus Prev/Next, grouped on one bar; reach for it when a flow's steps are individually addressable.
|
|
2
|
+
|
|
3
|
+
```tsx
|
|
4
|
+
<FlowNav
|
|
5
|
+
steps={[{ id: 'a', label: 'Request link' }, { id: 'b', label: 'Check email' }]}
|
|
6
|
+
activeId="a"
|
|
7
|
+
onSelect={(id) => goto(id)}
|
|
8
|
+
/>
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`onSelect(id)` fires from both chip clicks and the Prev/Next buttons. Prev/Next resolve to the neighboring step's `id` (not a delta or direction), so the handler is always "go to this step". They auto-disable at the first and last step.
|
|
12
|
+
|
|
13
|
+
For a single pill, reach for `Chip`.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import { FlowNav, type FlowStep } from './FlowNav'
|
|
4
|
+
|
|
5
|
+
const steps: FlowStep[] = [
|
|
6
|
+
{ id: 'a', label: 'Empty' },
|
|
7
|
+
{ id: 'b', label: 'Filled' },
|
|
8
|
+
{ id: 'c', label: 'Submitted' },
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
describe('FlowNav', () => {
|
|
12
|
+
test('renders one numbered step chip per step', () => {
|
|
13
|
+
const html = renderToStaticMarkup(<FlowNav steps={steps} activeId="a" />)
|
|
14
|
+
expect(html).toContain('Empty')
|
|
15
|
+
expect(html).toContain('Filled')
|
|
16
|
+
expect(html).toContain('Submitted')
|
|
17
|
+
// The mono index column on each chip.
|
|
18
|
+
expect((html.match(/dcui-chip-index/g) ?? []).length).toBe(3)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('marks the active step chip as current', () => {
|
|
22
|
+
const html = renderToStaticMarkup(<FlowNav steps={steps} activeId="b" />)
|
|
23
|
+
expect(html).toContain('aria-current="true"')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('disables Prev on the first step', () => {
|
|
27
|
+
const html = renderToStaticMarkup(<FlowNav steps={steps} activeId="a" />)
|
|
28
|
+
// The Prev button (← Prev) precedes Next and is the only disabled control.
|
|
29
|
+
const prevIdx = html.indexOf('← Prev')
|
|
30
|
+
const slice = html.slice(0, prevIdx)
|
|
31
|
+
expect(slice.lastIndexOf('disabled')).toBeGreaterThan(
|
|
32
|
+
slice.lastIndexOf('<button'),
|
|
33
|
+
)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('disables Next on the last step but not Prev', () => {
|
|
37
|
+
const html = renderToStaticMarkup(<FlowNav steps={steps} activeId="c" />)
|
|
38
|
+
expect(html).toContain('← Prev')
|
|
39
|
+
expect(html).toContain('Next →')
|
|
40
|
+
// Exactly one rail button is disabled at an end.
|
|
41
|
+
expect((html.match(/disabled/g) ?? []).length).toBe(1)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('enables both rail buttons in the middle of the flow', () => {
|
|
45
|
+
const html = renderToStaticMarkup(<FlowNav steps={steps} activeId="b" />)
|
|
46
|
+
expect(html).not.toContain('disabled')
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { Button } from '../controls/Button'
|
|
3
|
+
import { Chip } from './Chip'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Display Case — FlowNav
|
|
7
|
+
* The stepper for multi-step flows: numbered step chips + Prev / Next, grouped
|
|
8
|
+
* on one bar. Each step is individually addressable (click a chip), mirroring
|
|
9
|
+
* the flow's `goto` transitions.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface FlowStep {
|
|
13
|
+
id: string
|
|
14
|
+
label: ReactNode
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface FlowNavProps {
|
|
18
|
+
steps: FlowStep[]
|
|
19
|
+
activeId: string
|
|
20
|
+
onSelect?: (id: string) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function FlowNav({ steps, activeId, onSelect }: FlowNavProps) {
|
|
24
|
+
const idx = steps.findIndex((s) => s.id === activeId)
|
|
25
|
+
const prev = steps[idx - 1]
|
|
26
|
+
const next = steps[idx + 1]
|
|
27
|
+
return (
|
|
28
|
+
<div className="dcui-flownav">
|
|
29
|
+
<ol className="dcui-flownav-steps">
|
|
30
|
+
{steps.map((s, i) => (
|
|
31
|
+
<li key={s.id}>
|
|
32
|
+
<Chip
|
|
33
|
+
index={i + 1}
|
|
34
|
+
current={s.id === activeId}
|
|
35
|
+
onClick={() => onSelect?.(s.id)}>
|
|
36
|
+
{s.label}
|
|
37
|
+
</Chip>
|
|
38
|
+
</li>
|
|
39
|
+
))}
|
|
40
|
+
</ol>
|
|
41
|
+
<div className="dcui-flownav-rail">
|
|
42
|
+
<Button
|
|
43
|
+
size="sm"
|
|
44
|
+
variant="subtle"
|
|
45
|
+
disabled={!prev}
|
|
46
|
+
onClick={() => prev && onSelect?.(prev.id)}>
|
|
47
|
+
← Prev
|
|
48
|
+
</Button>
|
|
49
|
+
<Button
|
|
50
|
+
size="sm"
|
|
51
|
+
disabled={!next}
|
|
52
|
+
onClick={() => next && onSelect?.(next.id)}>
|
|
53
|
+
Next →
|
|
54
|
+
</Button>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
)
|
|
58
|
+
}
|