@charcuterie/ui 3.17.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 (81) 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 +124 -56
  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/MarkdownView.d.ts +24 -1
  41. package/dist/markdownEditorCodeMirror/MarkdownView.d.ts.map +1 -1
  42. package/dist/markdownEditorCodeMirror/MarkdownView.js +14 -2
  43. package/dist/markdownEditorCodeMirror/MarkdownView.js.map +1 -1
  44. package/dist/markdownEditorCodeMirror/livePreview.d.ts +7 -0
  45. package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -1
  46. package/dist/markdownEditorCodeMirror/livePreview.js +28 -2
  47. package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -1
  48. package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts +22 -2
  49. package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts.map +1 -1
  50. package/dist/markdownEditorCodeMirror/livePreviewRanges.js +26 -7
  51. package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -1
  52. package/dist/viewport.testHelpers.d.ts +18 -4
  53. package/dist/viewport.testHelpers.d.ts.map +1 -1
  54. package/dist/viewport.testHelpers.js +18 -6
  55. package/dist/viewport.testHelpers.js.map +1 -1
  56. package/package.json +2 -2
  57. package/src/Combobox/Combobox.mdx +26 -0
  58. package/src/Combobox/Combobox.stories.tsx +65 -0
  59. package/src/Combobox/Combobox.test.tsx +135 -0
  60. package/src/Combobox/Combobox.tsx +176 -83
  61. package/src/Combobox/ComboboxOption.tsx +11 -1
  62. package/src/Listbox/Listbox.mdx +24 -0
  63. package/src/Listbox/Listbox.stories.tsx +81 -5
  64. package/src/Listbox/Listbox.tsx +17 -0
  65. package/src/Listbox/ListboxOption.tsx +10 -1
  66. package/src/Menu/Menu.mdx +49 -0
  67. package/src/Menu/Menu.stories.tsx +113 -2
  68. package/src/Menu/Menu.test.tsx +129 -0
  69. package/src/Menu/Menu.tsx +62 -3
  70. package/src/Menu/MenuAction.tsx +29 -4
  71. package/src/Overlay/useAnchoredOverlay.ts +33 -5
  72. package/src/Overlay/usePanelItemSize.ts +71 -0
  73. package/src/controlStyles.ts +38 -0
  74. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.test.tsx +185 -0
  75. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.tsx +42 -1
  76. package/src/markdownEditorCodeMirror/MarkdownView.tsx +40 -1
  77. package/src/markdownEditorCodeMirror/livePreview.ts +40 -3
  78. package/src/markdownEditorCodeMirror/livePreviewRanges.test.ts +137 -0
  79. package/src/markdownEditorCodeMirror/livePreviewRanges.ts +50 -6
  80. package/src/sourceRules.test.ts +9 -0
  81. package/src/viewport.testHelpers.ts +21 -6
@@ -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
  })
@@ -286,32 +323,40 @@ export const Combobox = ({
286
323
 
287
324
  const hasSeededOnOpen = useRef(false)
288
325
 
289
- // The seed sets the index and the in-view effect further down scrolls
290
- // to it, one commit later — the virtualizer measures its scroll
291
- // element in an effect of its own, so a `scrollToIndex` issued any
292
- // earlier has nothing to scroll and is dropped. That effect must not
293
- // scroll the *stale* highlight in the meantime, which would undo the
294
- // centring, so it is told which index to wait for and skips every pass
295
- // short of it.
296
- const pendingSeedIndex = useRef<null | number>(null)
297
-
298
- // Retrying the seed's centring scroll. `useAnchoredOverlay` caps the
299
- // panel's height by writing `style.maxHeight` straight onto the
300
- // floating element inside floating-ui's `size` middleware —
301
- // deliberately outside the `floatingStyles` React manages, so a
302
- // keystroke cannot wipe it. The cost lands here: it arrives a frame
303
- // after the open seed, announces itself with no re-render, and until
304
- // it does the list stands at its full content height with **nothing to
305
- // scroll**. A seed scroll issued then is dropped without a trace,
306
- // which is how the first build of this fix highlighted the chosen row
307
- // and still left it off screen in a real app.
308
- const [seedScrollAttempt, setSeedScrollAttempt] =
309
- useState(0)
326
+ /**
327
+ * The open seed's outstanding scroll — the row to centre, and how many
328
+ * frames it has waited for a panel it can move.
329
+ *
330
+ * **State, and a fresh object on every open.** The obvious build hangs
331
+ * the scroll off `activeIndex` changing, and that is a bug the second
332
+ * time the same picker opens: `activeIndex` still holds the index the
333
+ * *last* open seeded, so setting it again is a no-op, React re-renders
334
+ * nothing, and the effect that would have scrolled never runs. The
335
+ * first open worked and every reopen showed the top of the list.
336
+ *
337
+ * A new object per open cannot be a no-op, so the scroll no longer
338
+ * depends on the highlight having moved.
339
+ *
340
+ * The wait exists because `useAnchoredOverlay` caps the panel's height
341
+ * by writing `style.maxHeight` straight onto the floating element
342
+ * inside floating-ui's `size` middleware — deliberately outside the
343
+ * `floatingStyles` React manages, so a keystroke cannot wipe it. It
344
+ * lands a frame after the seed and re-renders nothing to announce
345
+ * itself, and until it does the list stands at its full content height
346
+ * with **nothing to scroll**, so a scroll issued then is dropped
347
+ * without a trace.
348
+ */
349
+ const [seedScroll, setSeedScroll] = useState<null | {
350
+ attempt: number
351
+ index: number
352
+ value: string
353
+ }>(null)
310
354
 
311
355
  useEffect(() => {
312
356
  if (!isVisible) {
313
357
  hasSeededOnOpen.current = false
314
- pendingSeedIndex.current = null
358
+
359
+ setSeedScroll(null)
315
360
 
316
361
  return
317
362
  }
@@ -337,9 +382,15 @@ export const Combobox = ({
337
382
 
338
383
  hasSeededOnOpen.current = true
339
384
 
340
- pendingSeedIndex.current = openSeedIndex
341
-
342
- setSeedScrollAttempt(0)
385
+ setSeedScroll({
386
+ attempt: 0,
387
+ index: openSeedIndex,
388
+ // Carried rather than looked up in the effect. `filtered` is a new
389
+ // array on every render, and as a dependency it would cancel and
390
+ // reschedule the pending frame each time — a wait that can never
391
+ // end while anything else re-renders.
392
+ value: seedValue,
393
+ })
343
394
 
344
395
  setActiveIndex(openSeedIndex)
345
396
  }, [
@@ -540,38 +591,41 @@ export const Combobox = ({
540
591
  }
541
592
  }
542
593
 
543
- // Keep the active row in view — a real bug in every fleet picker:
544
- // arrowing past the panel edge did not scroll.
594
+ // The open seed's own scroll, centred, and the only one allowed to run
595
+ // until it lands — an arrow move's `block: "nearest"` would drag the
596
+ // list straight back off the chosen row.
545
597
  useEffect(() => {
546
- if (!isVisible) {
598
+ if (!isVisible || seedScroll === null) {
547
599
  return
548
600
  }
549
601
 
550
- const isSeeding = pendingSeedIndex.current !== null
602
+ const list = listElement.current
551
603
 
552
- if (
553
- isSeeding &&
554
- pendingSeedIndex.current !== resolvedActiveIndex
555
- ) {
556
- // The seeded index has not reached this render yet. Scrolling the
557
- // stale highlight now would drag the list off the chosen row.
604
+ if (list === null) {
558
605
  return
559
606
  }
560
607
 
561
- const list = listElement.current
562
-
563
608
  if (
564
- isSeeding &&
565
- seedScrollAttempt < SEED_SCROLL_ATTEMPTS &&
566
- list !== null &&
567
- list.scrollHeight <= list.clientHeight
609
+ seedScroll.attempt < SEED_SCROLL_ATTEMPTS &&
610
+ (seedScroll.attempt === 0 ||
611
+ list.scrollHeight <= list.clientHeight)
568
612
  ) {
569
- // Nothing to scroll yet — the panel is still at its full height.
570
- // Hold the seed and look again next frame rather than scroll a
571
- // container that cannot move. Bounded, so a list that genuinely
572
- // fits its panel stops instead of spinning.
613
+ // Never scroll on the opening commit. Two separate things are
614
+ // still missing then, and each drops the scroll silently: the
615
+ // panel has no height cap yet, so the list stands at full height
616
+ // with nothing to move; and the virtualizer measures its scroll
617
+ // element in an effect of its own, so `scrollToIndex` has nothing
618
+ // to measure against. One frame settles both.
619
+ //
620
+ // After that, keep waiting only while the list cannot move.
621
+ // Bounded, so a list that genuinely fits its panel stops instead
622
+ // of spinning.
573
623
  const frame = requestAnimationFrame(() => {
574
- setSeedScrollAttempt((attempt) => attempt + 1)
624
+ setSeedScroll({
625
+ attempt: seedScroll.attempt + 1,
626
+ index: seedScroll.index,
627
+ value: seedScroll.value,
628
+ })
575
629
  })
576
630
 
577
631
  return () => {
@@ -579,17 +633,43 @@ export const Combobox = ({
579
633
  }
580
634
  }
581
635
 
582
- pendingSeedIndex.current = null
636
+ // Centred, where an arrow move only pulls the next row just into
637
+ // view. "nearest" on open would park the chosen row against the
638
+ // panel's bottom edge with nothing after it, so the neighbours the
639
+ // list was reopened to reach would still be off screen.
640
+ if (isWindowed) {
641
+ rowVirtualizer.scrollToIndex(seedScroll.index, {
642
+ align: "center",
643
+ })
644
+ } else {
645
+ list
646
+ .querySelector(
647
+ `#${CSS.escape(`${listboxId}-opt-${seedScroll.value}`)}`,
648
+ )
649
+ ?.scrollIntoView({ block: "center" })
650
+ }
651
+
652
+ setSeedScroll(null)
653
+ }, [
654
+ seedScroll,
655
+ isVisible,
656
+ isWindowed,
657
+ listboxId,
658
+ rowVirtualizer.scrollToIndex,
659
+ ])
660
+
661
+ // Keep the active row in view — a real bug in every fleet picker:
662
+ // arrowing past the panel edge did not scroll.
663
+ useEffect(() => {
664
+ // While the seed is outstanding the row it wants is not on screen
665
+ // yet, and pulling the highlight into view with `nearest` would
666
+ // settle the list somewhere the seed then has to undo.
667
+ if (!isVisible || seedScroll !== null) {
668
+ return
669
+ }
583
670
 
584
- // The open seed centres its row; an arrow move only pulls the next
585
- // one just into view. "nearest" on open would park the chosen row
586
- // against the panel's bottom edge with nothing after it, so the
587
- // neighbours the list was reopened to reach stay off screen.
588
671
  if (isWindowed) {
589
- rowVirtualizer.scrollToIndex(
590
- resolvedActiveIndex,
591
- isSeeding ? { align: "center" } : undefined,
592
- )
672
+ rowVirtualizer.scrollToIndex(resolvedActiveIndex)
593
673
 
594
674
  return
595
675
  }
@@ -599,9 +679,7 @@ export const Combobox = ({
599
679
  ?.querySelector(
600
680
  `#${CSS.escape(`${listboxId}-opt-${activeValue}`)}`,
601
681
  )
602
- ?.scrollIntoView({
603
- block: isSeeding ? "center" : "nearest",
604
- })
682
+ ?.scrollIntoView({ block: "nearest" })
605
683
  }
606
684
  }, [
607
685
  resolvedActiveIndex,
@@ -610,7 +688,7 @@ export const Combobox = ({
610
688
  isWindowed,
611
689
  listboxId,
612
690
  rowVirtualizer.scrollToIndex,
613
- seedScrollAttempt,
691
+ seedScroll,
614
692
  ])
615
693
 
616
694
  // ─── Attached mode: drive the consumer's input ──────────────────────
@@ -694,6 +772,7 @@ export const Combobox = ({
694
772
  isActive={index === resolvedActiveIndex}
695
773
  isSelected={selected.includes(option.value)}
696
774
  item={option}
775
+ itemSize={itemSize}
697
776
  key={option.value}
698
777
  onSelect={commitValue}
699
778
  posInSet={isWindowed ? index + 1 : undefined}
@@ -765,29 +844,43 @@ export const Combobox = ({
765
844
  style={floatingStyles}
766
845
  >
767
846
  {isAttached ? null : (
768
- // px-3 aligns the input's text start with the options
769
- // below: each option sits at 0.75rem (listbox p-1 + option
770
- // px-2), so the search row needs the same 0.75rem inset.
771
- <div className="flex items-center gap-1 border-border-subtle border-b px-3 py-2">
772
- <input
773
- aria-activedescendant={
774
- activeDescendantId
775
- }
776
- aria-autocomplete="list"
777
- aria-controls={listboxId}
778
- aria-expanded="true"
779
- aria-labelledby={triggerId}
780
- className="min-w-24 flex-1 bg-transparent text-content-primary text-sm outline-none placeholder:text-content-muted"
781
- onChange={(changeEvent) => {
782
- setQuery(changeEvent.target.value)
783
- }}
784
- onKeyDown={handleKeyDown}
785
- placeholder={placeholder}
786
- ref={internalInputElement}
787
- role="combobox"
788
- type="text"
789
- value={currentQuery}
790
- />
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>
791
884
  </div>
792
885
  )}
793
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}
@@ -1,12 +1,16 @@
1
+ import type { ControlSize } from "@charcuterie/tokens"
1
2
  import type { ReactNode } from "react"
2
3
  import { useEffect } from "react"
3
4
 
5
+ import { PANEL_ITEM_SIZE_CLASS } from "../controlStyles.ts"
4
6
  import { FOCUS_RING_CLASS } from "../intentStyles.ts"
5
7
  import { toClassName } from "../toClassName.ts"
6
8
  import type { ListboxItem } from "./Listbox.tsx"
7
9
 
8
10
  export type ListboxOptionProps = {
9
11
  isSelected: boolean
12
+ /** Already stepped down for a short window by `usePanelItemSize`. */
13
+ itemSize: ControlSize
10
14
  item: ListboxItem
11
15
  onSelect: (value: string) => void
12
16
  /** `RovingFocus.register` — membership of the arrow-key group. */
@@ -44,6 +48,7 @@ export type ListboxOptionProps = {
44
48
  export const ListboxOption = ({
45
49
  isSelected,
46
50
  item,
51
+ itemSize,
47
52
  onSelect,
48
53
  registerFocus,
49
54
  registerSelection,
@@ -73,7 +78,11 @@ export const ListboxOption = ({
73
78
  // and silently won at equal specificity, so the selected option's
74
79
  // accent fill never showed (only its ✓ did). Default is
75
80
  // transparent anyway; the tints below add a fill.
76
- "flex w-full cursor-pointer items-center justify-between gap-2 rounded-sm px-2 py-1.5 text-start text-sm transition-colors duration-(--duration-fast) ease-standard",
81
+ "flex w-full cursor-pointer items-center justify-between rounded-sm text-start transition-colors duration-(--duration-fast) ease-standard",
82
+ // Height, inline padding, gap and type from the row-size system,
83
+ // so an option is at least as tall as the control that opened it
84
+ // and a rich two-line label still grows past that floor.
85
+ PANEL_ITEM_SIZE_CLASS[itemSize],
77
86
  // The base sets no text colour: a plain `text-content-primary` in
78
87
  // it clobbers a conditional `text-content-disabled` at equal
79
88
  // specificity (Tailwind emits the base last), so a disabled option