@charcuterie/ui 3.18.0 → 3.19.2

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 +81 -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 +109 -0
  51. package/src/Combobox/Combobox.tsx +140 -25
  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
@@ -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.
@@ -155,6 +187,26 @@ export const Combobox = ({
155
187
 
156
188
  const listElement = useRef<HTMLDivElement>(null)
157
189
 
190
+ /**
191
+ * The same node as `listElement`, held in state as well as a ref.
192
+ *
193
+ * The panel's children mount a render *later* than `isVisible` flips —
194
+ * floating-ui's `FloatingPortal` renders nothing until its portal node
195
+ * exists — so an effect keyed on `isVisible` runs while the ref is
196
+ * still null. Anything that has to touch the list the moment it
197
+ * appears keys on this instead.
198
+ */
199
+ const [listNode, setListNode] =
200
+ useState<HTMLDivElement | null>(null)
201
+
202
+ const attachListElement = (
203
+ node: HTMLDivElement | null,
204
+ ) => {
205
+ listElement.current = node
206
+
207
+ setListNode(node)
208
+ }
209
+
158
210
  const isQueryControlled = query !== undefined
159
211
 
160
212
  const [internalQuery, setInternalQuery] = useState("")
@@ -254,7 +306,12 @@ export const Combobox = ({
254
306
 
255
307
  const rowVirtualizer = useVirtualizer({
256
308
  count: filtered.length,
257
- estimateSize: () => 36,
309
+ // An *estimate*: every row is re-measured (`measureElement` on the
310
+ // wrapper below), so this only decides how far the scrollbar lies
311
+ // before the first measurement lands. It still has to track
312
+ // `itemSize`, or a `lg` list opens with a scrollbar a third too
313
+ // short and visibly settles.
314
+ estimateSize: () => ROW_ESTIMATE_PX[itemSize],
258
315
  getScrollElement: () => listElement.current,
259
316
  overscan: 8,
260
317
  })
@@ -284,6 +341,49 @@ export const Combobox = ({
284
341
  !option.isDisabled,
285
342
  )
286
343
 
344
+ /**
345
+ * The virtualizer outlives the panel, and its scroll offset does not
346
+ * come back with it.
347
+ *
348
+ * `Combobox` stays mounted while the list element is created and
349
+ * destroyed on every open, so `useVirtualizer` keeps the offset from
350
+ * the last time the list was on screen. The freshly mounted element is
351
+ * at 0. The window it renders is then the one for a scroll position
352
+ * that no longer exists: a blank band down the top of the panel, with
353
+ * the rows for the old offset stranded below it.
354
+ *
355
+ * Re-sync once per open, before the seed's own scroll (which lands a
356
+ * frame later, so it still wins where there is a chosen row to centre).
357
+ */
358
+ const hasResyncedVirtualizer = useRef(false)
359
+
360
+ useEffect(() => {
361
+ if (!isVisible) {
362
+ hasResyncedVirtualizer.current = false
363
+
364
+ return
365
+ }
366
+
367
+ if (
368
+ hasResyncedVirtualizer.current ||
369
+ !isWindowed ||
370
+ listNode === null
371
+ ) {
372
+ return
373
+ }
374
+
375
+ hasResyncedVirtualizer.current = true
376
+
377
+ // A `scroll` event, because that is the only thing the virtualizer
378
+ // listens to. `observeOffset` attaches its listener and never reads
379
+ // the element it just attached to, so a replaced element sitting at 0
380
+ // cannot correct an instance that still believes it is at 500 —
381
+ // nothing moves, so nothing fires. `scrollToOffset(0)` does not help
382
+ // for the same reason: it asks for a position the element already
383
+ // holds, which raises no event either.
384
+ listNode.dispatchEvent(new Event("scroll"))
385
+ }, [isVisible, isWindowed, listNode])
386
+
287
387
  const hasSeededOnOpen = useRef(false)
288
388
 
289
389
  /**
@@ -735,6 +835,7 @@ export const Combobox = ({
735
835
  isActive={index === resolvedActiveIndex}
736
836
  isSelected={selected.includes(option.value)}
737
837
  item={option}
838
+ itemSize={itemSize}
738
839
  key={option.value}
739
840
  onSelect={commitValue}
740
841
  posInSet={isWindowed ? index + 1 : undefined}
@@ -806,29 +907,43 @@ export const Combobox = ({
806
907
  style={floatingStyles}
807
908
  >
808
909
  {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
- />
910
+ // The `p-1` is the list container's own padding, repeated
911
+ // here so the search caret starts exactly where the option
912
+ // labels below it do — the alignment a hand-picked `px-3`
913
+ // used to approximate, and which broke the moment the row
914
+ // padding became a token. The border stays on the outer
915
+ // element, so it still spans the whole panel.
916
+ <div className="border-border-subtle border-b p-1">
917
+ <div
918
+ className={toClassName(
919
+ "flex items-center",
920
+ PANEL_ITEM_SIZE_CLASS[itemSize],
921
+ )}
922
+ >
923
+ <input
924
+ aria-activedescendant={
925
+ activeDescendantId
926
+ }
927
+ aria-autocomplete="list"
928
+ aria-controls={listboxId}
929
+ aria-expanded="true"
930
+ aria-labelledby={triggerId}
931
+ // No `text-*` of its own: the wrapper's row-size
932
+ // class sets the font size, and the input inherits
933
+ // it — so the query reads at exactly the size of
934
+ // the options it is filtering.
935
+ className="min-w-24 flex-1 bg-transparent text-content-primary outline-none placeholder:text-content-muted"
936
+ onChange={(changeEvent) => {
937
+ setQuery(changeEvent.target.value)
938
+ }}
939
+ onKeyDown={handleKeyDown}
940
+ placeholder={placeholder}
941
+ ref={internalInputElement}
942
+ role="combobox"
943
+ type="text"
944
+ value={currentQuery}
945
+ />
946
+ </div>
832
947
  </div>
833
948
  )}
834
949
 
@@ -844,7 +959,7 @@ export const Combobox = ({
844
959
  }
845
960
  className="min-h-0 flex-1 overflow-auto p-1"
846
961
  id={listboxId}
847
- ref={listElement}
962
+ ref={attachListElement}
848
963
  role="listbox"
849
964
  >
850
965
  {isLoading ? (
@@ -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
package/src/Menu/Menu.mdx CHANGED
@@ -122,3 +122,52 @@ an anchored panel and an unanchored one land in the same place, and the test ass
122
122
  padding is still there before it asserts anything else.
123
123
 
124
124
  <Canvas of={MenuStories.SharedTrigger} />
125
+
126
+ ## The row is a click target, so it is `lg` by default
127
+
128
+ `itemSize` takes the same `sm` / `md` / `lg` a `Button` takes, from the same
129
+ density-aware `--control-height-*` tokens — so a `md` menu item and a `md` `Button`
130
+ measure the same, and the `kiosk` density grows both together.
131
+
132
+ It **defaults to `lg`**, which nothing else in the library does. The asymmetry is the
133
+ decision rather than an oversight: a menu item is a *pointer* target before it is a list
134
+ row. It is read once and then aimed at, usually in a hurry, and a menu rarely holds more
135
+ than about eight items — so it can spend the height a dense list cannot. At `comfortable`
136
+ density `lg` is 2.75rem, which is also the 44px WCAG 2.5.5 target; on the `kiosk` density
137
+ it is 3.75rem with no prop change.
138
+
139
+ The floor is a **minimum**, not a fixed height (`min-h-`, where a `Button` uses `h-`), so a
140
+ long label that wraps to a second line still grows past it.
141
+
142
+ <Canvas of={MenuStories.ItemSizes} />
143
+
144
+ ## A short window steps the size back down, and the panel scrolls
145
+
146
+ Two separate protections, because they answer different failures.
147
+
148
+ A window shorter than **40rem** drops every panel row one step, and one shorter than
149
+ **30rem** takes them to `sm` — so a `lg` menu is never the reason its own last item is off
150
+ the screen. That is `usePanelItemSize`, a `matchMedia` read rather than a CSS media query:
151
+ `text-sm` → `text-md` is a Tailwind bridge onto `--font-size-*` rather than a variable a
152
+ media query can reassign, so stepping the whole `ControlSize` in JS is what keeps one
153
+ literal class string per size. It costs a re-render on resize, which is not the axis the
154
+ library keeps as a repaint.
155
+
156
+ Independently, the panel is now clamped to the space the viewport actually left it and
157
+ scrolls inside that. A menu had **no** clamp before — survivable at a 32px row, and not at
158
+ 44px, where nine items are 400px and the panel simply ran off the bottom of the screen with
159
+ no way to reach the last one.
160
+
161
+ ## A long label wraps, rather than pushing the panel off screen
162
+
163
+ A portalled panel is `position: fixed`, so its shrink-to-fit width stops at the
164
+ **viewport** — not at the space `shift` left it. A label long enough to reach that cap
165
+ therefore produced a menu exactly as wide as the window, positioned 8px in, with 8px of
166
+ itself off the right edge: clipped and unreachable. Measured at 390px.
167
+
168
+ That was pre-existing and size-independent, but a `lg` row's larger type is what makes a
169
+ real label reach it, so the clamp lands in the same change. `isWidthClamped` caps the panel
170
+ at floating-ui's shifted `availableWidth`, and the label wraps inside its row — which is
171
+ also why a row's height is a `min-h-` rather than an `h-`.
172
+
173
+ <Canvas of={MenuStories.LongLabel} />