@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,8 +1,12 @@
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
 
5
- import { placementArgType } from "../argTypes.storyHelpers.ts"
6
+ import {
7
+ controlSizeArgType,
8
+ placementArgType,
9
+ } from "../argTypes.storyHelpers.ts"
6
10
  import { Button } from "../Button/Button.tsx"
7
11
  import {
8
12
  StoryCell,
@@ -79,11 +83,13 @@ const GROUPED_ACTIONS: MenuEntry[] = [
79
83
  const MenuHarness = ({
80
84
  emptyState,
81
85
  isInitiallyVisible = false,
86
+ itemSize,
82
87
  items = BAY_ACTIONS,
83
88
  triggerLabel,
84
89
  }: {
85
90
  emptyState?: ReactNode
86
91
  isInitiallyVisible?: boolean
92
+ itemSize?: ControlSize
87
93
  items?: MenuEntry[]
88
94
  triggerLabel: string
89
95
  }): ReactNode => {
@@ -95,6 +101,7 @@ const MenuHarness = ({
95
101
  <Menu
96
102
  emptyState={emptyState}
97
103
  isVisible={isVisible}
104
+ itemSize={itemSize}
98
105
  items={items}
99
106
  onDismiss={hide}
100
107
  trigger={
@@ -144,9 +151,13 @@ const meta = {
144
151
  title: "Components/Actions/Menu",
145
152
  component: Menu,
146
153
  parameters: { layout: "centered" },
147
- argTypes: { placement: placementArgType },
154
+ argTypes: {
155
+ itemSize: controlSizeArgType,
156
+ placement: placementArgType,
157
+ },
148
158
  args: {
149
159
  items: BAY_ACTIONS,
160
+ itemSize: "lg",
150
161
  placement: "bottom-start",
151
162
  },
152
163
  } satisfies Meta<typeof Menu>
@@ -304,3 +315,103 @@ export const Empty: Story = {
304
315
  />
305
316
  ),
306
317
  }
318
+
319
+ /**
320
+ * The three row sizes, open side by side.
321
+ *
322
+ * `lg` is the **default** here and nowhere else in the library. The
323
+ * argument is that a menu item is a pointer target before it is a
324
+ * list row: it is read once and then aimed at, usually in a hurry,
325
+ * and a menu rarely holds more than about eight items — so it can
326
+ * spend the height a dense list cannot. At `comfortable` density `lg`
327
+ * is 2.75rem, which is also the 44px WCAG 2.5.5 target, and on the
328
+ * `kiosk` density it is 3.75rem with no prop change.
329
+ *
330
+ * `sm` is what every menu in the fleet rendered before this, so it is
331
+ * the row to compare the other two against rather than a smaller
332
+ * option nobody wants.
333
+ *
334
+ * A window shorter than 40rem steps each of these down one step on
335
+ * its own, and one shorter than 30rem takes them to `sm` — so a `lg`
336
+ * menu is never the reason its own last item is off the screen. Drag
337
+ * the preview's bottom edge up to watch it happen.
338
+ */
339
+ export const ItemSizes: Story = {
340
+ args: {
341
+ isVisible: false,
342
+ onDismiss: noop,
343
+ trigger: <Button appearance="outline">Actions</Button>,
344
+ },
345
+ parameters: { layout: "padded" },
346
+ render: () => (
347
+ <StoryGrid columns={3}>
348
+ <StoryCell label="sm — the old row">
349
+ <MenuHarness
350
+ isInitiallyVisible
351
+ itemSize="sm"
352
+ triggerLabel="Bay 1"
353
+ />
354
+ </StoryCell>
355
+
356
+ <StoryCell label="md — matches a default Button">
357
+ <MenuHarness
358
+ isInitiallyVisible
359
+ itemSize="md"
360
+ triggerLabel="Bay 2"
361
+ />
362
+ </StoryCell>
363
+
364
+ <StoryCell label="lg — the default (44px)">
365
+ <MenuHarness
366
+ isInitiallyVisible
367
+ itemSize="lg"
368
+ triggerLabel="Bay 3"
369
+ />
370
+ </StoryCell>
371
+ </StoryGrid>
372
+ ),
373
+ }
374
+
375
+ /**
376
+ * A label longer than the panel, at the width it matters.
377
+ *
378
+ * A portalled panel is `position: fixed`, so its shrink-to-fit width
379
+ * stops at the **viewport** rather than at the space `shift` left it
380
+ * — which produced a menu exactly as wide as a 390px window,
381
+ * positioned 8px in, with 8px of itself off the right edge. It was
382
+ * pre-existing and size-independent; a `lg` row's larger type is what
383
+ * makes a real label reach it.
384
+ *
385
+ * The panel is clamped to the shifted `availableWidth` now, and the
386
+ * label wraps inside its row — which is also why a row's height is a
387
+ * `min-h-` rather than an `h-`.
388
+ */
389
+ export const LongLabel: Story = {
390
+ args: {
391
+ isVisible: false,
392
+ onDismiss: noop,
393
+ trigger: <Button appearance="outline">Actions</Button>,
394
+ },
395
+ globals: { viewport: { value: "mobile1" } },
396
+ parameters: { layout: "padded" },
397
+ render: () => (
398
+ <MenuHarness
399
+ isInitiallyVisible
400
+ items={[
401
+ {
402
+ icon: <SettingsIcon />,
403
+ key: "forget",
404
+ label:
405
+ "Forget every remembered passcode on this device",
406
+ onSelect: noop,
407
+ },
408
+ {
409
+ key: "skip",
410
+ label: "Skip title",
411
+ onSelect: noop,
412
+ },
413
+ ]}
414
+ triggerLabel="Bay 8"
415
+ />
416
+ ),
417
+ }
@@ -5,6 +5,12 @@ import { test } from "vitest"
5
5
  import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
6
6
  import { mountStory } from "../mountStory.testHelpers.ts"
7
7
  import { expectAgentDrivable } from "../testing/index.ts"
8
+ import {
9
+ DESKTOP,
10
+ PHONE,
11
+ SHORT_WINDOW,
12
+ setViewport,
13
+ } from "../viewport.testHelpers.ts"
8
14
  import * as stories from "./Menu.stories.tsx"
9
15
 
10
16
  const {
@@ -13,6 +19,7 @@ const {
13
19
  Empty,
14
20
  Grouped,
15
21
  Interactive,
22
+ LongLabel,
16
23
  SharedTrigger,
17
24
  } = composeStories(stories)
18
25
 
@@ -422,3 +429,125 @@ test("an empty menu shows a disabled note, not an actionable item", async () =>
422
429
 
423
430
  await expectNoAxeViolations(menu)
424
431
  })
432
+
433
+ /**
434
+ * `--control-height-<size>` in px.
435
+ *
436
+ * The token is declared in `rem`, and `getComputedStyle` hands back a
437
+ * custom property's *declared* value rather than a used length — so
438
+ * the conversion happens here, against the live root font size. That
439
+ * keeps the assertions below written against the token instead of
440
+ * against a `44` typed in this file, so a density change moves them
441
+ * with it.
442
+ */
443
+ const getControlHeightPx = (size: "lg" | "md" | "sm") => {
444
+ const root = globalThis.document.documentElement
445
+
446
+ const declared = globalThis
447
+ .getComputedStyle(root)
448
+ .getPropertyValue(`--control-height-${size}`)
449
+
450
+ return (
451
+ Number.parseFloat(declared) *
452
+ Number.parseFloat(
453
+ globalThis.getComputedStyle(root).fontSize,
454
+ )
455
+ )
456
+ }
457
+
458
+ /**
459
+ * The click target, measured rather than described.
460
+ *
461
+ * `itemSize` defaults to `lg` on a menu and nothing else in the
462
+ * library, which is the point: a menu item is aimed at in a hurry and
463
+ * there are rarely more than about eight of them, so it can afford
464
+ * the height a dense list cannot. `--control-height-lg` is 2.75rem at
465
+ * `comfortable` density — 44px, the WCAG 2.5.5 target — and the
466
+ * assertion is against the token rather than against 44, so the kiosk
467
+ * density does not have to be special-cased here.
468
+ */
469
+ test("a menu item is as tall as an `lg` control", async () => {
470
+ await setViewport(DESKTOP)
471
+
472
+ const { body } = await mountStory(AllStates)
473
+
474
+ const item = expectAgentDrivable(body, {
475
+ name: "Retry title",
476
+ role: "menuitem",
477
+ })
478
+
479
+ expect(
480
+ item.getBoundingClientRect().height,
481
+ ).toBeGreaterThanOrEqual(getControlHeightPx("lg"))
482
+ })
483
+
484
+ /**
485
+ * The other half of the same request: bigger rows, *unless* the
486
+ * window cannot spend the height. Nine `lg` rows are 400px of panel,
487
+ * which on a half-height window is the difference between a menu read
488
+ * at a glance and a menu scrolled.
489
+ */
490
+ test("a short window steps the item size back down", async () => {
491
+ await setViewport(SHORT_WINDOW)
492
+
493
+ try {
494
+ const { body } = await mountStory(AllStates)
495
+
496
+ const item = expectAgentDrivable(body, {
497
+ name: "Retry title",
498
+ role: "menuitem",
499
+ })
500
+
501
+ expect(
502
+ item.getBoundingClientRect().height,
503
+ ).toBeLessThan(getControlHeightPx("lg"))
504
+ } finally {
505
+ // Every later test in this file measures against the tall rows,
506
+ // and the viewport is shared across the file.
507
+ await setViewport(DESKTOP)
508
+ }
509
+ })
510
+
511
+ /**
512
+ * The panel stays inside the window, and the label wraps to do it.
513
+ *
514
+ * `position: fixed` makes a panel's shrink-to-fit width stop at the
515
+ * *viewport* rather than at the space `shift` left it, so a long label
516
+ * produced a menu exactly as wide as a 390px window at `left: 8` —
517
+ * with 8px of itself off the right edge, clipped and unreachable. The
518
+ * assertion is against `shift`'s own 8px padding on both sides, so it
519
+ * fails if either the clamp or the padding goes away.
520
+ */
521
+ test("a long label wraps rather than pushing the panel off screen", async () => {
522
+ await setViewport(PHONE)
523
+
524
+ try {
525
+ const { body } = await mountStory(LongLabel)
526
+
527
+ const menu = expectAgentDrivable(body, {
528
+ name: "Bay 8",
529
+ role: "menu",
530
+ })
531
+
532
+ const panel = menu.getBoundingClientRect()
533
+
534
+ expect(panel.left).toBeGreaterThanOrEqual(8)
535
+
536
+ expect(panel.right).toBeLessThanOrEqual(
537
+ globalThis.innerWidth - 8,
538
+ )
539
+
540
+ // Wrapped, not truncated and not one long line: the row is taller
541
+ // than the single-line floor it would otherwise sit at.
542
+ const item = expectAgentDrivable(body, {
543
+ name: "Forget every remembered passcode on this device",
544
+ role: "menuitem",
545
+ })
546
+
547
+ expect(
548
+ item.getBoundingClientRect().height,
549
+ ).toBeGreaterThan(getControlHeightPx("lg"))
550
+ } finally {
551
+ await setViewport(DESKTOP)
552
+ }
553
+ })
package/src/Menu/Menu.tsx CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  useRovingFocus,
4
4
  useUniqueId,
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,
@@ -11,8 +12,10 @@ import {
11
12
  import type { ReactElement, ReactNode } from "react"
12
13
  import { useEffect, useRef } from "react"
13
14
 
15
+ import { PANEL_ITEM_SIZE_CLASS } from "../controlStyles.ts"
14
16
  import { PANEL_SURFACE_CLASS } from "../Overlay/overlayPanelClass.ts"
15
17
  import { useAnchoredOverlay } from "../Overlay/useAnchoredOverlay.ts"
18
+ import { usePanelItemSize } from "../Overlay/usePanelItemSize.ts"
16
19
  import { toClassName } from "../toClassName.ts"
17
20
  import { MenuAction } from "./MenuAction.tsx"
18
21
 
@@ -72,6 +75,19 @@ export type MenuProps = {
72
75
  */
73
76
  emptyState?: ReactNode
74
77
  isVisible: boolean
78
+ /**
79
+ * How tall each item is, from the same density-aware tokens a
80
+ * `Button` reads — so a `md` item and a `md` `Button` measure the
81
+ * same.
82
+ *
83
+ * **Defaults to `lg`**, which no other component in the library does,
84
+ * and the asymmetry is the decision rather than an oversight. A menu
85
+ * item is a *pointer* target first: it is read once and then aimed
86
+ * at, often in a hurry, and there are rarely more than about eight of
87
+ * them — so the height a dense list cannot spare, a menu can. A short
88
+ * window steps it back down on its own (`usePanelItemSize`).
89
+ */
90
+ itemSize?: ControlSize
75
91
  items: MenuEntry[]
76
92
  /** Outside press, Escape, and choosing an item all land here. */
77
93
  onDismiss: () => void
@@ -80,6 +96,20 @@ export type MenuProps = {
80
96
  trigger: ReactElement
81
97
  }
82
98
 
99
+ /**
100
+ * A group heading is not a row — it has no height of its own to take
101
+ * — so it borrows only the inline padding, which is the one thing it
102
+ * has to share with the items under it.
103
+ */
104
+ const GROUP_HEADING_INSET_CLASS: Record<
105
+ ControlSize,
106
+ string
107
+ > = {
108
+ sm: "px-(--control-padding-inline-sm)",
109
+ md: "px-(--control-padding-inline-md)",
110
+ lg: "px-(--control-padding-inline-lg)",
111
+ }
112
+
83
113
  const isMenuItem = (entry: MenuEntry): entry is MenuItem =>
84
114
  entry.type === undefined || entry.type === "item"
85
115
 
@@ -171,10 +201,13 @@ export const Menu = ({
171
201
  emptyState,
172
202
  isVisible,
173
203
  items,
204
+ itemSize: requestedItemSize = "lg",
174
205
  onDismiss,
175
206
  placement = "bottom-start",
176
207
  trigger,
177
208
  }: MenuProps): ReactNode => {
209
+ const itemSize = usePanelItemSize(requestedItemSize)
210
+
178
211
  const menuId = useUniqueId()
179
212
 
180
213
  const itemElements = useRef(
@@ -195,7 +228,20 @@ export const Menu = ({
195
228
  getFloatingProps,
196
229
  setFloating,
197
230
  } = useAnchoredOverlay({
231
+ // A menu had no clamp at all, which was survivable while a row was
232
+ // 32px and is not now: nine `lg` rows are 400px, and on a short
233
+ // window the panel simply ran off the bottom of the screen with no
234
+ // way to reach the last item. It scrolls instead.
235
+ isHeightClamped: true,
198
236
  isVisible,
237
+ // And the inline twin. A portalled panel is `position: fixed`, so
238
+ // its shrink-to-fit width stops at the *viewport* rather than at
239
+ // the space `shift` left it — measured at 390px, a menu holding
240
+ // Mail Sifter's "Lock unlocks — forget passcodes on this device"
241
+ // came out 390px wide at `left: 8`, hanging 8px off the right
242
+ // edge. Pre-existing and size-independent, but a `lg` row's larger
243
+ // type is what makes a real label reach it.
244
+ isWidthClamped: true,
199
245
  offsetValue: 4,
200
246
  onDismiss,
201
247
  placement,
@@ -242,6 +288,7 @@ export const Menu = ({
242
288
  const renderItem = (item: MenuItem) => (
243
289
  <MenuAction
244
290
  item={item}
291
+ itemSize={itemSize}
245
292
  key={item.key}
246
293
  onDismiss={onDismiss}
247
294
  register={focus.register}
@@ -280,7 +327,13 @@ export const Menu = ({
280
327
  role="group"
281
328
  >
282
329
  <div
283
- className="px-2 pt-1.5 pb-0.5 font-medium text-content-secondary text-xs"
330
+ // The heading is inset to the items' own inline padding so
331
+ // its text starts where their labels do, which is why it
332
+ // reads the row-size tokens rather than a fixed `px-2`.
333
+ className={toClassName(
334
+ GROUP_HEADING_INSET_CLASS[itemSize],
335
+ "pt-1.5 pb-0.5 font-medium text-content-secondary text-xs",
336
+ )}
284
337
  id={headingId}
285
338
  >
286
339
  {entry.label}
@@ -304,7 +357,10 @@ export const Menu = ({
304
357
  emptyState === undefined ? null : (
305
358
  <div
306
359
  aria-disabled="true"
307
- className="px-2 py-1.5 text-content-disabled text-sm"
360
+ className={toClassName(
361
+ "flex items-center text-content-disabled",
362
+ PANEL_ITEM_SIZE_CLASS[itemSize],
363
+ )}
308
364
  role="menuitem"
309
365
  tabIndex={-1}
310
366
  >
@@ -333,7 +389,10 @@ export const Menu = ({
333
389
  {...getFloatingProps()}
334
390
  className={toClassName(
335
391
  PANEL_SURFACE_CLASS,
336
- "z-[var(--layer-modal)] flex min-w-48 flex-col gap-0.5 p-1",
392
+ // The height is clamped to the space the viewport left,
393
+ // written straight onto the element by the `size`
394
+ // middleware; this scrolls inside it.
395
+ "charcuterie-scrollbar z-[var(--layer-modal)] flex min-w-48 flex-col gap-0.5 overflow-y-auto p-1",
337
396
  className,
338
397
  )}
339
398
  onKeyDown={(keyEvent) => {
@@ -1,11 +1,15 @@
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 { MenuItem } from "./Menu.tsx"
7
9
 
8
10
  export type MenuActionProps = {
11
+ /** Already stepped down for a short window by `usePanelItemSize`. */
12
+ itemSize: ControlSize
9
13
  item: MenuItem
10
14
  onDismiss: () => void
11
15
  /** `RovingFocus.register` — membership of the arrow-key group. */
@@ -31,6 +35,7 @@ export type MenuActionProps = {
31
35
  */
32
36
  export const MenuAction = ({
33
37
  item,
38
+ itemSize,
34
39
  onDismiss,
35
40
  register,
36
41
  tabIndex,
@@ -55,10 +60,25 @@ export const MenuAction = ({
55
60
  return (
56
61
  <button
57
62
  className={toClassName(
58
- "flex w-full cursor-pointer items-center gap-2 rounded-sm bg-transparent px-2 py-1.5 text-start text-content-primary text-sm transition-colors duration-(--duration-fast) ease-standard",
59
- "hover:bg-intent-neutral-surface",
63
+ // No base `bg-transparent`: it is a plain `background-color`
64
+ // utility at the same specificity as the hover tint and Tailwind
65
+ // emits it last, so it wins — the same clobber that made a
66
+ // `Combobox`'s keyboard cursor invisible. A button's background is
67
+ // transparent already.
68
+ "flex w-full cursor-pointer items-center rounded-sm text-start text-content-primary transition-colors duration-(--duration-fast) ease-standard",
69
+ // Height, inline padding, gap and type all come from the row-size
70
+ // system, so a `md` menu item is exactly as tall as a `md`
71
+ // `Button` and the kiosk density grows both together.
72
+ PANEL_ITEM_SIZE_CLASS[itemSize],
73
+ // `-hover`, not plain `intent-neutral-surface`: this panel is
74
+ // `surface-overlay`, which every dark scheme paints *lighter*
75
+ // than the base-surface tint — so the plain one reads as no
76
+ // change. `ListboxOption` and `ComboboxOption` were corrected on
77
+ // 2026-08-05 and this was missed.
78
+ !isDisabled &&
79
+ "hover:bg-intent-neutral-surface-hover",
60
80
  isDisabled &&
61
- "cursor-not-allowed text-content-disabled hover:bg-transparent",
81
+ "cursor-not-allowed text-content-disabled",
62
82
  FOCUS_RING_CLASS,
63
83
  )}
64
84
  disabled={isDisabled}
@@ -89,7 +109,12 @@ export const MenuAction = ({
89
109
  </span>
90
110
  )}
91
111
 
92
- {label}
112
+ {/*
113
+ `min-w-0`, so a label longer than the panel wraps instead of
114
+ forcing the flex line wider than its container. The icon beside
115
+ it is `shrink-0` and keeps its width.
116
+ */}
117
+ <span className="min-w-0">{label}</span>
93
118
  </button>
94
119
  )
95
120
  }
@@ -85,9 +85,33 @@ export type UseAnchoredOverlayOptions = {
85
85
  * floating-ui `useDismiss` options they carry.
86
86
  */
87
87
  isEscapeDismissable?: boolean
88
+ /**
89
+ * Clamp the panel to the space the viewport actually leaves below
90
+ * (or above) the trigger, with no fixed cap. The panel owes itself
91
+ * an `overflow`, or a clamped one simply hides its last rows.
92
+ *
93
+ * `maxHeightPx` implies this *and* caps it at a number — a `Listbox`
94
+ * never wants to be 900px tall even on a monitor that would allow
95
+ * it. A `Menu` wants the other half only: as tall as its items need,
96
+ * and never off the bottom of the screen.
97
+ */
98
+ isHeightClamped?: boolean
88
99
  isOutsidePressDismissable?: boolean
89
100
  /** Ports `PortalDropdown`'s anchor-width match, via `size`. */
90
101
  isTriggerWidthMatched?: boolean
102
+ /**
103
+ * Clamp the panel to the width the viewport actually leaves, with no
104
+ * fixed cap — the inline twin of `isHeightClamped`, and `maxWidthPx`
105
+ * without the number.
106
+ *
107
+ * A portalled panel is `position: fixed`, so its shrink-to-fit width
108
+ * already stops at the *viewport*, not at the space `shift` left it
109
+ * — which means a long label produces a panel exactly as wide as the
110
+ * window, positioned 8px in, with 8px of itself off the right edge.
111
+ * `availableWidth` is the shifted number, so this is what makes a
112
+ * long label wrap instead.
113
+ */
114
+ isWidthClamped?: boolean
91
115
  isVisible: boolean
92
116
  /** Clamps the panel to `min(this, available viewport space)`. */
93
117
  maxHeightPx?: number
@@ -120,9 +144,11 @@ const PLACEHOLDER_TRIGGER = createElement("span")
120
144
  export const useAnchoredOverlay = ({
121
145
  anchorRef,
122
146
  isEscapeDismissable = true,
147
+ isHeightClamped = false,
123
148
  isOutsidePressDismissable = true,
124
149
  isTriggerWidthMatched = false,
125
150
  isVisible,
151
+ isWidthClamped = false,
126
152
  maxHeightPx,
127
153
  maxWidthPx,
128
154
  offsetValue = 8,
@@ -138,7 +164,9 @@ export const useAnchoredOverlay = ({
138
164
  ]
139
165
 
140
166
  if (
167
+ isHeightClamped ||
141
168
  isTriggerWidthMatched ||
169
+ isWidthClamped ||
142
170
  maxHeightPx !== undefined ||
143
171
  maxWidthPx !== undefined
144
172
  ) {
@@ -158,11 +186,11 @@ export const useAnchoredOverlay = ({
158
186
 
159
187
  elements.floating.style.maxHeight = `${heightCap}px`
160
188
 
161
- if (maxWidthPx !== undefined) {
162
- const widthCap = Math.min(
163
- maxWidthPx,
164
- availableWidth,
165
- )
189
+ if (isWidthClamped || maxWidthPx !== undefined) {
190
+ const widthCap =
191
+ maxWidthPx === undefined
192
+ ? availableWidth
193
+ : Math.min(maxWidthPx, availableWidth)
166
194
 
167
195
  elements.floating.style.maxWidth = `${widthCap}px`
168
196
  }
@@ -0,0 +1,71 @@
1
+ import { useMediaQuery } from "@charcuterie/logic"
2
+ import { matchMediaMatcher } from "@charcuterie/logic/browser"
3
+ import type { ControlSize } from "@charcuterie/tokens"
4
+
5
+ /**
6
+ * The requested row size, stepped down when the window is too short
7
+ * to spend the height.
8
+ *
9
+ * A fat option row is a bigger click target, which is the whole point
10
+ * of `itemSize` — but a nine-item menu at `lg` is 400px of panel, and
11
+ * on a half-height window that is the difference between a menu you
12
+ * read at a glance and a menu you scroll. So the size a panel asks
13
+ * for is a *ceiling*: below 40rem of viewport height it drops one
14
+ * step, and below 30rem it drops to the floor.
15
+ *
16
+ * ### Why a hook and not a media query in CSS
17
+ *
18
+ * Both were written. CSS is the library's usual answer — the density
19
+ * axis rewrites `--control-height-*` and nothing re-renders — but it
20
+ * cannot step *`text-sm` to `text-md`*: those are Tailwind bridges
21
+ * onto `--font-size-*` rather than variables a media query can
22
+ * reassign, and the alternative (`text-(length:--panel-item-font-size-lg)`
23
+ * everywhere, over nine new tokens) is the indirection
24
+ * `buildCss.ts` explicitly argues against. Stepping the whole
25
+ * `ControlSize` in JS keeps one literal class string per size, which
26
+ * is also what `tailwindCandidates.test.ts` can see.
27
+ *
28
+ * The re-render this costs happens on a window resize, not on a
29
+ * scheme or density flip — those stay repaints.
30
+ *
31
+ * ### `height`, not `width`
32
+ *
33
+ * Rows stack down the panel, so height is the axis that runs out.
34
+ * The Narrow View is a separate question and this is not it: a 390px
35
+ * phone held upright has plenty of height, and its menu stays fat.
36
+ */
37
+ const SHORT_VIEWPORT_QUERY = "(height <= 40rem)"
38
+
39
+ const VERY_SHORT_VIEWPORT_QUERY = "(height <= 30rem)"
40
+
41
+ const STEP_DOWN: Record<ControlSize, ControlSize> = {
42
+ sm: "sm",
43
+ md: "sm",
44
+ lg: "md",
45
+ }
46
+
47
+ export const usePanelItemSize = (
48
+ itemSize: ControlSize,
49
+ ): ControlSize => {
50
+ // Both hooks run unconditionally and the *narrower* one wins, so
51
+ // the two queries never disagree about which step applies.
52
+ const { isMatching: isShortViewport } = useMediaQuery({
53
+ matcher: matchMediaMatcher(SHORT_VIEWPORT_QUERY),
54
+ })
55
+
56
+ const { isMatching: isVeryShortViewport } = useMediaQuery(
57
+ {
58
+ matcher: matchMediaMatcher(VERY_SHORT_VIEWPORT_QUERY),
59
+ },
60
+ )
61
+
62
+ if (isVeryShortViewport) {
63
+ return "sm"
64
+ }
65
+
66
+ if (isShortViewport) {
67
+ return STEP_DOWN[itemSize]
68
+ }
69
+
70
+ return itemSize
71
+ }
@@ -38,6 +38,44 @@ export const CONTROL_SIZE_CLASS: Record<
38
38
  lg: "h-(--control-height-lg) gap-(--control-gap-lg) px-(--control-padding-inline-lg) text-lg",
39
39
  }
40
40
 
41
+ /**
42
+ * A row inside an overlay panel — a `Menu` item, a `Listbox` option,
43
+ * a `Combobox` option. The same three sizes as a control, read from
44
+ * the same density-aware tokens, so a `md` option row and a `md`
45
+ * `Button` are the same height.
46
+ *
47
+ * ### Why this is not `CONTROL_SIZE_CLASS`
48
+ *
49
+ * Two differences, and both are load-bearing.
50
+ *
51
+ * `min-h-`, not `h-`. A control on a form row holds one line by
52
+ * contract; an option may be rich — an icon, two lines, a trailing
53
+ * badge — and a fixed height clips it. The token is a **floor** here,
54
+ * which is what makes the row at least as big as the button that
55
+ * opened it while still growing for a label that wraps.
56
+ *
57
+ * `py-*`, which a control has none of. It is what a wrapped second
58
+ * line breathes on once `min-h-` stops being the binding constraint.
59
+ *
60
+ * ### The sizes are click targets, not decoration
61
+ *
62
+ * The owner's request is the whole reason this exists: *"I personally
63
+ * like larger click areas because it makes it easier to not mess up a
64
+ * click. I can be much faster the less precise I need to be when
65
+ * clicking."* At `comfortable` density `lg` is 2.75rem — 44px, the
66
+ * WCAG 2.5.5 target — and on the kiosk density it is 3.75rem with no
67
+ * prop change. See
68
+ * `docs/decisions/2026-08-25-a-panel-row-is-sized-by-itemsize-and-menus-default-to-lg.md`.
69
+ */
70
+ export const PANEL_ITEM_SIZE_CLASS: Record<
71
+ ControlSize,
72
+ string
73
+ > = {
74
+ sm: "min-h-(--control-height-sm) gap-(--control-gap-sm) px-(--control-padding-inline-sm) py-1 text-sm",
75
+ md: "min-h-(--control-height-md) gap-(--control-gap-md) px-(--control-padding-inline-md) py-1.5 text-md",
76
+ lg: "min-h-(--control-height-lg) gap-(--control-gap-lg) px-(--control-padding-inline-lg) py-2 text-lg",
77
+ }
78
+
41
79
  /**
42
80
  * Square, and sized by the *height* token on both axes so an icon
43
81
  * button lines up with the text button beside it in a toolbar. No