@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.
- package/dist/Combobox/Combobox.d.ts +14 -1
- package/dist/Combobox/Combobox.d.ts.map +1 -1
- package/dist/Combobox/Combobox.js +81 -6
- package/dist/Combobox/Combobox.js.map +1 -1
- package/dist/Combobox/ComboboxOption.d.ts +4 -1
- package/dist/Combobox/ComboboxOption.d.ts.map +1 -1
- package/dist/Combobox/ComboboxOption.js +8 -2
- package/dist/Combobox/ComboboxOption.js.map +1 -1
- package/dist/Listbox/Listbox.d.ts +13 -1
- package/dist/Listbox/Listbox.d.ts.map +1 -1
- package/dist/Listbox/Listbox.js +4 -2
- package/dist/Listbox/Listbox.js.map +1 -1
- package/dist/Listbox/ListboxOption.d.ts +4 -1
- package/dist/Listbox/ListboxOption.d.ts.map +1 -1
- package/dist/Listbox/ListboxOption.js +7 -2
- package/dist/Listbox/ListboxOption.js.map +1 -1
- package/dist/Menu/Menu.d.ts +15 -1
- package/dist/Menu/Menu.d.ts.map +1 -1
- package/dist/Menu/Menu.js +39 -5
- package/dist/Menu/Menu.js.map +1 -1
- package/dist/Menu/MenuAction.d.ts +4 -1
- package/dist/Menu/MenuAction.d.ts.map +1 -1
- package/dist/Menu/MenuAction.js +22 -4
- package/dist/Menu/MenuAction.js.map +1 -1
- package/dist/Overlay/useAnchoredOverlay.d.ts +25 -1
- package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -1
- package/dist/Overlay/useAnchoredOverlay.js +8 -4
- package/dist/Overlay/useAnchoredOverlay.js.map +1 -1
- package/dist/Overlay/usePanelItemSize.d.ts +3 -0
- package/dist/Overlay/usePanelItemSize.d.ts.map +1 -0
- package/dist/Overlay/usePanelItemSize.js +59 -0
- package/dist/Overlay/usePanelItemSize.js.map +1 -0
- package/dist/controlStyles.d.ts +30 -0
- package/dist/controlStyles.d.ts.map +1 -1
- package/dist/controlStyles.js +34 -0
- package/dist/controlStyles.js.map +1 -1
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js +1 -1
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreview.js +22 -0
- package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -1
- package/dist/viewport.testHelpers.d.ts +18 -4
- package/dist/viewport.testHelpers.d.ts.map +1 -1
- package/dist/viewport.testHelpers.js +18 -6
- package/dist/viewport.testHelpers.js.map +1 -1
- package/package.json +1 -1
- package/src/Combobox/Combobox.mdx +26 -0
- package/src/Combobox/Combobox.stories.tsx +65 -0
- package/src/Combobox/Combobox.test.tsx +109 -0
- package/src/Combobox/Combobox.tsx +140 -25
- package/src/Combobox/ComboboxOption.tsx +11 -1
- package/src/Listbox/Listbox.mdx +24 -0
- package/src/Listbox/Listbox.stories.tsx +81 -5
- package/src/Listbox/Listbox.tsx +17 -0
- package/src/Listbox/ListboxOption.tsx +10 -1
- package/src/Menu/Menu.mdx +49 -0
- package/src/Menu/Menu.stories.tsx +113 -2
- package/src/Menu/Menu.test.tsx +129 -0
- package/src/Menu/Menu.tsx +62 -3
- package/src/Menu/MenuAction.tsx +29 -4
- package/src/Overlay/useAnchoredOverlay.ts +33 -5
- package/src/Overlay/usePanelItemSize.ts +71 -0
- package/src/controlStyles.ts +38 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.test.tsx +185 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.tsx +42 -1
- package/src/markdownEditorCodeMirror/livePreview.ts +22 -0
- package/src/sourceRules.test.ts +9 -0
- 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 {
|
|
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: {
|
|
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
|
+
}
|
package/src/Menu/Menu.test.tsx
CHANGED
|
@@ -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
|
-
|
|
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=
|
|
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
|
-
|
|
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) => {
|
package/src/Menu/MenuAction.tsx
CHANGED
|
@@ -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
|
-
|
|
59
|
-
|
|
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
|
|
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
|
-
{
|
|
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 =
|
|
163
|
-
maxWidthPx
|
|
164
|
-
|
|
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
|
+
}
|
package/src/controlStyles.ts
CHANGED
|
@@ -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
|