@charcuterie/ui 3.18.0 → 3.19.1

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 (69) hide show
  1. package/dist/Combobox/Combobox.d.ts +14 -1
  2. package/dist/Combobox/Combobox.d.ts.map +1 -1
  3. package/dist/Combobox/Combobox.js +32 -6
  4. package/dist/Combobox/Combobox.js.map +1 -1
  5. package/dist/Combobox/ComboboxOption.d.ts +4 -1
  6. package/dist/Combobox/ComboboxOption.d.ts.map +1 -1
  7. package/dist/Combobox/ComboboxOption.js +8 -2
  8. package/dist/Combobox/ComboboxOption.js.map +1 -1
  9. package/dist/Listbox/Listbox.d.ts +13 -1
  10. package/dist/Listbox/Listbox.d.ts.map +1 -1
  11. package/dist/Listbox/Listbox.js +4 -2
  12. package/dist/Listbox/Listbox.js.map +1 -1
  13. package/dist/Listbox/ListboxOption.d.ts +4 -1
  14. package/dist/Listbox/ListboxOption.d.ts.map +1 -1
  15. package/dist/Listbox/ListboxOption.js +7 -2
  16. package/dist/Listbox/ListboxOption.js.map +1 -1
  17. package/dist/Menu/Menu.d.ts +15 -1
  18. package/dist/Menu/Menu.d.ts.map +1 -1
  19. package/dist/Menu/Menu.js +39 -5
  20. package/dist/Menu/Menu.js.map +1 -1
  21. package/dist/Menu/MenuAction.d.ts +4 -1
  22. package/dist/Menu/MenuAction.d.ts.map +1 -1
  23. package/dist/Menu/MenuAction.js +22 -4
  24. package/dist/Menu/MenuAction.js.map +1 -1
  25. package/dist/Overlay/useAnchoredOverlay.d.ts +25 -1
  26. package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -1
  27. package/dist/Overlay/useAnchoredOverlay.js +8 -4
  28. package/dist/Overlay/useAnchoredOverlay.js.map +1 -1
  29. package/dist/Overlay/usePanelItemSize.d.ts +3 -0
  30. package/dist/Overlay/usePanelItemSize.d.ts.map +1 -0
  31. package/dist/Overlay/usePanelItemSize.js +59 -0
  32. package/dist/Overlay/usePanelItemSize.js.map +1 -0
  33. package/dist/controlStyles.d.ts +30 -0
  34. package/dist/controlStyles.d.ts.map +1 -1
  35. package/dist/controlStyles.js +34 -0
  36. package/dist/controlStyles.js.map +1 -1
  37. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts.map +1 -1
  38. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js +1 -1
  39. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js.map +1 -1
  40. package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -1
  41. package/dist/markdownEditorCodeMirror/livePreview.js +22 -0
  42. package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -1
  43. package/dist/viewport.testHelpers.d.ts +18 -4
  44. package/dist/viewport.testHelpers.d.ts.map +1 -1
  45. package/dist/viewport.testHelpers.js +18 -6
  46. package/dist/viewport.testHelpers.js.map +1 -1
  47. package/package.json +1 -1
  48. package/src/Combobox/Combobox.mdx +26 -0
  49. package/src/Combobox/Combobox.stories.tsx +65 -0
  50. package/src/Combobox/Combobox.test.tsx +46 -0
  51. package/src/Combobox/Combobox.tsx +76 -24
  52. package/src/Combobox/ComboboxOption.tsx +11 -1
  53. package/src/Listbox/Listbox.mdx +24 -0
  54. package/src/Listbox/Listbox.stories.tsx +81 -5
  55. package/src/Listbox/Listbox.tsx +17 -0
  56. package/src/Listbox/ListboxOption.tsx +10 -1
  57. package/src/Menu/Menu.mdx +49 -0
  58. package/src/Menu/Menu.stories.tsx +113 -2
  59. package/src/Menu/Menu.test.tsx +129 -0
  60. package/src/Menu/Menu.tsx +62 -3
  61. package/src/Menu/MenuAction.tsx +29 -4
  62. package/src/Overlay/useAnchoredOverlay.ts +33 -5
  63. package/src/Overlay/usePanelItemSize.ts +71 -0
  64. package/src/controlStyles.ts +38 -0
  65. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.test.tsx +185 -0
  66. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.tsx +42 -1
  67. package/src/markdownEditorCodeMirror/livePreview.ts +22 -0
  68. package/src/sourceRules.test.ts +9 -0
  69. package/src/viewport.testHelpers.ts +21 -6
@@ -11,17 +11,23 @@ import { page } from "@vitest/browser/context";
11
11
  * fails intermittently — which is the worst possible property for
12
12
  * the one gate that says the shell does not scroll sideways.
13
13
  *
14
- * Polling `innerWidth` is the honest wait: it is the same number
15
- * the media queries and the layout resolved against, so when it
16
- * matches, the thing under test really is at the width the test
17
- * claims.
14
+ * Polling `innerWidth`/`innerHeight` is the honest wait: they are the
15
+ * same numbers the media queries and the layout resolved against, so
16
+ * when they match, the thing under test really is at the size the
17
+ * test claims.
18
+ *
19
+ * **Height counts too**, and it did not used to. A panel row's size
20
+ * steps down under `(height <= 40rem)` (`usePanelItemSize`), so a test
21
+ * that shrinks only the height would have raced the resize with
22
+ * nothing to wait on and measured the tall rows about half the time.
18
23
  */
19
24
  export const setViewport = async ({ height, width, }) => {
20
25
  await page.viewport(width, height);
21
26
  const deadline = Date.now() + 2000;
22
- while (globalThis.innerWidth !== width) {
27
+ while (globalThis.innerWidth !== width ||
28
+ globalThis.innerHeight !== height) {
23
29
  if (Date.now() > deadline) {
24
- throw new Error(`The viewport never reached ${width}px — it is ${globalThis.innerWidth}px. Every width assertion after this point would be measuring the wrong page.`);
30
+ throw new Error(`The viewport never reached ${width}x${height}px — it is ${globalThis.innerWidth}x${globalThis.innerHeight}px. Every size assertion after this point would be measuring the wrong page.`);
25
31
  }
26
32
  await new Promise((resolve) => {
27
33
  globalThis.requestAnimationFrame(resolve);
@@ -31,4 +37,10 @@ export const setViewport = async ({ height, width, }) => {
31
37
  /** A phone in portrait — the width plex-channels scrolls at. */
32
38
  export const PHONE = { height: 844, width: 390 };
33
39
  export const DESKTOP = { height: 900, width: 1440 };
40
+ /**
41
+ * A window short enough to trip `usePanelItemSize`'s first step-down
42
+ * (`height <= 40rem`, so 640px) and wide enough that nothing else
43
+ * about the layout changes with it.
44
+ */
45
+ export const SHORT_WINDOW = { height: 600, width: 1440 };
34
46
  //# sourceMappingURL=viewport.testHelpers.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"viewport.testHelpers.js","sourceRoot":"","sources":["../src/viewport.testHelpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,yBAAyB,CAAA;AAE9C;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,KAAK,EAAE,EAChC,MAAM,EACN,KAAK,GAIN,EAAiB,EAAE;IAClB,MAAM,IAAI,CAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,CAAA;IAElC,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAA;IAElC,OAAO,UAAU,CAAC,UAAU,KAAK,KAAK,EAAE,CAAC;QACvC,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,QAAQ,EAAE,CAAC;YAC1B,MAAM,IAAI,KAAK,CACb,8BAA8B,KAAK,cAAc,UAAU,CAAC,UAAU,+EAA+E,CACtJ,CAAA;QACH,CAAC;QAED,MAAM,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;YAC5B,UAAU,CAAC,qBAAqB,CAAC,OAAO,CAAC,CAAA;QAC3C,CAAC,CAAC,CAAA;IACJ,CAAC;AACH,CAAC,CAAA;AAED,gEAAgE;AAChE,MAAM,CAAC,MAAM,KAAK,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,GAAG,EAAE,CAAA;AAEhD,MAAM,CAAC,MAAM,OAAO,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,CAAA"}
1
+ {"version":3,"file":"viewport.testHelpers.js","sourceRoot":"","sources":["../src/viewport.testHelpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,yBAAyB,CAAA;AAE9C;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,KAAK,EAAE,EAChC,MAAM,EACN,KAAK,GAIN,EAAiB,EAAE;IAClB,MAAM,IAAI,CAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,CAAA;IAElC,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAA;IAElC,OACE,UAAU,CAAC,UAAU,KAAK,KAAK;QAC/B,UAAU,CAAC,WAAW,KAAK,MAAM,EACjC,CAAC;QACD,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,QAAQ,EAAE,CAAC;YAC1B,MAAM,IAAI,KAAK,CACb,8BAA8B,KAAK,IAAI,MAAM,cAAc,UAAU,CAAC,UAAU,IAAI,UAAU,CAAC,WAAW,8EAA8E,CACzL,CAAA;QACH,CAAC;QAED,MAAM,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;YAC5B,UAAU,CAAC,qBAAqB,CAAC,OAAO,CAAC,CAAA;QAC3C,CAAC,CAAC,CAAA;IACJ,CAAC;AACH,CAAC,CAAA;AAED,gEAAgE;AAChE,MAAM,CAAC,MAAM,KAAK,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,GAAG,EAAE,CAAA;AAEhD,MAAM,CAAC,MAAM,OAAO,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,CAAA;AAEnD;;;;GAIG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@charcuterie/ui",
3
- "version": "3.18.0",
3
+ "version": "3.19.1",
4
4
  "description": "The Charcuterie components. Tokens for look, @charcuterie/logic for state, ARIA that agents can actually drive.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -99,3 +99,29 @@ for a 4,000-item list. Below the threshold the list is plain DOM.
99
99
  <Canvas of={ComboboxStories.Virtualized} />
100
100
 
101
101
  <Canvas of={ComboboxStories.Interactive} />
102
+
103
+ ## One panel, one row size
104
+
105
+ `itemSize` drives the search field **and** the options together, from the same
106
+ density-aware `--control-height-*` tokens a `Button` reads. It defaults to `md`, so at the
107
+ default a trigger, the search field and every option are all 2.25rem.
108
+
109
+ Covering the search row is the point rather than a bonus. The report that started this was
110
+ that a `Combobox`'s trigger was bigger than the options under it — but the panel's own
111
+ field was bigger than them too, and **two sizes inside one panel** is the defect rather
112
+ than either number being wrong.
113
+
114
+ It also retires a hand-picked number. The search row used to carry `px-3` to line its caret
115
+ up with the option labels below it, which worked only while the option padding was a
116
+ hardcoded `px-2`; it is now wrapped in the list's own `p-1` and given the same row-size
117
+ class, so the two cannot drift apart again.
118
+
119
+ `lg` is the fat one — 2.75rem, the 44px WCAG 2.5.5 target. A window shorter than 40rem
120
+ steps each size down one step on its own.
121
+
122
+ The virtualizer's `estimateSize` tracks `itemSize` too. Every row is re-measured
123
+ immediately, so the estimate only decides how far the scrollbar lies before the first
124
+ measurement lands — but a `lg` list opening with a scrollbar a third too short and visibly
125
+ settling is exactly the kind of thing nobody files a bug about.
126
+
127
+ <Canvas of={ComboboxStories.ItemSizes} />
@@ -1,9 +1,15 @@
1
1
  import { useVisibility } from "@charcuterie/logic"
2
+ import type { ControlSize } from "@charcuterie/tokens"
2
3
  import type { Meta, StoryObj } from "@storybook/react"
3
4
  import type { ReactNode } from "react"
4
5
  import { useRef, useState } from "react"
5
6
 
7
+ import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
6
8
  import { Button } from "../Button/Button.tsx"
9
+ import {
10
+ StoryCell,
11
+ StoryGrid,
12
+ } from "../board.storyHelpers.tsx"
7
13
  import type { ListboxItem } from "../Listbox/Listbox.tsx"
8
14
  import { Combobox } from "./Combobox.tsx"
9
15
 
@@ -49,6 +55,8 @@ const meta = {
49
55
  title: "Components/Controls/Combobox",
50
56
  component: Combobox,
51
57
  parameters: { layout: "padded" },
58
+ argTypes: { itemSize: controlSizeArgType },
59
+ args: { itemSize: "md" },
52
60
  } satisfies Meta<typeof Combobox>
53
61
 
54
62
  export default meta
@@ -65,6 +73,7 @@ const ComboboxHarness = ({
65
73
  isLoading,
66
74
  isMultiple,
67
75
  isVirtualized,
76
+ itemSize,
68
77
  options,
69
78
  selectedValue,
70
79
  triggerLabel,
@@ -78,6 +87,7 @@ const ComboboxHarness = ({
78
87
  isLoading?: boolean
79
88
  isMultiple?: boolean
80
89
  isVirtualized?: boolean
90
+ itemSize?: ControlSize
81
91
  options: ListboxItem[]
82
92
  selectedValue?: readonly string[] | string
83
93
  triggerLabel: string
@@ -105,6 +115,7 @@ const ComboboxHarness = ({
105
115
  isLoading={isLoading}
106
116
  isMultiple={isMultiple}
107
117
  isVirtualized={isVirtualized}
118
+ itemSize={itemSize}
108
119
  isVisible={isVisible}
109
120
  onDismiss={hide}
110
121
  onSelect={(value) => {
@@ -589,3 +600,57 @@ export const VirtualizedChosenValue: Story = {
589
600
  />
590
601
  ),
591
602
  }
603
+
604
+ /**
605
+ * The three row sizes — and the fix the owner's screenshot asked for.
606
+ *
607
+ * The report was that a `Combobox`'s trigger was visibly bigger than
608
+ * the options under it. Two things were true at once: the option row
609
+ * was a hardcoded `px-2 py-1.5 text-sm` that no control shares, and
610
+ * the panel's **own** search field was bigger than the options it
611
+ * filters — two sizes inside one panel, which is the defect rather
612
+ * than either number being wrong.
613
+ *
614
+ * `itemSize` now drives the search row and the options together, from
615
+ * the same `--control-height-*` a `Button` reads. At the `md` default
616
+ * a trigger, the search field and every option are all 2.25rem.
617
+ */
618
+ export const ItemSizes: Story = {
619
+ args: {
620
+ isVisible: false,
621
+ onDismiss: () => {},
622
+ onSelect: () => {},
623
+ options: LANGUAGES,
624
+ trigger: <Button>Search languages</Button>,
625
+ },
626
+ render: () => (
627
+ <StoryGrid columns={3}>
628
+ <StoryCell label="sm — the old option">
629
+ <ComboboxHarness
630
+ isInitiallyVisible
631
+ itemSize="sm"
632
+ options={LANGUAGES}
633
+ triggerLabel="Search languages"
634
+ />
635
+ </StoryCell>
636
+
637
+ <StoryCell label="md — the default, matches the trigger">
638
+ <ComboboxHarness
639
+ isInitiallyVisible
640
+ itemSize="md"
641
+ options={LANGUAGES}
642
+ triggerLabel="Search languages"
643
+ />
644
+ </StoryCell>
645
+
646
+ <StoryCell label="lg — the fat option (44px)">
647
+ <ComboboxHarness
648
+ isInitiallyVisible
649
+ itemSize="lg"
650
+ options={LANGUAGES}
651
+ triggerLabel="Search languages"
652
+ />
653
+ </StoryCell>
654
+ </StoryGrid>
655
+ ),
656
+ }
@@ -705,3 +705,49 @@ test("a windowed list reseeds its window on every open, not just the first", asy
705
705
  mounted.body.queryByRole("option", { name: "Track 1" }),
706
706
  ).toBeNull()
707
707
  })
708
+
709
+ /**
710
+ * One panel, one row size.
711
+ *
712
+ * The owner's report was that the search field was visibly bigger
713
+ * than the options it filters — *"the button is huge, but the options
714
+ * don't match it"* — and two sizes inside one panel is the defect,
715
+ * not the field being wrong or the option being wrong. `itemSize`
716
+ * drives both, so this measures the pair rather than either number.
717
+ */
718
+ test("the search row and an option are the same height", async () => {
719
+ const { body, canvas } = await mountStory(Interactive)
720
+
721
+ await userEvent.click(
722
+ expectAgentDrivable(canvas, {
723
+ name: "Search languages",
724
+ role: "button",
725
+ }),
726
+ )
727
+
728
+ const input = body.getByRole("combobox")
729
+
730
+ await waitFor(() => {
731
+ expect(input).toHaveFocus()
732
+ })
733
+
734
+ // The row is the input's *parent*: the input itself is a bare
735
+ // `flex-1` field, and the box that carries the row-size class — the
736
+ // height, the inline padding, the type — is the wrapper around it.
737
+ const searchRow = input.closest("div")
738
+
739
+ const [option] = body.getAllByRole("option")
740
+
741
+ expect(option).toBeDefined()
742
+
743
+ expect(searchRow?.getBoundingClientRect().height).toBe(
744
+ option?.getBoundingClientRect().height,
745
+ )
746
+
747
+ // And the query reads at the size of the options it is filtering,
748
+ // which is inherited from that wrapper rather than set on the input.
749
+ expect(globalThis.getComputedStyle(input).fontSize).toBe(
750
+ globalThis.getComputedStyle(option as HTMLElement)
751
+ .fontSize,
752
+ )
753
+ })
@@ -1,3 +1,4 @@
1
+ import type { ControlSize } from "@charcuterie/tokens"
1
2
  import {
2
3
  FloatingFocusManager,
3
4
  FloatingPortal,
@@ -11,9 +12,11 @@ import type {
11
12
  } from "react"
12
13
  import { useEffect, useRef, useState } from "react"
13
14
 
15
+ import { PANEL_ITEM_SIZE_CLASS } from "../controlStyles.ts"
14
16
  import type { ListboxItem } from "../Listbox/Listbox.tsx"
15
17
  import { PANEL_SURFACE_CLASS } from "../Overlay/overlayPanelClass.ts"
16
18
  import { useAnchoredOverlay } from "../Overlay/useAnchoredOverlay.ts"
19
+ import { usePanelItemSize } from "../Overlay/usePanelItemSize.ts"
17
20
  import { toClassName } from "../toClassName.ts"
18
21
  import { ComboboxOption } from "./ComboboxOption.tsx"
19
22
 
@@ -48,6 +51,18 @@ export type ComboboxProps = {
48
51
  /** Windowing. Auto-on above ~100 options when left undefined. */
49
52
  isVirtualized?: boolean
50
53
  isVisible: boolean
54
+ /**
55
+ * How tall each option — and the search field above them — is, from
56
+ * the same density-aware tokens a `Button` reads.
57
+ *
58
+ * It covers the search row deliberately. The owner's report was that
59
+ * the panel's own field was visibly bigger than the options under it
60
+ * (*"the button is huge, but the options don't match it"*), and two
61
+ * sizes inside one panel is the defect. One prop drives both.
62
+ *
63
+ * A short window steps it back down on its own (`usePanelItemSize`).
64
+ */
65
+ itemSize?: ControlSize
51
66
  onDismiss: () => void
52
67
  onSelect: (value: string) => void
53
68
  /**
@@ -71,6 +86,20 @@ export type ComboboxProps = {
71
86
 
72
87
  const AUTO_VIRTUALIZE_THRESHOLD = 100
73
88
 
89
+ /**
90
+ * The virtualizer's pre-measurement guess per row size, in px at
91
+ * `comfortable` density — `--control-height-*` plus the row's own
92
+ * `py-*`. Hardcoded because a virtualizer wants a number before the
93
+ * first paint, which is the one place a token cannot reach; every row
94
+ * is measured immediately afterwards, so a density that disagrees
95
+ * costs a frame rather than a wrong layout.
96
+ */
97
+ const ROW_ESTIMATE_PX: Record<ControlSize, number> = {
98
+ sm: 40,
99
+ md: 48,
100
+ lg: 60,
101
+ }
102
+
74
103
  /**
75
104
  * How many frames the open seed will wait for the panel to be capped
76
105
  * before it gives up on centring the chosen row. A few is all
@@ -130,6 +159,7 @@ export const Combobox = ({
130
159
  isMultiple = false,
131
160
  isVirtualized,
132
161
  isVisible,
162
+ itemSize: requestedItemSize = "md",
133
163
  onDismiss,
134
164
  onQueryChange,
135
165
  onSelect,
@@ -139,6 +169,8 @@ export const Combobox = ({
139
169
  selectedValue,
140
170
  trigger,
141
171
  }: ComboboxProps): ReactNode => {
172
+ const itemSize = usePanelItemSize(requestedItemSize)
173
+
142
174
  // Attached-input mode: the consumer's `<input>` is the reference,
143
175
  // query source, focus holder and keyboard target; Combobox renders the
144
176
  // list-only panel.
@@ -254,7 +286,12 @@ export const Combobox = ({
254
286
 
255
287
  const rowVirtualizer = useVirtualizer({
256
288
  count: filtered.length,
257
- estimateSize: () => 36,
289
+ // An *estimate*: every row is re-measured (`measureElement` on the
290
+ // wrapper below), so this only decides how far the scrollbar lies
291
+ // before the first measurement lands. It still has to track
292
+ // `itemSize`, or a `lg` list opens with a scrollbar a third too
293
+ // short and visibly settles.
294
+ estimateSize: () => ROW_ESTIMATE_PX[itemSize],
258
295
  getScrollElement: () => listElement.current,
259
296
  overscan: 8,
260
297
  })
@@ -735,6 +772,7 @@ export const Combobox = ({
735
772
  isActive={index === resolvedActiveIndex}
736
773
  isSelected={selected.includes(option.value)}
737
774
  item={option}
775
+ itemSize={itemSize}
738
776
  key={option.value}
739
777
  onSelect={commitValue}
740
778
  posInSet={isWindowed ? index + 1 : undefined}
@@ -806,29 +844,43 @@ export const Combobox = ({
806
844
  style={floatingStyles}
807
845
  >
808
846
  {isAttached ? null : (
809
- // px-3 aligns the input's text start with the options
810
- // below: each option sits at 0.75rem (listbox p-1 + option
811
- // px-2), so the search row needs the same 0.75rem inset.
812
- <div className="flex items-center gap-1 border-border-subtle border-b px-3 py-2">
813
- <input
814
- aria-activedescendant={
815
- activeDescendantId
816
- }
817
- aria-autocomplete="list"
818
- aria-controls={listboxId}
819
- aria-expanded="true"
820
- aria-labelledby={triggerId}
821
- className="min-w-24 flex-1 bg-transparent text-content-primary text-sm outline-none placeholder:text-content-muted"
822
- onChange={(changeEvent) => {
823
- setQuery(changeEvent.target.value)
824
- }}
825
- onKeyDown={handleKeyDown}
826
- placeholder={placeholder}
827
- ref={internalInputElement}
828
- role="combobox"
829
- type="text"
830
- value={currentQuery}
831
- />
847
+ // The `p-1` is the list container's own padding, repeated
848
+ // here so the search caret starts exactly where the option
849
+ // labels below it do — the alignment a hand-picked `px-3`
850
+ // used to approximate, and which broke the moment the row
851
+ // padding became a token. The border stays on the outer
852
+ // element, so it still spans the whole panel.
853
+ <div className="border-border-subtle border-b p-1">
854
+ <div
855
+ className={toClassName(
856
+ "flex items-center",
857
+ PANEL_ITEM_SIZE_CLASS[itemSize],
858
+ )}
859
+ >
860
+ <input
861
+ aria-activedescendant={
862
+ activeDescendantId
863
+ }
864
+ aria-autocomplete="list"
865
+ aria-controls={listboxId}
866
+ aria-expanded="true"
867
+ aria-labelledby={triggerId}
868
+ // No `text-*` of its own: the wrapper's row-size
869
+ // class sets the font size, and the input inherits
870
+ // it — so the query reads at exactly the size of
871
+ // the options it is filtering.
872
+ className="min-w-24 flex-1 bg-transparent text-content-primary outline-none placeholder:text-content-muted"
873
+ onChange={(changeEvent) => {
874
+ setQuery(changeEvent.target.value)
875
+ }}
876
+ onKeyDown={handleKeyDown}
877
+ placeholder={placeholder}
878
+ ref={internalInputElement}
879
+ role="combobox"
880
+ type="text"
881
+ value={currentQuery}
882
+ />
883
+ </div>
832
884
  </div>
833
885
  )}
834
886
 
@@ -1,5 +1,7 @@
1
+ import type { ControlSize } from "@charcuterie/tokens"
1
2
  import type { ReactNode } from "react"
2
3
 
4
+ import { PANEL_ITEM_SIZE_CLASS } from "../controlStyles.ts"
3
5
  import type { ListboxItem } from "../Listbox/Listbox.tsx"
4
6
  import { toClassName } from "../toClassName.ts"
5
7
 
@@ -8,6 +10,8 @@ export type ComboboxOptionProps = {
8
10
  id: string
9
11
  isActive: boolean
10
12
  isSelected: boolean
13
+ /** Already stepped down for a short window by `usePanelItemSize`. */
14
+ itemSize: ControlSize
11
15
  item: ListboxItem
12
16
  onSelect: (value: string) => void
13
17
  /**
@@ -41,6 +45,7 @@ export const ComboboxOption = ({
41
45
  isActive,
42
46
  isSelected,
43
47
  item,
48
+ itemSize,
44
49
  onSelect,
45
50
  posInSet,
46
51
  setSize,
@@ -60,7 +65,12 @@ export const ComboboxOption = ({
60
65
  // fill and the keyboard cursor was invisible (it read as "arrows
61
66
  // do nothing"). A button's background is transparent by default;
62
67
  // the tint classes only ever add one.
63
- "flex w-full cursor-pointer items-center justify-between gap-2 rounded-sm px-2 py-1.5 text-start text-sm",
68
+ "flex w-full cursor-pointer items-center justify-between rounded-sm text-start",
69
+ // Height, inline padding, gap and type from the row-size system,
70
+ // so an option matches the search field above it and the trigger
71
+ // that opened it, rather than being visibly the smaller thing in
72
+ // its own panel.
73
+ PANEL_ITEM_SIZE_CLASS[itemSize],
64
74
  // The base sets no text colour: a plain `text-content-primary`
65
75
  // clobbers a conditional `text-content-disabled` at equal
66
76
  // specificity (Tailwind emits the base last), so a disabled option
@@ -66,3 +66,27 @@ is no `heading` prop; the button names the widget. The panel height is clamped t
66
66
  viewport by floating-ui's `size` middleware and scrolls within it.
67
67
 
68
68
  <Canvas of={ListboxStories.Interactive} />
69
+
70
+ ## An option is the size of the control that opened it
71
+
72
+ `itemSize` takes the same `sm` / `md` / `lg` a `Button` takes, from the same
73
+ density-aware `--control-height-*` tokens. It **defaults to `md`** — 2.25rem, the height a
74
+ default `Button` and a `Select` already take — so a picker's options measure the same as
75
+ its trigger.
76
+
77
+ That default is a fix, not a preference. The rows were a hardcoded `px-2 py-1.5 text-sm`
78
+ that no control in the library shares, so an option was visibly the smaller thing inside
79
+ its own panel; the owner's report was *"the button is huge, but the options don't match
80
+ it"*.
81
+
82
+ `lg` is the fat one: 2.75rem, the 44px WCAG 2.5.5 target, for a list that is aimed at
83
+ rather than scanned. `sm` is what this component rendered before, kept for a genuinely
84
+ dense list.
85
+
86
+ The floor is a **minimum**, not a fixed height (`min-h-`, where a `Button` uses `h-`) — the
87
+ rich option below, with a flag and a trailing `Badge`, still grows past it.
88
+
89
+ A window shorter than 40rem steps each size down one step on its own, and one shorter than
90
+ 30rem takes them to `sm`.
91
+
92
+ <Canvas of={ListboxStories.ItemSizes} />
@@ -1,12 +1,20 @@
1
1
  import { useVisibility } from "@charcuterie/logic"
2
+ import type { ControlSize } from "@charcuterie/tokens"
2
3
  import type { Meta, StoryObj } from "@storybook/react"
3
4
  import type { ReactNode } from "react"
4
5
  import { useState } from "react"
5
6
 
6
- import { placementArgType } from "../argTypes.storyHelpers.ts"
7
+ import {
8
+ controlSizeArgType,
9
+ placementArgType,
10
+ } from "../argTypes.storyHelpers.ts"
7
11
  import { Badge } from "../Badge/Badge.tsx"
8
12
  import { Button } from "../Button/Button.tsx"
9
- import { StoryRow } from "../board.storyHelpers.tsx"
13
+ import {
14
+ StoryCell,
15
+ StoryGrid,
16
+ StoryRow,
17
+ } from "../board.storyHelpers.tsx"
10
18
  import type { ListboxItem } from "./Listbox.tsx"
11
19
  import { Listbox } from "./Listbox.tsx"
12
20
 
@@ -73,8 +81,11 @@ const meta = {
73
81
  title: "Components/Controls/Listbox",
74
82
  component: Listbox,
75
83
  parameters: { layout: "padded" },
76
- argTypes: { placement: placementArgType },
77
- args: { placement: "bottom-start" },
84
+ argTypes: {
85
+ itemSize: controlSizeArgType,
86
+ placement: placementArgType,
87
+ },
88
+ args: { itemSize: "md", placement: "bottom-start" },
78
89
  } satisfies Meta<typeof Listbox>
79
90
 
80
91
  export default meta
@@ -83,14 +94,20 @@ type Story = StoryObj<typeof meta>
83
94
 
84
95
  const ListboxHarness = ({
85
96
  initialSelected,
97
+ isInitiallyVisible = false,
98
+ itemSize,
86
99
  options,
87
100
  triggerLabel,
88
101
  }: {
89
102
  initialSelected?: string
103
+ isInitiallyVisible?: boolean
104
+ itemSize?: ControlSize
90
105
  options: ListboxItem[]
91
106
  triggerLabel: string
92
107
  }): ReactNode => {
93
- const { hide, isVisible, toggle } = useVisibility()
108
+ const { hide, isVisible, toggle } = useVisibility({
109
+ isVisible: isInitiallyVisible,
110
+ })
94
111
 
95
112
  const [selected, setSelected] = useState<
96
113
  string | undefined
@@ -111,6 +128,7 @@ const ListboxHarness = ({
111
128
  return (
112
129
  <Listbox
113
130
  isVisible={isVisible}
131
+ itemSize={itemSize}
114
132
  onDismiss={hide}
115
133
  onSelect={setSelected}
116
134
  options={options}
@@ -203,3 +221,61 @@ export const Interactive: Story = {
203
221
  />
204
222
  ),
205
223
  }
224
+
225
+ /**
226
+ * The three option sizes, open side by side.
227
+ *
228
+ * `md` is the default, and the reason is the report that started this
229
+ * work: an option that is smaller than the control which opened it
230
+ * reads as a different, lesser thing inside its own panel. `md` is
231
+ * `--control-height-md` — the same 2.25rem a default `Button` and a
232
+ * `Select` take — so a picker's options now measure the same as its
233
+ * trigger.
234
+ *
235
+ * `lg` is the fat one: 2.75rem, the 44px target, for a list that is
236
+ * aimed at rather than scanned. `sm` is what this component rendered
237
+ * before, kept for a genuinely dense list.
238
+ *
239
+ * The floor is a **minimum**, not a fixed height — the rich option
240
+ * below (flag, name, badge) still grows past it, which is why this is
241
+ * `min-h-` and a `Button`'s is `h-`.
242
+ */
243
+ export const ItemSizes: Story = {
244
+ args: {
245
+ isVisible: false,
246
+ onDismiss: () => {},
247
+ onSelect: () => {},
248
+ options: LANGUAGES,
249
+ trigger: <Button>Language</Button>,
250
+ },
251
+ render: () => (
252
+ <StoryGrid columns={3}>
253
+ <StoryCell label="sm — the old option">
254
+ <ListboxHarness
255
+ isInitiallyVisible
256
+ itemSize="sm"
257
+ options={LANGUAGES}
258
+ triggerLabel="Language"
259
+ />
260
+ </StoryCell>
261
+
262
+ <StoryCell label="md — the default, matches the trigger">
263
+ <ListboxHarness
264
+ isInitiallyVisible
265
+ itemSize="md"
266
+ options={LANGUAGES}
267
+ triggerLabel="Language"
268
+ />
269
+ </StoryCell>
270
+
271
+ <StoryCell label="lg — the fat option (44px)">
272
+ <ListboxHarness
273
+ isInitiallyVisible
274
+ itemSize="lg"
275
+ options={LANGUAGES}
276
+ triggerLabel="Language"
277
+ />
278
+ </StoryCell>
279
+ </StoryGrid>
280
+ ),
281
+ }
@@ -3,6 +3,7 @@ import {
3
3
  useRovingFocus,
4
4
  useSinglePicker,
5
5
  } from "@charcuterie/logic"
6
+ import type { ControlSize } from "@charcuterie/tokens"
6
7
  import type { Placement } from "@floating-ui/react"
7
8
  import {
8
9
  FloatingFocusManager,
@@ -13,6 +14,7 @@ import { useEffect, useRef } from "react"
13
14
 
14
15
  import { PANEL_SURFACE_CLASS } from "../Overlay/overlayPanelClass.ts"
15
16
  import { useAnchoredOverlay } from "../Overlay/useAnchoredOverlay.ts"
17
+ import { usePanelItemSize } from "../Overlay/usePanelItemSize.ts"
16
18
  import { toClassName } from "../toClassName.ts"
17
19
  import { ListboxOption } from "./ListboxOption.tsx"
18
20
 
@@ -31,6 +33,17 @@ export type ListboxItem = {
31
33
  export type ListboxProps = {
32
34
  className?: string
33
35
  isVisible: boolean
36
+ /**
37
+ * How tall each option is, from the same density-aware tokens a
38
+ * `Button` reads — so a `md` option is exactly as tall as the `md`
39
+ * trigger that opened it. `lg` is the fat one: 2.75rem at
40
+ * `comfortable` density, and a bigger target to aim at.
41
+ *
42
+ * A short window steps it back down on its own
43
+ * (`usePanelItemSize`), so a `lg` list is never the reason the last
44
+ * option is off-screen.
45
+ */
46
+ itemSize?: ControlSize
34
47
  /** Outside press and Escape both land here. */
35
48
  onDismiss: () => void
36
49
  onSelect: (value: string) => void
@@ -79,6 +92,7 @@ const getOptionText = (item: ListboxItem) =>
79
92
  export const Listbox = ({
80
93
  className,
81
94
  isVisible,
95
+ itemSize: requestedItemSize = "md",
82
96
  onDismiss,
83
97
  onSelect,
84
98
  options,
@@ -86,6 +100,8 @@ export const Listbox = ({
86
100
  selectedValue,
87
101
  trigger,
88
102
  }: ListboxProps): ReactNode => {
103
+ const itemSize = usePanelItemSize(requestedItemSize)
104
+
89
105
  const optionElements = useRef(
90
106
  new Map<string, HTMLButtonElement>(),
91
107
  )
@@ -266,6 +282,7 @@ export const Listbox = ({
266
282
  picker.selectedValue === option.value
267
283
  }
268
284
  item={option}
285
+ itemSize={itemSize}
269
286
  key={option.value}
270
287
  onSelect={chooseValue}
271
288
  registerFocus={focus.register}