@charcuterie/ui 3.18.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.
- package/dist/Combobox/Combobox.d.ts +14 -1
- package/dist/Combobox/Combobox.d.ts.map +1 -1
- package/dist/Combobox/Combobox.js +32 -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 +46 -0
- package/src/Combobox/Combobox.tsx +76 -24
- 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
|
@@ -11,17 +11,23 @@ import { page } from "@vitest/browser/context";
|
|
|
11
11
|
* fails intermittently — which is the worst possible property for
|
|
12
12
|
* the one gate that says the shell does not scroll sideways.
|
|
13
13
|
*
|
|
14
|
-
* Polling `innerWidth` is the honest wait:
|
|
15
|
-
* the media queries and the layout resolved against, so
|
|
16
|
-
*
|
|
17
|
-
* claims.
|
|
14
|
+
* Polling `innerWidth`/`innerHeight` is the honest wait: they are the
|
|
15
|
+
* same numbers the media queries and the layout resolved against, so
|
|
16
|
+
* when they match, the thing under test really is at the size the
|
|
17
|
+
* test claims.
|
|
18
|
+
*
|
|
19
|
+
* **Height counts too**, and it did not used to. A panel row's size
|
|
20
|
+
* steps down under `(height <= 40rem)` (`usePanelItemSize`), so a test
|
|
21
|
+
* that shrinks only the height would have raced the resize with
|
|
22
|
+
* nothing to wait on and measured the tall rows about half the time.
|
|
18
23
|
*/
|
|
19
24
|
export const setViewport = async ({ height, width, }) => {
|
|
20
25
|
await page.viewport(width, height);
|
|
21
26
|
const deadline = Date.now() + 2000;
|
|
22
|
-
while (globalThis.innerWidth !== width
|
|
27
|
+
while (globalThis.innerWidth !== width ||
|
|
28
|
+
globalThis.innerHeight !== height) {
|
|
23
29
|
if (Date.now() > deadline) {
|
|
24
|
-
throw new Error(`The viewport never reached ${width}px — it is ${globalThis.innerWidth}px. Every
|
|
30
|
+
throw new Error(`The viewport never reached ${width}x${height}px — it is ${globalThis.innerWidth}x${globalThis.innerHeight}px. Every size assertion after this point would be measuring the wrong page.`);
|
|
25
31
|
}
|
|
26
32
|
await new Promise((resolve) => {
|
|
27
33
|
globalThis.requestAnimationFrame(resolve);
|
|
@@ -31,4 +37,10 @@ export const setViewport = async ({ height, width, }) => {
|
|
|
31
37
|
/** A phone in portrait — the width plex-channels scrolls at. */
|
|
32
38
|
export const PHONE = { height: 844, width: 390 };
|
|
33
39
|
export const DESKTOP = { height: 900, width: 1440 };
|
|
40
|
+
/**
|
|
41
|
+
* A window short enough to trip `usePanelItemSize`'s first step-down
|
|
42
|
+
* (`height <= 40rem`, so 640px) and wide enough that nothing else
|
|
43
|
+
* about the layout changes with it.
|
|
44
|
+
*/
|
|
45
|
+
export const SHORT_WINDOW = { height: 600, width: 1440 };
|
|
34
46
|
//# sourceMappingURL=viewport.testHelpers.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"viewport.testHelpers.js","sourceRoot":"","sources":["../src/viewport.testHelpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,yBAAyB,CAAA;AAE9C
|
|
1
|
+
{"version":3,"file":"viewport.testHelpers.js","sourceRoot":"","sources":["../src/viewport.testHelpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,yBAAyB,CAAA;AAE9C;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,KAAK,EAAE,EAChC,MAAM,EACN,KAAK,GAIN,EAAiB,EAAE;IAClB,MAAM,IAAI,CAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,CAAA;IAElC,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAA;IAElC,OACE,UAAU,CAAC,UAAU,KAAK,KAAK;QAC/B,UAAU,CAAC,WAAW,KAAK,MAAM,EACjC,CAAC;QACD,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,QAAQ,EAAE,CAAC;YAC1B,MAAM,IAAI,KAAK,CACb,8BAA8B,KAAK,IAAI,MAAM,cAAc,UAAU,CAAC,UAAU,IAAI,UAAU,CAAC,WAAW,8EAA8E,CACzL,CAAA;QACH,CAAC;QAED,MAAM,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;YAC5B,UAAU,CAAC,qBAAqB,CAAC,OAAO,CAAC,CAAA;QAC3C,CAAC,CAAC,CAAA;IACJ,CAAC;AACH,CAAC,CAAA;AAED,gEAAgE;AAChE,MAAM,CAAC,MAAM,KAAK,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,GAAG,EAAE,CAAA;AAEhD,MAAM,CAAC,MAAM,OAAO,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,CAAA;AAEnD;;;;GAIG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,CAAA"}
|
package/package.json
CHANGED
|
@@ -99,3 +99,29 @@ for a 4,000-item list. Below the threshold the list is plain DOM.
|
|
|
99
99
|
<Canvas of={ComboboxStories.Virtualized} />
|
|
100
100
|
|
|
101
101
|
<Canvas of={ComboboxStories.Interactive} />
|
|
102
|
+
|
|
103
|
+
## One panel, one row size
|
|
104
|
+
|
|
105
|
+
`itemSize` drives the search field **and** the options together, from the same
|
|
106
|
+
density-aware `--control-height-*` tokens a `Button` reads. It defaults to `md`, so at the
|
|
107
|
+
default a trigger, the search field and every option are all 2.25rem.
|
|
108
|
+
|
|
109
|
+
Covering the search row is the point rather than a bonus. The report that started this was
|
|
110
|
+
that a `Combobox`'s trigger was bigger than the options under it — but the panel's own
|
|
111
|
+
field was bigger than them too, and **two sizes inside one panel** is the defect rather
|
|
112
|
+
than either number being wrong.
|
|
113
|
+
|
|
114
|
+
It also retires a hand-picked number. The search row used to carry `px-3` to line its caret
|
|
115
|
+
up with the option labels below it, which worked only while the option padding was a
|
|
116
|
+
hardcoded `px-2`; it is now wrapped in the list's own `p-1` and given the same row-size
|
|
117
|
+
class, so the two cannot drift apart again.
|
|
118
|
+
|
|
119
|
+
`lg` is the fat one — 2.75rem, the 44px WCAG 2.5.5 target. A window shorter than 40rem
|
|
120
|
+
steps each size down one step on its own.
|
|
121
|
+
|
|
122
|
+
The virtualizer's `estimateSize` tracks `itemSize` too. Every row is re-measured
|
|
123
|
+
immediately, so the estimate only decides how far the scrollbar lies before the first
|
|
124
|
+
measurement lands — but a `lg` list opening with a scrollbar a third too short and visibly
|
|
125
|
+
settling is exactly the kind of thing nobody files a bug about.
|
|
126
|
+
|
|
127
|
+
<Canvas of={ComboboxStories.ItemSizes} />
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { useVisibility } from "@charcuterie/logic"
|
|
2
|
+
import type { ControlSize } from "@charcuterie/tokens"
|
|
2
3
|
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
4
|
import type { ReactNode } from "react"
|
|
4
5
|
import { useRef, useState } from "react"
|
|
5
6
|
|
|
7
|
+
import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
|
|
6
8
|
import { Button } from "../Button/Button.tsx"
|
|
9
|
+
import {
|
|
10
|
+
StoryCell,
|
|
11
|
+
StoryGrid,
|
|
12
|
+
} from "../board.storyHelpers.tsx"
|
|
7
13
|
import type { ListboxItem } from "../Listbox/Listbox.tsx"
|
|
8
14
|
import { Combobox } from "./Combobox.tsx"
|
|
9
15
|
|
|
@@ -49,6 +55,8 @@ const meta = {
|
|
|
49
55
|
title: "Components/Controls/Combobox",
|
|
50
56
|
component: Combobox,
|
|
51
57
|
parameters: { layout: "padded" },
|
|
58
|
+
argTypes: { itemSize: controlSizeArgType },
|
|
59
|
+
args: { itemSize: "md" },
|
|
52
60
|
} satisfies Meta<typeof Combobox>
|
|
53
61
|
|
|
54
62
|
export default meta
|
|
@@ -65,6 +73,7 @@ const ComboboxHarness = ({
|
|
|
65
73
|
isLoading,
|
|
66
74
|
isMultiple,
|
|
67
75
|
isVirtualized,
|
|
76
|
+
itemSize,
|
|
68
77
|
options,
|
|
69
78
|
selectedValue,
|
|
70
79
|
triggerLabel,
|
|
@@ -78,6 +87,7 @@ const ComboboxHarness = ({
|
|
|
78
87
|
isLoading?: boolean
|
|
79
88
|
isMultiple?: boolean
|
|
80
89
|
isVirtualized?: boolean
|
|
90
|
+
itemSize?: ControlSize
|
|
81
91
|
options: ListboxItem[]
|
|
82
92
|
selectedValue?: readonly string[] | string
|
|
83
93
|
triggerLabel: string
|
|
@@ -105,6 +115,7 @@ const ComboboxHarness = ({
|
|
|
105
115
|
isLoading={isLoading}
|
|
106
116
|
isMultiple={isMultiple}
|
|
107
117
|
isVirtualized={isVirtualized}
|
|
118
|
+
itemSize={itemSize}
|
|
108
119
|
isVisible={isVisible}
|
|
109
120
|
onDismiss={hide}
|
|
110
121
|
onSelect={(value) => {
|
|
@@ -589,3 +600,57 @@ export const VirtualizedChosenValue: Story = {
|
|
|
589
600
|
/>
|
|
590
601
|
),
|
|
591
602
|
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* The three row sizes — and the fix the owner's screenshot asked for.
|
|
606
|
+
*
|
|
607
|
+
* The report was that a `Combobox`'s trigger was visibly bigger than
|
|
608
|
+
* the options under it. Two things were true at once: the option row
|
|
609
|
+
* was a hardcoded `px-2 py-1.5 text-sm` that no control shares, and
|
|
610
|
+
* the panel's **own** search field was bigger than the options it
|
|
611
|
+
* filters — two sizes inside one panel, which is the defect rather
|
|
612
|
+
* than either number being wrong.
|
|
613
|
+
*
|
|
614
|
+
* `itemSize` now drives the search row and the options together, from
|
|
615
|
+
* the same `--control-height-*` a `Button` reads. At the `md` default
|
|
616
|
+
* a trigger, the search field and every option are all 2.25rem.
|
|
617
|
+
*/
|
|
618
|
+
export const ItemSizes: Story = {
|
|
619
|
+
args: {
|
|
620
|
+
isVisible: false,
|
|
621
|
+
onDismiss: () => {},
|
|
622
|
+
onSelect: () => {},
|
|
623
|
+
options: LANGUAGES,
|
|
624
|
+
trigger: <Button>Search languages</Button>,
|
|
625
|
+
},
|
|
626
|
+
render: () => (
|
|
627
|
+
<StoryGrid columns={3}>
|
|
628
|
+
<StoryCell label="sm — the old option">
|
|
629
|
+
<ComboboxHarness
|
|
630
|
+
isInitiallyVisible
|
|
631
|
+
itemSize="sm"
|
|
632
|
+
options={LANGUAGES}
|
|
633
|
+
triggerLabel="Search languages"
|
|
634
|
+
/>
|
|
635
|
+
</StoryCell>
|
|
636
|
+
|
|
637
|
+
<StoryCell label="md — the default, matches the trigger">
|
|
638
|
+
<ComboboxHarness
|
|
639
|
+
isInitiallyVisible
|
|
640
|
+
itemSize="md"
|
|
641
|
+
options={LANGUAGES}
|
|
642
|
+
triggerLabel="Search languages"
|
|
643
|
+
/>
|
|
644
|
+
</StoryCell>
|
|
645
|
+
|
|
646
|
+
<StoryCell label="lg — the fat option (44px)">
|
|
647
|
+
<ComboboxHarness
|
|
648
|
+
isInitiallyVisible
|
|
649
|
+
itemSize="lg"
|
|
650
|
+
options={LANGUAGES}
|
|
651
|
+
triggerLabel="Search languages"
|
|
652
|
+
/>
|
|
653
|
+
</StoryCell>
|
|
654
|
+
</StoryGrid>
|
|
655
|
+
),
|
|
656
|
+
}
|
|
@@ -705,3 +705,49 @@ test("a windowed list reseeds its window on every open, not just the first", asy
|
|
|
705
705
|
mounted.body.queryByRole("option", { name: "Track 1" }),
|
|
706
706
|
).toBeNull()
|
|
707
707
|
})
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* One panel, one row size.
|
|
711
|
+
*
|
|
712
|
+
* The owner's report was that the search field was visibly bigger
|
|
713
|
+
* than the options it filters — *"the button is huge, but the options
|
|
714
|
+
* don't match it"* — and two sizes inside one panel is the defect,
|
|
715
|
+
* not the field being wrong or the option being wrong. `itemSize`
|
|
716
|
+
* drives both, so this measures the pair rather than either number.
|
|
717
|
+
*/
|
|
718
|
+
test("the search row and an option are the same height", async () => {
|
|
719
|
+
const { body, canvas } = await mountStory(Interactive)
|
|
720
|
+
|
|
721
|
+
await userEvent.click(
|
|
722
|
+
expectAgentDrivable(canvas, {
|
|
723
|
+
name: "Search languages",
|
|
724
|
+
role: "button",
|
|
725
|
+
}),
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
const input = body.getByRole("combobox")
|
|
729
|
+
|
|
730
|
+
await waitFor(() => {
|
|
731
|
+
expect(input).toHaveFocus()
|
|
732
|
+
})
|
|
733
|
+
|
|
734
|
+
// The row is the input's *parent*: the input itself is a bare
|
|
735
|
+
// `flex-1` field, and the box that carries the row-size class — the
|
|
736
|
+
// height, the inline padding, the type — is the wrapper around it.
|
|
737
|
+
const searchRow = input.closest("div")
|
|
738
|
+
|
|
739
|
+
const [option] = body.getAllByRole("option")
|
|
740
|
+
|
|
741
|
+
expect(option).toBeDefined()
|
|
742
|
+
|
|
743
|
+
expect(searchRow?.getBoundingClientRect().height).toBe(
|
|
744
|
+
option?.getBoundingClientRect().height,
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
// And the query reads at the size of the options it is filtering,
|
|
748
|
+
// which is inherited from that wrapper rather than set on the input.
|
|
749
|
+
expect(globalThis.getComputedStyle(input).fontSize).toBe(
|
|
750
|
+
globalThis.getComputedStyle(option as HTMLElement)
|
|
751
|
+
.fontSize,
|
|
752
|
+
)
|
|
753
|
+
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ControlSize } from "@charcuterie/tokens"
|
|
1
2
|
import {
|
|
2
3
|
FloatingFocusManager,
|
|
3
4
|
FloatingPortal,
|
|
@@ -11,9 +12,11 @@ import type {
|
|
|
11
12
|
} from "react"
|
|
12
13
|
import { useEffect, useRef, useState } from "react"
|
|
13
14
|
|
|
15
|
+
import { PANEL_ITEM_SIZE_CLASS } from "../controlStyles.ts"
|
|
14
16
|
import type { ListboxItem } from "../Listbox/Listbox.tsx"
|
|
15
17
|
import { PANEL_SURFACE_CLASS } from "../Overlay/overlayPanelClass.ts"
|
|
16
18
|
import { useAnchoredOverlay } from "../Overlay/useAnchoredOverlay.ts"
|
|
19
|
+
import { usePanelItemSize } from "../Overlay/usePanelItemSize.ts"
|
|
17
20
|
import { toClassName } from "../toClassName.ts"
|
|
18
21
|
import { ComboboxOption } from "./ComboboxOption.tsx"
|
|
19
22
|
|
|
@@ -48,6 +51,18 @@ export type ComboboxProps = {
|
|
|
48
51
|
/** Windowing. Auto-on above ~100 options when left undefined. */
|
|
49
52
|
isVirtualized?: boolean
|
|
50
53
|
isVisible: boolean
|
|
54
|
+
/**
|
|
55
|
+
* How tall each option — and the search field above them — is, from
|
|
56
|
+
* the same density-aware tokens a `Button` reads.
|
|
57
|
+
*
|
|
58
|
+
* It covers the search row deliberately. The owner's report was that
|
|
59
|
+
* the panel's own field was visibly bigger than the options under it
|
|
60
|
+
* (*"the button is huge, but the options don't match it"*), and two
|
|
61
|
+
* sizes inside one panel is the defect. One prop drives both.
|
|
62
|
+
*
|
|
63
|
+
* A short window steps it back down on its own (`usePanelItemSize`).
|
|
64
|
+
*/
|
|
65
|
+
itemSize?: ControlSize
|
|
51
66
|
onDismiss: () => void
|
|
52
67
|
onSelect: (value: string) => void
|
|
53
68
|
/**
|
|
@@ -71,6 +86,20 @@ export type ComboboxProps = {
|
|
|
71
86
|
|
|
72
87
|
const AUTO_VIRTUALIZE_THRESHOLD = 100
|
|
73
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The virtualizer's pre-measurement guess per row size, in px at
|
|
91
|
+
* `comfortable` density — `--control-height-*` plus the row's own
|
|
92
|
+
* `py-*`. Hardcoded because a virtualizer wants a number before the
|
|
93
|
+
* first paint, which is the one place a token cannot reach; every row
|
|
94
|
+
* is measured immediately afterwards, so a density that disagrees
|
|
95
|
+
* costs a frame rather than a wrong layout.
|
|
96
|
+
*/
|
|
97
|
+
const ROW_ESTIMATE_PX: Record<ControlSize, number> = {
|
|
98
|
+
sm: 40,
|
|
99
|
+
md: 48,
|
|
100
|
+
lg: 60,
|
|
101
|
+
}
|
|
102
|
+
|
|
74
103
|
/**
|
|
75
104
|
* How many frames the open seed will wait for the panel to be capped
|
|
76
105
|
* before it gives up on centring the chosen row. A few is all
|
|
@@ -130,6 +159,7 @@ export const Combobox = ({
|
|
|
130
159
|
isMultiple = false,
|
|
131
160
|
isVirtualized,
|
|
132
161
|
isVisible,
|
|
162
|
+
itemSize: requestedItemSize = "md",
|
|
133
163
|
onDismiss,
|
|
134
164
|
onQueryChange,
|
|
135
165
|
onSelect,
|
|
@@ -139,6 +169,8 @@ export const Combobox = ({
|
|
|
139
169
|
selectedValue,
|
|
140
170
|
trigger,
|
|
141
171
|
}: ComboboxProps): ReactNode => {
|
|
172
|
+
const itemSize = usePanelItemSize(requestedItemSize)
|
|
173
|
+
|
|
142
174
|
// Attached-input mode: the consumer's `<input>` is the reference,
|
|
143
175
|
// query source, focus holder and keyboard target; Combobox renders the
|
|
144
176
|
// list-only panel.
|
|
@@ -254,7 +286,12 @@ export const Combobox = ({
|
|
|
254
286
|
|
|
255
287
|
const rowVirtualizer = useVirtualizer({
|
|
256
288
|
count: filtered.length,
|
|
257
|
-
|
|
289
|
+
// An *estimate*: every row is re-measured (`measureElement` on the
|
|
290
|
+
// wrapper below), so this only decides how far the scrollbar lies
|
|
291
|
+
// before the first measurement lands. It still has to track
|
|
292
|
+
// `itemSize`, or a `lg` list opens with a scrollbar a third too
|
|
293
|
+
// short and visibly settles.
|
|
294
|
+
estimateSize: () => ROW_ESTIMATE_PX[itemSize],
|
|
258
295
|
getScrollElement: () => listElement.current,
|
|
259
296
|
overscan: 8,
|
|
260
297
|
})
|
|
@@ -735,6 +772,7 @@ export const Combobox = ({
|
|
|
735
772
|
isActive={index === resolvedActiveIndex}
|
|
736
773
|
isSelected={selected.includes(option.value)}
|
|
737
774
|
item={option}
|
|
775
|
+
itemSize={itemSize}
|
|
738
776
|
key={option.value}
|
|
739
777
|
onSelect={commitValue}
|
|
740
778
|
posInSet={isWindowed ? index + 1 : undefined}
|
|
@@ -806,29 +844,43 @@ export const Combobox = ({
|
|
|
806
844
|
style={floatingStyles}
|
|
807
845
|
>
|
|
808
846
|
{isAttached ? null : (
|
|
809
|
-
//
|
|
810
|
-
//
|
|
811
|
-
//
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
847
|
+
// The `p-1` is the list container's own padding, repeated
|
|
848
|
+
// here so the search caret starts exactly where the option
|
|
849
|
+
// labels below it do — the alignment a hand-picked `px-3`
|
|
850
|
+
// used to approximate, and which broke the moment the row
|
|
851
|
+
// padding became a token. The border stays on the outer
|
|
852
|
+
// element, so it still spans the whole panel.
|
|
853
|
+
<div className="border-border-subtle border-b p-1">
|
|
854
|
+
<div
|
|
855
|
+
className={toClassName(
|
|
856
|
+
"flex items-center",
|
|
857
|
+
PANEL_ITEM_SIZE_CLASS[itemSize],
|
|
858
|
+
)}
|
|
859
|
+
>
|
|
860
|
+
<input
|
|
861
|
+
aria-activedescendant={
|
|
862
|
+
activeDescendantId
|
|
863
|
+
}
|
|
864
|
+
aria-autocomplete="list"
|
|
865
|
+
aria-controls={listboxId}
|
|
866
|
+
aria-expanded="true"
|
|
867
|
+
aria-labelledby={triggerId}
|
|
868
|
+
// No `text-*` of its own: the wrapper's row-size
|
|
869
|
+
// class sets the font size, and the input inherits
|
|
870
|
+
// it — so the query reads at exactly the size of
|
|
871
|
+
// the options it is filtering.
|
|
872
|
+
className="min-w-24 flex-1 bg-transparent text-content-primary outline-none placeholder:text-content-muted"
|
|
873
|
+
onChange={(changeEvent) => {
|
|
874
|
+
setQuery(changeEvent.target.value)
|
|
875
|
+
}}
|
|
876
|
+
onKeyDown={handleKeyDown}
|
|
877
|
+
placeholder={placeholder}
|
|
878
|
+
ref={internalInputElement}
|
|
879
|
+
role="combobox"
|
|
880
|
+
type="text"
|
|
881
|
+
value={currentQuery}
|
|
882
|
+
/>
|
|
883
|
+
</div>
|
|
832
884
|
</div>
|
|
833
885
|
)}
|
|
834
886
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import type { ControlSize } from "@charcuterie/tokens"
|
|
1
2
|
import type { ReactNode } from "react"
|
|
2
3
|
|
|
4
|
+
import { PANEL_ITEM_SIZE_CLASS } from "../controlStyles.ts"
|
|
3
5
|
import type { ListboxItem } from "../Listbox/Listbox.tsx"
|
|
4
6
|
import { toClassName } from "../toClassName.ts"
|
|
5
7
|
|
|
@@ -8,6 +10,8 @@ export type ComboboxOptionProps = {
|
|
|
8
10
|
id: string
|
|
9
11
|
isActive: boolean
|
|
10
12
|
isSelected: boolean
|
|
13
|
+
/** Already stepped down for a short window by `usePanelItemSize`. */
|
|
14
|
+
itemSize: ControlSize
|
|
11
15
|
item: ListboxItem
|
|
12
16
|
onSelect: (value: string) => void
|
|
13
17
|
/**
|
|
@@ -41,6 +45,7 @@ export const ComboboxOption = ({
|
|
|
41
45
|
isActive,
|
|
42
46
|
isSelected,
|
|
43
47
|
item,
|
|
48
|
+
itemSize,
|
|
44
49
|
onSelect,
|
|
45
50
|
posInSet,
|
|
46
51
|
setSize,
|
|
@@ -60,7 +65,12 @@ export const ComboboxOption = ({
|
|
|
60
65
|
// fill and the keyboard cursor was invisible (it read as "arrows
|
|
61
66
|
// do nothing"). A button's background is transparent by default;
|
|
62
67
|
// the tint classes only ever add one.
|
|
63
|
-
"flex w-full cursor-pointer items-center justify-between
|
|
68
|
+
"flex w-full cursor-pointer items-center justify-between rounded-sm text-start",
|
|
69
|
+
// Height, inline padding, gap and type from the row-size system,
|
|
70
|
+
// so an option matches the search field above it and the trigger
|
|
71
|
+
// that opened it, rather than being visibly the smaller thing in
|
|
72
|
+
// its own panel.
|
|
73
|
+
PANEL_ITEM_SIZE_CLASS[itemSize],
|
|
64
74
|
// The base sets no text colour: a plain `text-content-primary`
|
|
65
75
|
// clobbers a conditional `text-content-disabled` at equal
|
|
66
76
|
// specificity (Tailwind emits the base last), so a disabled option
|
package/src/Listbox/Listbox.mdx
CHANGED
|
@@ -66,3 +66,27 @@ is no `heading` prop; the button names the widget. The panel height is clamped t
|
|
|
66
66
|
viewport by floating-ui's `size` middleware and scrolls within it.
|
|
67
67
|
|
|
68
68
|
<Canvas of={ListboxStories.Interactive} />
|
|
69
|
+
|
|
70
|
+
## An option is the size of the control that opened it
|
|
71
|
+
|
|
72
|
+
`itemSize` takes the same `sm` / `md` / `lg` a `Button` takes, from the same
|
|
73
|
+
density-aware `--control-height-*` tokens. It **defaults to `md`** — 2.25rem, the height a
|
|
74
|
+
default `Button` and a `Select` already take — so a picker's options measure the same as
|
|
75
|
+
its trigger.
|
|
76
|
+
|
|
77
|
+
That default is a fix, not a preference. The rows were a hardcoded `px-2 py-1.5 text-sm`
|
|
78
|
+
that no control in the library shares, so an option was visibly the smaller thing inside
|
|
79
|
+
its own panel; the owner's report was *"the button is huge, but the options don't match
|
|
80
|
+
it"*.
|
|
81
|
+
|
|
82
|
+
`lg` is the fat one: 2.75rem, the 44px WCAG 2.5.5 target, for a list that is aimed at
|
|
83
|
+
rather than scanned. `sm` is what this component rendered before, kept for a genuinely
|
|
84
|
+
dense list.
|
|
85
|
+
|
|
86
|
+
The floor is a **minimum**, not a fixed height (`min-h-`, where a `Button` uses `h-`) — the
|
|
87
|
+
rich option below, with a flag and a trailing `Badge`, still grows past it.
|
|
88
|
+
|
|
89
|
+
A window shorter than 40rem steps each size down one step on its own, and one shorter than
|
|
90
|
+
30rem takes them to `sm`.
|
|
91
|
+
|
|
92
|
+
<Canvas of={ListboxStories.ItemSizes} />
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import { useVisibility } from "@charcuterie/logic"
|
|
2
|
+
import type { ControlSize } from "@charcuterie/tokens"
|
|
2
3
|
import type { Meta, StoryObj } from "@storybook/react"
|
|
3
4
|
import type { ReactNode } from "react"
|
|
4
5
|
import { useState } from "react"
|
|
5
6
|
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
controlSizeArgType,
|
|
9
|
+
placementArgType,
|
|
10
|
+
} from "../argTypes.storyHelpers.ts"
|
|
7
11
|
import { Badge } from "../Badge/Badge.tsx"
|
|
8
12
|
import { Button } from "../Button/Button.tsx"
|
|
9
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
StoryCell,
|
|
15
|
+
StoryGrid,
|
|
16
|
+
StoryRow,
|
|
17
|
+
} from "../board.storyHelpers.tsx"
|
|
10
18
|
import type { ListboxItem } from "./Listbox.tsx"
|
|
11
19
|
import { Listbox } from "./Listbox.tsx"
|
|
12
20
|
|
|
@@ -73,8 +81,11 @@ const meta = {
|
|
|
73
81
|
title: "Components/Controls/Listbox",
|
|
74
82
|
component: Listbox,
|
|
75
83
|
parameters: { layout: "padded" },
|
|
76
|
-
argTypes: {
|
|
77
|
-
|
|
84
|
+
argTypes: {
|
|
85
|
+
itemSize: controlSizeArgType,
|
|
86
|
+
placement: placementArgType,
|
|
87
|
+
},
|
|
88
|
+
args: { itemSize: "md", placement: "bottom-start" },
|
|
78
89
|
} satisfies Meta<typeof Listbox>
|
|
79
90
|
|
|
80
91
|
export default meta
|
|
@@ -83,14 +94,20 @@ type Story = StoryObj<typeof meta>
|
|
|
83
94
|
|
|
84
95
|
const ListboxHarness = ({
|
|
85
96
|
initialSelected,
|
|
97
|
+
isInitiallyVisible = false,
|
|
98
|
+
itemSize,
|
|
86
99
|
options,
|
|
87
100
|
triggerLabel,
|
|
88
101
|
}: {
|
|
89
102
|
initialSelected?: string
|
|
103
|
+
isInitiallyVisible?: boolean
|
|
104
|
+
itemSize?: ControlSize
|
|
90
105
|
options: ListboxItem[]
|
|
91
106
|
triggerLabel: string
|
|
92
107
|
}): ReactNode => {
|
|
93
|
-
const { hide, isVisible, toggle } = useVisibility(
|
|
108
|
+
const { hide, isVisible, toggle } = useVisibility({
|
|
109
|
+
isVisible: isInitiallyVisible,
|
|
110
|
+
})
|
|
94
111
|
|
|
95
112
|
const [selected, setSelected] = useState<
|
|
96
113
|
string | undefined
|
|
@@ -111,6 +128,7 @@ const ListboxHarness = ({
|
|
|
111
128
|
return (
|
|
112
129
|
<Listbox
|
|
113
130
|
isVisible={isVisible}
|
|
131
|
+
itemSize={itemSize}
|
|
114
132
|
onDismiss={hide}
|
|
115
133
|
onSelect={setSelected}
|
|
116
134
|
options={options}
|
|
@@ -203,3 +221,61 @@ export const Interactive: Story = {
|
|
|
203
221
|
/>
|
|
204
222
|
),
|
|
205
223
|
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The three option sizes, open side by side.
|
|
227
|
+
*
|
|
228
|
+
* `md` is the default, and the reason is the report that started this
|
|
229
|
+
* work: an option that is smaller than the control which opened it
|
|
230
|
+
* reads as a different, lesser thing inside its own panel. `md` is
|
|
231
|
+
* `--control-height-md` — the same 2.25rem a default `Button` and a
|
|
232
|
+
* `Select` take — so a picker's options now measure the same as its
|
|
233
|
+
* trigger.
|
|
234
|
+
*
|
|
235
|
+
* `lg` is the fat one: 2.75rem, the 44px target, for a list that is
|
|
236
|
+
* aimed at rather than scanned. `sm` is what this component rendered
|
|
237
|
+
* before, kept for a genuinely dense list.
|
|
238
|
+
*
|
|
239
|
+
* The floor is a **minimum**, not a fixed height — the rich option
|
|
240
|
+
* below (flag, name, badge) still grows past it, which is why this is
|
|
241
|
+
* `min-h-` and a `Button`'s is `h-`.
|
|
242
|
+
*/
|
|
243
|
+
export const ItemSizes: Story = {
|
|
244
|
+
args: {
|
|
245
|
+
isVisible: false,
|
|
246
|
+
onDismiss: () => {},
|
|
247
|
+
onSelect: () => {},
|
|
248
|
+
options: LANGUAGES,
|
|
249
|
+
trigger: <Button>Language</Button>,
|
|
250
|
+
},
|
|
251
|
+
render: () => (
|
|
252
|
+
<StoryGrid columns={3}>
|
|
253
|
+
<StoryCell label="sm — the old option">
|
|
254
|
+
<ListboxHarness
|
|
255
|
+
isInitiallyVisible
|
|
256
|
+
itemSize="sm"
|
|
257
|
+
options={LANGUAGES}
|
|
258
|
+
triggerLabel="Language"
|
|
259
|
+
/>
|
|
260
|
+
</StoryCell>
|
|
261
|
+
|
|
262
|
+
<StoryCell label="md — the default, matches the trigger">
|
|
263
|
+
<ListboxHarness
|
|
264
|
+
isInitiallyVisible
|
|
265
|
+
itemSize="md"
|
|
266
|
+
options={LANGUAGES}
|
|
267
|
+
triggerLabel="Language"
|
|
268
|
+
/>
|
|
269
|
+
</StoryCell>
|
|
270
|
+
|
|
271
|
+
<StoryCell label="lg — the fat option (44px)">
|
|
272
|
+
<ListboxHarness
|
|
273
|
+
isInitiallyVisible
|
|
274
|
+
itemSize="lg"
|
|
275
|
+
options={LANGUAGES}
|
|
276
|
+
triggerLabel="Language"
|
|
277
|
+
/>
|
|
278
|
+
</StoryCell>
|
|
279
|
+
</StoryGrid>
|
|
280
|
+
),
|
|
281
|
+
}
|
package/src/Listbox/Listbox.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
useRovingFocus,
|
|
4
4
|
useSinglePicker,
|
|
5
5
|
} from "@charcuterie/logic"
|
|
6
|
+
import type { ControlSize } from "@charcuterie/tokens"
|
|
6
7
|
import type { Placement } from "@floating-ui/react"
|
|
7
8
|
import {
|
|
8
9
|
FloatingFocusManager,
|
|
@@ -13,6 +14,7 @@ import { useEffect, useRef } from "react"
|
|
|
13
14
|
|
|
14
15
|
import { PANEL_SURFACE_CLASS } from "../Overlay/overlayPanelClass.ts"
|
|
15
16
|
import { useAnchoredOverlay } from "../Overlay/useAnchoredOverlay.ts"
|
|
17
|
+
import { usePanelItemSize } from "../Overlay/usePanelItemSize.ts"
|
|
16
18
|
import { toClassName } from "../toClassName.ts"
|
|
17
19
|
import { ListboxOption } from "./ListboxOption.tsx"
|
|
18
20
|
|
|
@@ -31,6 +33,17 @@ export type ListboxItem = {
|
|
|
31
33
|
export type ListboxProps = {
|
|
32
34
|
className?: string
|
|
33
35
|
isVisible: boolean
|
|
36
|
+
/**
|
|
37
|
+
* How tall each option is, from the same density-aware tokens a
|
|
38
|
+
* `Button` reads — so a `md` option is exactly as tall as the `md`
|
|
39
|
+
* trigger that opened it. `lg` is the fat one: 2.75rem at
|
|
40
|
+
* `comfortable` density, and a bigger target to aim at.
|
|
41
|
+
*
|
|
42
|
+
* A short window steps it back down on its own
|
|
43
|
+
* (`usePanelItemSize`), so a `lg` list is never the reason the last
|
|
44
|
+
* option is off-screen.
|
|
45
|
+
*/
|
|
46
|
+
itemSize?: ControlSize
|
|
34
47
|
/** Outside press and Escape both land here. */
|
|
35
48
|
onDismiss: () => void
|
|
36
49
|
onSelect: (value: string) => void
|
|
@@ -79,6 +92,7 @@ const getOptionText = (item: ListboxItem) =>
|
|
|
79
92
|
export const Listbox = ({
|
|
80
93
|
className,
|
|
81
94
|
isVisible,
|
|
95
|
+
itemSize: requestedItemSize = "md",
|
|
82
96
|
onDismiss,
|
|
83
97
|
onSelect,
|
|
84
98
|
options,
|
|
@@ -86,6 +100,8 @@ export const Listbox = ({
|
|
|
86
100
|
selectedValue,
|
|
87
101
|
trigger,
|
|
88
102
|
}: ListboxProps): ReactNode => {
|
|
103
|
+
const itemSize = usePanelItemSize(requestedItemSize)
|
|
104
|
+
|
|
89
105
|
const optionElements = useRef(
|
|
90
106
|
new Map<string, HTMLButtonElement>(),
|
|
91
107
|
)
|
|
@@ -266,6 +282,7 @@ export const Listbox = ({
|
|
|
266
282
|
picker.selectedValue === option.value
|
|
267
283
|
}
|
|
268
284
|
item={option}
|
|
285
|
+
itemSize={itemSize}
|
|
269
286
|
key={option.value}
|
|
270
287
|
onSelect={chooseValue}
|
|
271
288
|
registerFocus={focus.register}
|