@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,48 @@
|
|
|
1
|
+
import { defineCases, tweak } from '@awarebydefault/display-case'
|
|
2
|
+
import { Select } from './Select'
|
|
3
|
+
|
|
4
|
+
const grouped = [
|
|
5
|
+
{ label: 'Responsive', options: ['Responsive', 'Desktop', 'Tablet'] },
|
|
6
|
+
{ label: 'Devices', options: ['iPhone 14', 'iPad', 'Pixel 7'] },
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
export default defineCases(
|
|
10
|
+
'Select',
|
|
11
|
+
{
|
|
12
|
+
Playground: {
|
|
13
|
+
tweaks: {
|
|
14
|
+
value: tweak.choice(['text', 'number', 'boolean'], 'number'),
|
|
15
|
+
size: tweak.choice(['sm', 'md'], 'md'),
|
|
16
|
+
disabled: tweak.boolean(false),
|
|
17
|
+
},
|
|
18
|
+
render: (t) => (
|
|
19
|
+
<Select
|
|
20
|
+
aria-label="Field type"
|
|
21
|
+
options={['text', 'number', 'boolean']}
|
|
22
|
+
defaultValue={t.value}
|
|
23
|
+
size={t.size as 'sm' | 'md'}
|
|
24
|
+
disabled={t.disabled}
|
|
25
|
+
/>
|
|
26
|
+
),
|
|
27
|
+
},
|
|
28
|
+
Options: () => (
|
|
29
|
+
<Select
|
|
30
|
+
aria-label="Field type"
|
|
31
|
+
options={['text', 'number', 'boolean']}
|
|
32
|
+
defaultValue="number"
|
|
33
|
+
/>
|
|
34
|
+
),
|
|
35
|
+
Grouped: () => (
|
|
36
|
+
<Select aria-label="Viewport" options={grouped} defaultValue="Desktop" />
|
|
37
|
+
),
|
|
38
|
+
Disabled: () => (
|
|
39
|
+
<Select
|
|
40
|
+
aria-label="Field type"
|
|
41
|
+
options={['text', 'number']}
|
|
42
|
+
disabled
|
|
43
|
+
defaultValue="text"
|
|
44
|
+
/>
|
|
45
|
+
),
|
|
46
|
+
},
|
|
47
|
+
{ level: 'atom' },
|
|
48
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
.dcui-select {
|
|
2
|
+
position: relative;
|
|
3
|
+
display: inline-flex;
|
|
4
|
+
align-items: center;
|
|
5
|
+
}
|
|
6
|
+
.dcui-select-el {
|
|
7
|
+
appearance: none;
|
|
8
|
+
-webkit-appearance: none;
|
|
9
|
+
font-family: var(--dc-font-sans);
|
|
10
|
+
font-size: var(--dc-text-base);
|
|
11
|
+
color: var(--dc-fg);
|
|
12
|
+
background: var(--dc-surface);
|
|
13
|
+
border: 1px solid var(--dc-border);
|
|
14
|
+
border-radius: var(--dc-radius-sm);
|
|
15
|
+
padding: 0 calc(var(--dc-space-8) + 0.5rem) 0 var(--dc-space-4);
|
|
16
|
+
height: 30px;
|
|
17
|
+
cursor: pointer;
|
|
18
|
+
transition:
|
|
19
|
+
border-color var(--dc-transition-fast),
|
|
20
|
+
background var(--dc-transition-fast);
|
|
21
|
+
}
|
|
22
|
+
.dcui-select-el:hover {
|
|
23
|
+
background: var(--dc-hover);
|
|
24
|
+
}
|
|
25
|
+
.dcui-select-el:focus-visible {
|
|
26
|
+
outline: 2px solid var(--dc-focus-ring);
|
|
27
|
+
outline-offset: 1px;
|
|
28
|
+
}
|
|
29
|
+
.dcui-select[data-size="sm"] .dcui-select-el {
|
|
30
|
+
height: 26px;
|
|
31
|
+
font-size: var(--dc-text-sm);
|
|
32
|
+
}
|
|
33
|
+
.dcui-select-caret {
|
|
34
|
+
position: absolute;
|
|
35
|
+
right: var(--dc-space-4);
|
|
36
|
+
font-family: var(--dc-font-mono);
|
|
37
|
+
font-size: var(--dc-text-sm);
|
|
38
|
+
color: var(--dc-fg-muted);
|
|
39
|
+
pointer-events: none;
|
|
40
|
+
}
|
|
41
|
+
.dcui-select-el:disabled {
|
|
42
|
+
opacity: 0.5;
|
|
43
|
+
cursor: not-allowed;
|
|
44
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
**Select** — a native `<select>` styled to match the chrome (mono `▾` caret); reach for it to pick one value from a fixed set of choices.
|
|
2
|
+
|
|
3
|
+
```tsx
|
|
4
|
+
<Select options={['text', 'number', 'boolean']} value={kind} onChange={(e) => setKind(e.target.value)} />
|
|
5
|
+
<Select options={[
|
|
6
|
+
{ label: 'Responsive', options: ['Full', 'Desktop'] },
|
|
7
|
+
{ label: 'Devices', options: ['iPhone 14', 'iPad'] },
|
|
8
|
+
]} />
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`onChange` emits a native `<select>` event — read `event.target.value`. Accepts plain options, grouped options, or arbitrary `<option>` children.
|
|
12
|
+
|
|
13
|
+
For free-form text or number entry, use `Input`.
|
|
14
|
+
|
|
15
|
+
Sizes: `sm` · `md` (default). Other native `<select>` props (`value`, `disabled`, …) spread onto the element.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import { Select } from './Select'
|
|
4
|
+
|
|
5
|
+
describe('Select', () => {
|
|
6
|
+
test('renders an aria-hidden caret beside the native control', () => {
|
|
7
|
+
const html = renderToStaticMarkup(<Select options={['a']} />)
|
|
8
|
+
expect(html).toContain('class="dcui-select"')
|
|
9
|
+
expect(html).toContain('class="dcui-select-el"')
|
|
10
|
+
expect(html).toContain('aria-hidden="true"')
|
|
11
|
+
expect(html).toContain('▾')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('renders string options with value and label equal', () => {
|
|
15
|
+
const html = renderToStaticMarkup(<Select options={['light', 'dark']} />)
|
|
16
|
+
expect(html).toContain('<option value="light">light</option>')
|
|
17
|
+
expect(html).toContain('<option value="dark">dark</option>')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('renders object options with a distinct value and label', () => {
|
|
21
|
+
const html = renderToStaticMarkup(
|
|
22
|
+
<Select options={[{ value: 'sm', label: 'Small' }]} />,
|
|
23
|
+
)
|
|
24
|
+
expect(html).toContain('<option value="sm">Small</option>')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('renders grouped options as optgroups', () => {
|
|
28
|
+
const html = renderToStaticMarkup(
|
|
29
|
+
<Select
|
|
30
|
+
options={[
|
|
31
|
+
{
|
|
32
|
+
label: 'Devices',
|
|
33
|
+
options: ['iPhone', { value: 'ipad', label: 'iPad' }],
|
|
34
|
+
},
|
|
35
|
+
]}
|
|
36
|
+
/>,
|
|
37
|
+
)
|
|
38
|
+
expect(html).toContain('<optgroup label="Devices">')
|
|
39
|
+
expect(html).toContain('<option value="iPhone">iPhone</option>')
|
|
40
|
+
expect(html).toContain('<option value="ipad">iPad</option>')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('arbitrary option children override the options prop', () => {
|
|
44
|
+
const html = renderToStaticMarkup(
|
|
45
|
+
<Select options={['ignored']}>
|
|
46
|
+
<option value="custom">Custom</option>
|
|
47
|
+
</Select>,
|
|
48
|
+
)
|
|
49
|
+
expect(html).toContain('<option value="custom">Custom</option>')
|
|
50
|
+
expect(html).not.toContain('ignored')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('disabling propagates to the native select element', () => {
|
|
54
|
+
const html = renderToStaticMarkup(<Select options={['a']} disabled />)
|
|
55
|
+
expect(html).toContain('disabled')
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ReactNode, SelectHTMLAttributes } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Display Case — Select
|
|
5
|
+
* Native <select> styled to match the chrome, with a mono caret. Accepts plain
|
|
6
|
+
* options or grouped options (Responsive / Devices), or arbitrary <option>
|
|
7
|
+
* children.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type SelectSize = 'sm' | 'md'
|
|
11
|
+
export type SelectOption = string | { value: string; label?: ReactNode }
|
|
12
|
+
export interface SelectOptionGroup {
|
|
13
|
+
label: string
|
|
14
|
+
options: SelectOption[]
|
|
15
|
+
}
|
|
16
|
+
export type SelectItem = SelectOption | SelectOptionGroup
|
|
17
|
+
|
|
18
|
+
export interface SelectProps
|
|
19
|
+
extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'size'> {
|
|
20
|
+
options?: SelectItem[]
|
|
21
|
+
size?: SelectSize
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function renderOption(o: SelectItem) {
|
|
25
|
+
if (o && typeof o === 'object' && 'options' in o) {
|
|
26
|
+
return (
|
|
27
|
+
<optgroup key={o.label} label={o.label}>
|
|
28
|
+
{o.options.map(renderOption)}
|
|
29
|
+
</optgroup>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
const value = typeof o === 'string' ? o : o.value
|
|
33
|
+
const label = typeof o === 'string' ? o : (o.label ?? o.value)
|
|
34
|
+
return (
|
|
35
|
+
<option key={value} value={value}>
|
|
36
|
+
{label}
|
|
37
|
+
</option>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function Select({
|
|
42
|
+
options = [],
|
|
43
|
+
size = 'md',
|
|
44
|
+
disabled = false,
|
|
45
|
+
children,
|
|
46
|
+
...rest
|
|
47
|
+
}: SelectProps) {
|
|
48
|
+
return (
|
|
49
|
+
<span className="dcui-select" data-size={size}>
|
|
50
|
+
<select className="dcui-select-el" disabled={disabled} {...rest}>
|
|
51
|
+
{children ?? options.map(renderOption)}
|
|
52
|
+
</select>
|
|
53
|
+
<span className="dcui-select-caret" aria-hidden="true">
|
|
54
|
+
▾
|
|
55
|
+
</span>
|
|
56
|
+
</span>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { defineCases, tweak } from '@awarebydefault/display-case'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { SelectMenu, type SelectMenuOption } from './SelectMenu'
|
|
4
|
+
|
|
5
|
+
const FRUITS = ['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry']
|
|
6
|
+
|
|
7
|
+
// Disabled options stand in as non-interactive group headers (the chrome's
|
|
8
|
+
// screen-size picker uses this for its Responsive / Devices groups).
|
|
9
|
+
const GROUPED: SelectMenuOption[] = [
|
|
10
|
+
{ value: '__responsive', label: 'Responsive', disabled: true },
|
|
11
|
+
{ value: 'full', label: 'Full' },
|
|
12
|
+
{ value: 'desktop', label: 'Desktop' },
|
|
13
|
+
{ value: '__devices', label: 'Devices', disabled: true },
|
|
14
|
+
{ value: 'ipad', label: 'iPad' },
|
|
15
|
+
{ value: 'iphone', label: 'iPhone' },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
// SelectMenu is controlled, so each specimen owns its selected value. The open
|
|
19
|
+
// menu is an out-of-flow popup, so the specimen reserves height for it — the
|
|
20
|
+
// stage hugs a component's natural box and would otherwise clip the menu (same
|
|
21
|
+
// reason the floating TweaksPanel specimen reserves a sized surface).
|
|
22
|
+
function Demo({
|
|
23
|
+
options,
|
|
24
|
+
initial,
|
|
25
|
+
size,
|
|
26
|
+
disabled,
|
|
27
|
+
}: {
|
|
28
|
+
options: SelectMenuOption[]
|
|
29
|
+
initial: string
|
|
30
|
+
size?: 'sm' | 'md'
|
|
31
|
+
disabled?: boolean
|
|
32
|
+
}) {
|
|
33
|
+
const [value, setValue] = useState(initial)
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
style={{
|
|
37
|
+
width: '14rem',
|
|
38
|
+
minHeight: '12rem',
|
|
39
|
+
display: 'flex',
|
|
40
|
+
alignItems: 'flex-start',
|
|
41
|
+
}}>
|
|
42
|
+
<SelectMenu
|
|
43
|
+
aria-label="Demo select"
|
|
44
|
+
options={options}
|
|
45
|
+
value={value}
|
|
46
|
+
onChange={setValue}
|
|
47
|
+
size={size}
|
|
48
|
+
disabled={disabled}
|
|
49
|
+
/>
|
|
50
|
+
</div>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default defineCases(
|
|
55
|
+
'SelectMenu',
|
|
56
|
+
{
|
|
57
|
+
Playground: {
|
|
58
|
+
tweaks: {
|
|
59
|
+
// The values to choose between — edit the comma-separated list to change
|
|
60
|
+
// the menu's options live.
|
|
61
|
+
options: tweak.text('Apple, Banana, Cherry, Date, Elderberry'),
|
|
62
|
+
// The selected value (any of the options above); you can also open the
|
|
63
|
+
// menu and pick in the stage.
|
|
64
|
+
value: tweak.text('Cherry'),
|
|
65
|
+
size: tweak.choice(['sm', 'md'], 'md'),
|
|
66
|
+
disabled: tweak.boolean(false),
|
|
67
|
+
},
|
|
68
|
+
// Re-seed when the options or starting value change; size/disabled flow in
|
|
69
|
+
// as props (no remount, so an in-stage pick survives toggling them).
|
|
70
|
+
render: (t) => {
|
|
71
|
+
const options = (t.options as string)
|
|
72
|
+
.split(',')
|
|
73
|
+
.map((s) => s.trim())
|
|
74
|
+
.filter(Boolean)
|
|
75
|
+
return (
|
|
76
|
+
<Demo
|
|
77
|
+
key={`${t.options}|${t.value}`}
|
|
78
|
+
options={options}
|
|
79
|
+
initial={t.value as string}
|
|
80
|
+
size={t.size as 'sm' | 'md'}
|
|
81
|
+
disabled={t.disabled}
|
|
82
|
+
/>
|
|
83
|
+
)
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
// Distinct `key` per case: the browse chrome swaps cases in place, so an
|
|
87
|
+
// unkeyed <Demo> would keep the previous case's selected value (and `Grouped`
|
|
88
|
+
// uses a different option set entirely) — see docs/writing-cases.md.
|
|
89
|
+
Default: () => <Demo key="default" options={FRUITS} initial="Cherry" />,
|
|
90
|
+
Small: () => (
|
|
91
|
+
<Demo key="small" options={FRUITS} initial="Apple" size="sm" />
|
|
92
|
+
),
|
|
93
|
+
Disabled: () => (
|
|
94
|
+
<Demo key="disabled" options={FRUITS} initial="Banana" disabled />
|
|
95
|
+
),
|
|
96
|
+
// Open the menu in the stage to see the group headers.
|
|
97
|
+
Grouped: () => <Demo key="grouped" options={GROUPED} initial="desktop" />,
|
|
98
|
+
},
|
|
99
|
+
{ level: 'atom' },
|
|
100
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
.dcui-selectmenu {
|
|
2
|
+
position: relative;
|
|
3
|
+
display: inline-flex;
|
|
4
|
+
align-items: center;
|
|
5
|
+
}
|
|
6
|
+
/* The trigger borrows the native Select's chrome (.dcui-select-el) so the two
|
|
7
|
+
read identically, then layers in flex for the value + ellipsis. */
|
|
8
|
+
.dcui-selectmenu-trigger {
|
|
9
|
+
display: inline-flex;
|
|
10
|
+
align-items: center;
|
|
11
|
+
min-width: 0;
|
|
12
|
+
user-select: none;
|
|
13
|
+
}
|
|
14
|
+
.dcui-selectmenu-value {
|
|
15
|
+
overflow: hidden;
|
|
16
|
+
text-overflow: ellipsis;
|
|
17
|
+
white-space: nowrap;
|
|
18
|
+
}
|
|
19
|
+
.dcui-selectmenu-trigger[aria-disabled="true"] {
|
|
20
|
+
opacity: 0.5;
|
|
21
|
+
cursor: not-allowed;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.dcui-selectmenu-list {
|
|
25
|
+
position: fixed;
|
|
26
|
+
z-index: 60;
|
|
27
|
+
margin: 0;
|
|
28
|
+
padding: var(--dc-space-2);
|
|
29
|
+
list-style: none;
|
|
30
|
+
background: var(--dc-surface);
|
|
31
|
+
border: 1px solid var(--dc-border);
|
|
32
|
+
border-radius: var(--dc-radius-md);
|
|
33
|
+
box-shadow: var(--dc-shadow-overlay);
|
|
34
|
+
overflow-y: auto;
|
|
35
|
+
min-width: max-content;
|
|
36
|
+
}
|
|
37
|
+
.dcui-selectmenu-option {
|
|
38
|
+
display: flex;
|
|
39
|
+
align-items: center;
|
|
40
|
+
gap: var(--dc-space-3);
|
|
41
|
+
padding: var(--dc-space-2) var(--dc-space-4);
|
|
42
|
+
border-radius: var(--dc-radius-sm);
|
|
43
|
+
font-family: var(--dc-font-sans);
|
|
44
|
+
font-size: var(--dc-text-sm);
|
|
45
|
+
color: var(--dc-fg);
|
|
46
|
+
cursor: pointer;
|
|
47
|
+
white-space: nowrap;
|
|
48
|
+
}
|
|
49
|
+
/* Active (keyboard or hover) — the single moving highlight. */
|
|
50
|
+
.dcui-selectmenu-option[data-active="true"] {
|
|
51
|
+
background: var(--dc-hover);
|
|
52
|
+
}
|
|
53
|
+
/* A disabled option is a non-interactive group header (mono, uppercase, quiet)
|
|
54
|
+
— never highlighted, never the check column's concern. */
|
|
55
|
+
.dcui-selectmenu-option[aria-disabled="true"] {
|
|
56
|
+
cursor: default;
|
|
57
|
+
color: var(--dc-fg-subtle);
|
|
58
|
+
font-family: var(--dc-font-mono);
|
|
59
|
+
font-size: var(--dc-text-xs);
|
|
60
|
+
letter-spacing: 0.05em;
|
|
61
|
+
text-transform: uppercase;
|
|
62
|
+
}
|
|
63
|
+
.dcui-selectmenu-option[aria-disabled="true"][data-active="true"] {
|
|
64
|
+
background: none;
|
|
65
|
+
}
|
|
66
|
+
.dcui-selectmenu-check {
|
|
67
|
+
flex: 0 0 auto;
|
|
68
|
+
width: 1em;
|
|
69
|
+
font-family: var(--dc-font-mono);
|
|
70
|
+
font-size: var(--dc-text-xs);
|
|
71
|
+
color: var(--dc-fg-muted);
|
|
72
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
**SelectMenu** — an accessible custom single-select (WAI-ARIA "select-only combobox"): a styled trigger that opens a popup listbox. Looks like `Select`, but commits the picked value **instantly** with no native OS popup — reach for it when the choice drives a live view that must update the moment an option is clicked.
|
|
2
|
+
|
|
3
|
+
```tsx
|
|
4
|
+
<SelectMenu
|
|
5
|
+
aria-label="variant"
|
|
6
|
+
options={['primary', 'ghost', 'bare']}
|
|
7
|
+
value={variant}
|
|
8
|
+
onChange={setVariant}
|
|
9
|
+
/>
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Controlled only: pass `value` and an `onChange(value: string)` (the value, **not** an event — unlike `Select`). `options` are plain strings or `{ value, label }` for a display label that differs from the stored value.
|
|
13
|
+
|
|
14
|
+
Why it exists: a native `<select>` on macOS defers its `change` event until the OS dropdown finishes dismissing, which visibly lags any live-updating preview. `SelectMenu` avoids that. Prefer the simpler **`Select`** (native) when there's no live binding, or when you need `<optgroup>` grouping (SelectMenu is flat).
|
|
15
|
+
|
|
16
|
+
Accessibility: focus stays on the `role="combobox"` trigger; the active option is tracked via `aria-activedescendant`. Full keyboard parity with a native select — ↑/↓ move, Home/End jump, type-ahead matches by label, Enter/Space commit, Esc cancels, Tab commits then moves on. The popup portals to `document.body`, so an `overflow`-clipping or `position: fixed` ancestor won't trap it.
|
|
17
|
+
|
|
18
|
+
Sizes: `sm` · `md` (default). `disabled` skips interaction and drops it from the tab order.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import { SelectMenu } from './SelectMenu'
|
|
4
|
+
|
|
5
|
+
// The popup listbox is portaled to document.body and only mounts while open;
|
|
6
|
+
// SSR renders the closed trigger, so these assert the combobox's resting state.
|
|
7
|
+
const noop = () => {}
|
|
8
|
+
|
|
9
|
+
describe('SelectMenu', () => {
|
|
10
|
+
test('renders a closed combobox trigger with the listbox wiring', () => {
|
|
11
|
+
const html = renderToStaticMarkup(
|
|
12
|
+
<SelectMenu
|
|
13
|
+
options={['light', 'dark']}
|
|
14
|
+
value="light"
|
|
15
|
+
onChange={noop}
|
|
16
|
+
aria-label="Theme"
|
|
17
|
+
/>,
|
|
18
|
+
)
|
|
19
|
+
expect(html).toContain('role="combobox"')
|
|
20
|
+
expect(html).toContain('aria-haspopup="listbox"')
|
|
21
|
+
expect(html).toContain('aria-expanded="false"')
|
|
22
|
+
expect(html).toContain('aria-label="Theme"')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('shows the selected option label in the trigger', () => {
|
|
26
|
+
const html = renderToStaticMarkup(
|
|
27
|
+
<SelectMenu
|
|
28
|
+
options={[{ value: 'dark', label: 'Dark mode' }, 'light']}
|
|
29
|
+
value="dark"
|
|
30
|
+
onChange={noop}
|
|
31
|
+
/>,
|
|
32
|
+
)
|
|
33
|
+
expect(html).toContain('Dark mode')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('does not render the popup listbox while closed', () => {
|
|
37
|
+
const html = renderToStaticMarkup(
|
|
38
|
+
<SelectMenu options={['a', 'b']} value="a" onChange={noop} />,
|
|
39
|
+
)
|
|
40
|
+
expect(html).not.toContain('role="listbox"')
|
|
41
|
+
expect(html).not.toContain('role="option"')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('an unknown value falls back to the first selectable option label', () => {
|
|
45
|
+
const html = renderToStaticMarkup(
|
|
46
|
+
<SelectMenu
|
|
47
|
+
options={[
|
|
48
|
+
{ value: 'h', label: 'Group', disabled: true },
|
|
49
|
+
{ value: 'one', label: 'First' },
|
|
50
|
+
]}
|
|
51
|
+
value="nope"
|
|
52
|
+
onChange={noop}
|
|
53
|
+
/>,
|
|
54
|
+
)
|
|
55
|
+
// The disabled header is skipped, so the first real option leads.
|
|
56
|
+
expect(html).toContain('First')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('a disabled menu is taken out of the tab order and marked aria-disabled', () => {
|
|
60
|
+
const html = renderToStaticMarkup(
|
|
61
|
+
<SelectMenu options={['a']} value="a" onChange={noop} disabled />,
|
|
62
|
+
)
|
|
63
|
+
expect(html).toContain('aria-disabled="true"')
|
|
64
|
+
expect(html).toContain('tabindex="-1"')
|
|
65
|
+
})
|
|
66
|
+
})
|