@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.
Files changed (254) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +309 -0
  3. package/display-case.prompt.md +64 -0
  4. package/docs/ai-agents.md +126 -0
  5. package/docs/cli.md +99 -0
  6. package/docs/configuration.md +410 -0
  7. package/docs/documentation-panel.md +50 -0
  8. package/docs/examples/README.md +14 -0
  9. package/docs/examples/multi-variant.case.tsx +30 -0
  10. package/docs/examples/plain.case.tsx +22 -0
  11. package/docs/examples/tweak-control.placard.md +80 -0
  12. package/docs/examples/tweaks.case.tsx +39 -0
  13. package/docs/hierarchy.md +59 -0
  14. package/docs/quick-start.md +78 -0
  15. package/docs/style-engines.md +180 -0
  16. package/docs/testing.md +245 -0
  17. package/docs/theming.md +97 -0
  18. package/docs/tweaks.md +75 -0
  19. package/docs/writing-cases.md +144 -0
  20. package/docs/writing-placard-docs.md +194 -0
  21. package/package.json +113 -0
  22. package/skills/display-case-author-case/README.md +20 -0
  23. package/skills/display-case-author-case/SKILL.md +40 -0
  24. package/skills/display-case-author-placard-doc/README.md +24 -0
  25. package/skills/display-case-author-placard-doc/SKILL.md +65 -0
  26. package/skills/display-case-review/README.md +19 -0
  27. package/skills/display-case-review/SKILL.md +30 -0
  28. package/skills/display-case-snapshot/README.md +20 -0
  29. package/skills/display-case-snapshot/SKILL.md +29 -0
  30. package/src/checks/a11y-scanner.test.ts +240 -0
  31. package/src/checks/a11y-scanner.ts +410 -0
  32. package/src/checks/check-text.test.ts +53 -0
  33. package/src/checks/check-text.ts +78 -0
  34. package/src/checks/check.test.ts +194 -0
  35. package/src/checks/check.ts +473 -0
  36. package/src/checks/providers/pixelmatch-diff.test.ts +79 -0
  37. package/src/checks/providers/pixelmatch-diff.ts +30 -0
  38. package/src/checks/providers/playwright-driver.ts +104 -0
  39. package/src/checks/ssr-check.test.ts +73 -0
  40. package/src/checks/ssr-check.ts +96 -0
  41. package/src/checks/structure-check.cross-package.test.ts +165 -0
  42. package/src/checks/structure-check.test.ts +651 -0
  43. package/src/checks/structure-check.ts +988 -0
  44. package/src/checks/tokens-check.test.ts +159 -0
  45. package/src/checks/tokens-check.ts +162 -0
  46. package/src/cli.ts +218 -0
  47. package/src/commands/agents.test.ts +24 -0
  48. package/src/commands/agents.ts +28 -0
  49. package/src/commands/init-run.test.ts +123 -0
  50. package/src/commands/init.test.ts +63 -0
  51. package/src/commands/init.ts +412 -0
  52. package/src/commands/publish.test.ts +210 -0
  53. package/src/commands/publish.ts +292 -0
  54. package/src/core/affected.test.ts +99 -0
  55. package/src/core/affected.ts +144 -0
  56. package/src/core/catalog.test.ts +152 -0
  57. package/src/core/catalog.ts +92 -0
  58. package/src/core/discovery.test.ts +184 -0
  59. package/src/core/discovery.ts +250 -0
  60. package/src/core/manifest.ts +41 -0
  61. package/src/core/mdx-lite/__fixtures__/box-stub.tsx +7 -0
  62. package/src/core/mdx-lite/index.ts +393 -0
  63. package/src/core/mdx-lite/mdx-lite.test.ts +345 -0
  64. package/src/core/mdx-plugin.test.ts +60 -0
  65. package/src/core/mdx-plugin.ts +30 -0
  66. package/src/flow-step.test-d.ts +39 -0
  67. package/src/index.test.ts +100 -0
  68. package/src/index.ts +564 -0
  69. package/src/render/collect-styles.emotion.test.tsx +114 -0
  70. package/src/render/collect-styles.test.tsx +72 -0
  71. package/src/render/collect-styles.ts +33 -0
  72. package/src/render/documents.test.ts +184 -0
  73. package/src/render/documents.ts +88 -0
  74. package/src/render/render-node.test.tsx +160 -0
  75. package/src/render/render-node.tsx +133 -0
  76. package/src/render/ssr-primer.test.tsx +25 -0
  77. package/src/render/ssr-primer.tsx +54 -0
  78. package/src/render/ssr-render.test.tsx +142 -0
  79. package/src/render/ssr-render.tsx +63 -0
  80. package/src/render/ssr-shell.test.tsx +57 -0
  81. package/src/render/ssr-shell.tsx +54 -0
  82. package/src/server/prod-server.ts +237 -0
  83. package/src/server/server.test.ts +117 -0
  84. package/src/server/server.ts +1039 -0
  85. package/src/style-engine.test-d.ts +37 -0
  86. package/src/testing/test-helpers.ts +27 -0
  87. package/src/types/pixelmatch.d.ts +12 -0
  88. package/src/ui/browser-entry.tsx +51 -0
  89. package/src/ui/chrome.css +485 -0
  90. package/src/ui/design-system/README.md +88 -0
  91. package/src/ui/design-system/components/controls/Button.case.tsx +52 -0
  92. package/src/ui/design-system/components/controls/Button.css +89 -0
  93. package/src/ui/design-system/components/controls/Button.placard.md +14 -0
  94. package/src/ui/design-system/components/controls/Button.test.tsx +45 -0
  95. package/src/ui/design-system/components/controls/Button.tsx +41 -0
  96. package/src/ui/design-system/components/controls/IconButton.case.tsx +52 -0
  97. package/src/ui/design-system/components/controls/IconButton.css +67 -0
  98. package/src/ui/design-system/components/controls/IconButton.placard.md +13 -0
  99. package/src/ui/design-system/components/controls/IconButton.test.tsx +39 -0
  100. package/src/ui/design-system/components/controls/IconButton.tsx +47 -0
  101. package/src/ui/design-system/components/controls/Input.case.tsx +50 -0
  102. package/src/ui/design-system/components/controls/Input.css +52 -0
  103. package/src/ui/design-system/components/controls/Input.placard.md +12 -0
  104. package/src/ui/design-system/components/controls/Input.test.tsx +43 -0
  105. package/src/ui/design-system/components/controls/Input.tsx +45 -0
  106. package/src/ui/design-system/components/controls/Select.case.tsx +48 -0
  107. package/src/ui/design-system/components/controls/Select.css +44 -0
  108. package/src/ui/design-system/components/controls/Select.placard.md +15 -0
  109. package/src/ui/design-system/components/controls/Select.test.tsx +57 -0
  110. package/src/ui/design-system/components/controls/Select.tsx +58 -0
  111. package/src/ui/design-system/components/controls/SelectMenu.case.tsx +100 -0
  112. package/src/ui/design-system/components/controls/SelectMenu.css +72 -0
  113. package/src/ui/design-system/components/controls/SelectMenu.placard.md +18 -0
  114. package/src/ui/design-system/components/controls/SelectMenu.test.tsx +66 -0
  115. package/src/ui/design-system/components/controls/SelectMenu.tsx +377 -0
  116. package/src/ui/design-system/components/index.ts +66 -0
  117. package/src/ui/design-system/components/primer-specimen/ColorRamp.case.tsx +44 -0
  118. package/src/ui/design-system/components/primer-specimen/ColorRamp.placard.md +15 -0
  119. package/src/ui/design-system/components/primer-specimen/ColorRamp.tsx +51 -0
  120. package/src/ui/design-system/components/primer-specimen/DefinitionList.case.tsx +38 -0
  121. package/src/ui/design-system/components/primer-specimen/DefinitionList.placard.md +15 -0
  122. package/src/ui/design-system/components/primer-specimen/DefinitionList.tsx +41 -0
  123. package/src/ui/design-system/components/primer-specimen/FontFamilies.case.tsx +24 -0
  124. package/src/ui/design-system/components/primer-specimen/FontFamilies.placard.md +12 -0
  125. package/src/ui/design-system/components/primer-specimen/FontFamilies.tsx +41 -0
  126. package/src/ui/design-system/components/primer-specimen/GlyphGrid.case.tsx +27 -0
  127. package/src/ui/design-system/components/primer-specimen/GlyphGrid.placard.md +13 -0
  128. package/src/ui/design-system/components/primer-specimen/GlyphGrid.tsx +34 -0
  129. package/src/ui/design-system/components/primer-specimen/LayoutMock.case.tsx +36 -0
  130. package/src/ui/design-system/components/primer-specimen/LayoutMock.placard.md +7 -0
  131. package/src/ui/design-system/components/primer-specimen/LayoutMock.tsx +36 -0
  132. package/src/ui/design-system/components/primer-specimen/SpacingScale.case.tsx +20 -0
  133. package/src/ui/design-system/components/primer-specimen/SpacingScale.placard.md +12 -0
  134. package/src/ui/design-system/components/primer-specimen/SpacingScale.tsx +33 -0
  135. package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.case.tsx +56 -0
  136. package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.placard.md +17 -0
  137. package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.tsx +45 -0
  138. package/src/ui/design-system/components/primer-specimen/StatusList.case.tsx +17 -0
  139. package/src/ui/design-system/components/primer-specimen/StatusList.placard.md +16 -0
  140. package/src/ui/design-system/components/primer-specimen/StatusList.tsx +39 -0
  141. package/src/ui/design-system/components/primer-specimen/SwatchGrid.case.tsx +26 -0
  142. package/src/ui/design-system/components/primer-specimen/SwatchGrid.placard.md +15 -0
  143. package/src/ui/design-system/components/primer-specimen/SwatchGrid.tsx +42 -0
  144. package/src/ui/design-system/components/primer-specimen/TypeScale.case.tsx +23 -0
  145. package/src/ui/design-system/components/primer-specimen/TypeScale.placard.md +14 -0
  146. package/src/ui/design-system/components/primer-specimen/TypeScale.tsx +34 -0
  147. package/src/ui/design-system/components/primer-specimen/WeightSpecimen.case.tsx +28 -0
  148. package/src/ui/design-system/components/primer-specimen/WeightSpecimen.placard.md +15 -0
  149. package/src/ui/design-system/components/primer-specimen/WeightSpecimen.tsx +46 -0
  150. package/src/ui/design-system/components/primer-specimen/index.ts +31 -0
  151. package/src/ui/design-system/components/primer-specimen/styles.css +476 -0
  152. package/src/ui/design-system/components/shell/A11yPage.case.tsx +237 -0
  153. package/src/ui/design-system/components/shell/A11yPage.placard.md +15 -0
  154. package/src/ui/design-system/components/shell/CaseTemplate.case.tsx +32 -0
  155. package/src/ui/design-system/components/shell/CaseTemplate.placard.md +5 -0
  156. package/src/ui/design-system/components/shell/CasesPage.case.tsx +141 -0
  157. package/src/ui/design-system/components/shell/CasesPage.placard.md +12 -0
  158. package/src/ui/design-system/components/shell/PrimerPage.case.tsx +22 -0
  159. package/src/ui/design-system/components/shell/PrimerPage.placard.md +3 -0
  160. package/src/ui/design-system/components/shell/PrimerTemplate.case.tsx +22 -0
  161. package/src/ui/design-system/components/shell/PrimerTemplate.placard.md +5 -0
  162. package/src/ui/design-system/components/shell/ShellView.case.tsx +57 -0
  163. package/src/ui/design-system/components/shell/ShellView.placard.md +5 -0
  164. package/src/ui/design-system/components/shell/ShellView.tsx +678 -0
  165. package/src/ui/design-system/components/shell/shell-fixtures.tsx +727 -0
  166. package/src/ui/design-system/components/showcase/A11yBadge.case.tsx +46 -0
  167. package/src/ui/design-system/components/showcase/A11yBadge.css +27 -0
  168. package/src/ui/design-system/components/showcase/A11yBadge.placard.md +11 -0
  169. package/src/ui/design-system/components/showcase/A11yBadge.test.tsx +31 -0
  170. package/src/ui/design-system/components/showcase/A11yBadge.tsx +41 -0
  171. package/src/ui/design-system/components/showcase/A11yPanel.case.tsx +121 -0
  172. package/src/ui/design-system/components/showcase/A11yPanel.css +198 -0
  173. package/src/ui/design-system/components/showcase/A11yPanel.placard.md +19 -0
  174. package/src/ui/design-system/components/showcase/A11yPanel.test.tsx +81 -0
  175. package/src/ui/design-system/components/showcase/A11yPanel.tsx +144 -0
  176. package/src/ui/design-system/components/showcase/Chip.case.tsx +48 -0
  177. package/src/ui/design-system/components/showcase/Chip.css +51 -0
  178. package/src/ui/design-system/components/showcase/Chip.placard.md +13 -0
  179. package/src/ui/design-system/components/showcase/Chip.test.tsx +46 -0
  180. package/src/ui/design-system/components/showcase/Chip.tsx +54 -0
  181. package/src/ui/design-system/components/showcase/Eyebrow.case.tsx +30 -0
  182. package/src/ui/design-system/components/showcase/Eyebrow.css +16 -0
  183. package/src/ui/design-system/components/showcase/Eyebrow.placard.md +10 -0
  184. package/src/ui/design-system/components/showcase/Eyebrow.test.tsx +38 -0
  185. package/src/ui/design-system/components/showcase/Eyebrow.tsx +29 -0
  186. package/src/ui/design-system/components/showcase/FlowNav.case.tsx +35 -0
  187. package/src/ui/design-system/components/showcase/FlowNav.css +29 -0
  188. package/src/ui/design-system/components/showcase/FlowNav.placard.md +13 -0
  189. package/src/ui/design-system/components/showcase/FlowNav.test.tsx +48 -0
  190. package/src/ui/design-system/components/showcase/FlowNav.tsx +58 -0
  191. package/src/ui/design-system/components/showcase/ImpactTag.case.tsx +19 -0
  192. package/src/ui/design-system/components/showcase/ImpactTag.css +36 -0
  193. package/src/ui/design-system/components/showcase/ImpactTag.placard.md +14 -0
  194. package/src/ui/design-system/components/showcase/ImpactTag.test.tsx +40 -0
  195. package/src/ui/design-system/components/showcase/ImpactTag.tsx +35 -0
  196. package/src/ui/design-system/components/showcase/NavItem.case.tsx +86 -0
  197. package/src/ui/design-system/components/showcase/NavItem.css +111 -0
  198. package/src/ui/design-system/components/showcase/NavItem.placard.md +13 -0
  199. package/src/ui/design-system/components/showcase/NavItem.test.tsx +65 -0
  200. package/src/ui/design-system/components/showcase/NavItem.tsx +95 -0
  201. package/src/ui/design-system/components/showcase/RenderAddress.case.tsx +21 -0
  202. package/src/ui/design-system/components/showcase/RenderAddress.css +35 -0
  203. package/src/ui/design-system/components/showcase/RenderAddress.placard.md +7 -0
  204. package/src/ui/design-system/components/showcase/RenderAddress.test.tsx +26 -0
  205. package/src/ui/design-system/components/showcase/RenderAddress.tsx +43 -0
  206. package/src/ui/design-system/components/showcase/SegmentedToggle.case.tsx +84 -0
  207. package/src/ui/design-system/components/showcase/SegmentedToggle.css +61 -0
  208. package/src/ui/design-system/components/showcase/SegmentedToggle.placard.md +21 -0
  209. package/src/ui/design-system/components/showcase/SegmentedToggle.test.tsx +81 -0
  210. package/src/ui/design-system/components/showcase/SegmentedToggle.tsx +75 -0
  211. package/src/ui/design-system/components/showcase/Sidebar.case.tsx +67 -0
  212. package/src/ui/design-system/components/showcase/Sidebar.css +6 -0
  213. package/src/ui/design-system/components/showcase/Sidebar.placard.md +14 -0
  214. package/src/ui/design-system/components/showcase/Sidebar.test.tsx +32 -0
  215. package/src/ui/design-system/components/showcase/Sidebar.tsx +30 -0
  216. package/src/ui/design-system/components/showcase/Stage.case.tsx +51 -0
  217. package/src/ui/design-system/components/showcase/Stage.css +91 -0
  218. package/src/ui/design-system/components/showcase/Stage.placard.md +15 -0
  219. package/src/ui/design-system/components/showcase/Stage.test.tsx +84 -0
  220. package/src/ui/design-system/components/showcase/Stage.tsx +97 -0
  221. package/src/ui/design-system/components/showcase/TweaksPanel.case.tsx +81 -0
  222. package/src/ui/design-system/components/showcase/TweaksPanel.css +169 -0
  223. package/src/ui/design-system/components/showcase/TweaksPanel.placard.md +20 -0
  224. package/src/ui/design-system/components/showcase/TweaksPanel.tsx +230 -0
  225. package/src/ui/design-system/components/showcase/Wordmark.case.tsx +42 -0
  226. package/src/ui/design-system/components/showcase/Wordmark.css +31 -0
  227. package/src/ui/design-system/components/showcase/Wordmark.placard.md +10 -0
  228. package/src/ui/design-system/components/showcase/Wordmark.test.tsx +22 -0
  229. package/src/ui/design-system/components/showcase/Wordmark.tsx +22 -0
  230. package/src/ui/design-system/primer-specimens/brand.tsx +26 -0
  231. package/src/ui/design-system/primer-specimens/colors.tsx +83 -0
  232. package/src/ui/design-system/primer-specimens/components.tsx +308 -0
  233. package/src/ui/design-system/primer-specimens/foundations.tsx +71 -0
  234. package/src/ui/design-system/primer-specimens/index.ts +25 -0
  235. package/src/ui/design-system/primer-specimens/showcase.tsx +68 -0
  236. package/src/ui/design-system/primer-specimens/spacing.tsx +101 -0
  237. package/src/ui/design-system/primer-specimens/type.tsx +75 -0
  238. package/src/ui/design-system/primer.mdx +236 -0
  239. package/src/ui/design-system/styles.css +14 -0
  240. package/src/ui/design-system/tokens/colors.css +172 -0
  241. package/src/ui/design-system/tokens/fonts.css +18 -0
  242. package/src/ui/design-system/tokens/spacing.css +48 -0
  243. package/src/ui/design-system/tokens/typography.css +49 -0
  244. package/src/ui/markdown.test.tsx +54 -0
  245. package/src/ui/markdown.tsx +19 -0
  246. package/src/ui/primer-mount.tsx +76 -0
  247. package/src/ui/primer.css +175 -0
  248. package/src/ui/primer.tsx +277 -0
  249. package/src/ui/render-mount.tsx +284 -0
  250. package/src/ui/shell-core.test.ts +340 -0
  251. package/src/ui/shell-core.ts +295 -0
  252. package/src/ui/shell.tsx +60 -0
  253. package/src/ui/test-ids.ts +53 -0
  254. 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
+ })