@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
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} />
@@ -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
  }