@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,3 +1,4 @@
|
|
|
1
|
+
import type { ControlSize } from "@charcuterie/tokens"
|
|
1
2
|
import {
|
|
2
3
|
FloatingFocusManager,
|
|
3
4
|
FloatingPortal,
|
|
@@ -11,9 +12,11 @@ import type {
|
|
|
11
12
|
} from "react"
|
|
12
13
|
import { useEffect, useRef, useState } from "react"
|
|
13
14
|
|
|
15
|
+
import { PANEL_ITEM_SIZE_CLASS } from "../controlStyles.ts"
|
|
14
16
|
import type { ListboxItem } from "../Listbox/Listbox.tsx"
|
|
15
17
|
import { PANEL_SURFACE_CLASS } from "../Overlay/overlayPanelClass.ts"
|
|
16
18
|
import { useAnchoredOverlay } from "../Overlay/useAnchoredOverlay.ts"
|
|
19
|
+
import { usePanelItemSize } from "../Overlay/usePanelItemSize.ts"
|
|
17
20
|
import { toClassName } from "../toClassName.ts"
|
|
18
21
|
import { ComboboxOption } from "./ComboboxOption.tsx"
|
|
19
22
|
|
|
@@ -48,6 +51,18 @@ export type ComboboxProps = {
|
|
|
48
51
|
/** Windowing. Auto-on above ~100 options when left undefined. */
|
|
49
52
|
isVirtualized?: boolean
|
|
50
53
|
isVisible: boolean
|
|
54
|
+
/**
|
|
55
|
+
* How tall each option — and the search field above them — is, from
|
|
56
|
+
* the same density-aware tokens a `Button` reads.
|
|
57
|
+
*
|
|
58
|
+
* It covers the search row deliberately. The owner's report was that
|
|
59
|
+
* the panel's own field was visibly bigger than the options under it
|
|
60
|
+
* (*"the button is huge, but the options don't match it"*), and two
|
|
61
|
+
* sizes inside one panel is the defect. One prop drives both.
|
|
62
|
+
*
|
|
63
|
+
* A short window steps it back down on its own (`usePanelItemSize`).
|
|
64
|
+
*/
|
|
65
|
+
itemSize?: ControlSize
|
|
51
66
|
onDismiss: () => void
|
|
52
67
|
onSelect: (value: string) => void
|
|
53
68
|
/**
|
|
@@ -71,6 +86,20 @@ export type ComboboxProps = {
|
|
|
71
86
|
|
|
72
87
|
const AUTO_VIRTUALIZE_THRESHOLD = 100
|
|
73
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The virtualizer's pre-measurement guess per row size, in px at
|
|
91
|
+
* `comfortable` density — `--control-height-*` plus the row's own
|
|
92
|
+
* `py-*`. Hardcoded because a virtualizer wants a number before the
|
|
93
|
+
* first paint, which is the one place a token cannot reach; every row
|
|
94
|
+
* is measured immediately afterwards, so a density that disagrees
|
|
95
|
+
* costs a frame rather than a wrong layout.
|
|
96
|
+
*/
|
|
97
|
+
const ROW_ESTIMATE_PX: Record<ControlSize, number> = {
|
|
98
|
+
sm: 40,
|
|
99
|
+
md: 48,
|
|
100
|
+
lg: 60,
|
|
101
|
+
}
|
|
102
|
+
|
|
74
103
|
/**
|
|
75
104
|
* How many frames the open seed will wait for the panel to be capped
|
|
76
105
|
* before it gives up on centring the chosen row. A few is all
|
|
@@ -130,6 +159,7 @@ export const Combobox = ({
|
|
|
130
159
|
isMultiple = false,
|
|
131
160
|
isVirtualized,
|
|
132
161
|
isVisible,
|
|
162
|
+
itemSize: requestedItemSize = "md",
|
|
133
163
|
onDismiss,
|
|
134
164
|
onQueryChange,
|
|
135
165
|
onSelect,
|
|
@@ -139,6 +169,8 @@ export const Combobox = ({
|
|
|
139
169
|
selectedValue,
|
|
140
170
|
trigger,
|
|
141
171
|
}: ComboboxProps): ReactNode => {
|
|
172
|
+
const itemSize = usePanelItemSize(requestedItemSize)
|
|
173
|
+
|
|
142
174
|
// Attached-input mode: the consumer's `<input>` is the reference,
|
|
143
175
|
// query source, focus holder and keyboard target; Combobox renders the
|
|
144
176
|
// list-only panel.
|
|
@@ -155,6 +187,26 @@ export const Combobox = ({
|
|
|
155
187
|
|
|
156
188
|
const listElement = useRef<HTMLDivElement>(null)
|
|
157
189
|
|
|
190
|
+
/**
|
|
191
|
+
* The same node as `listElement`, held in state as well as a ref.
|
|
192
|
+
*
|
|
193
|
+
* The panel's children mount a render *later* than `isVisible` flips —
|
|
194
|
+
* floating-ui's `FloatingPortal` renders nothing until its portal node
|
|
195
|
+
* exists — so an effect keyed on `isVisible` runs while the ref is
|
|
196
|
+
* still null. Anything that has to touch the list the moment it
|
|
197
|
+
* appears keys on this instead.
|
|
198
|
+
*/
|
|
199
|
+
const [listNode, setListNode] =
|
|
200
|
+
useState<HTMLDivElement | null>(null)
|
|
201
|
+
|
|
202
|
+
const attachListElement = (
|
|
203
|
+
node: HTMLDivElement | null,
|
|
204
|
+
) => {
|
|
205
|
+
listElement.current = node
|
|
206
|
+
|
|
207
|
+
setListNode(node)
|
|
208
|
+
}
|
|
209
|
+
|
|
158
210
|
const isQueryControlled = query !== undefined
|
|
159
211
|
|
|
160
212
|
const [internalQuery, setInternalQuery] = useState("")
|
|
@@ -254,7 +306,12 @@ export const Combobox = ({
|
|
|
254
306
|
|
|
255
307
|
const rowVirtualizer = useVirtualizer({
|
|
256
308
|
count: filtered.length,
|
|
257
|
-
|
|
309
|
+
// An *estimate*: every row is re-measured (`measureElement` on the
|
|
310
|
+
// wrapper below), so this only decides how far the scrollbar lies
|
|
311
|
+
// before the first measurement lands. It still has to track
|
|
312
|
+
// `itemSize`, or a `lg` list opens with a scrollbar a third too
|
|
313
|
+
// short and visibly settles.
|
|
314
|
+
estimateSize: () => ROW_ESTIMATE_PX[itemSize],
|
|
258
315
|
getScrollElement: () => listElement.current,
|
|
259
316
|
overscan: 8,
|
|
260
317
|
})
|
|
@@ -284,6 +341,49 @@ export const Combobox = ({
|
|
|
284
341
|
!option.isDisabled,
|
|
285
342
|
)
|
|
286
343
|
|
|
344
|
+
/**
|
|
345
|
+
* The virtualizer outlives the panel, and its scroll offset does not
|
|
346
|
+
* come back with it.
|
|
347
|
+
*
|
|
348
|
+
* `Combobox` stays mounted while the list element is created and
|
|
349
|
+
* destroyed on every open, so `useVirtualizer` keeps the offset from
|
|
350
|
+
* the last time the list was on screen. The freshly mounted element is
|
|
351
|
+
* at 0. The window it renders is then the one for a scroll position
|
|
352
|
+
* that no longer exists: a blank band down the top of the panel, with
|
|
353
|
+
* the rows for the old offset stranded below it.
|
|
354
|
+
*
|
|
355
|
+
* Re-sync once per open, before the seed's own scroll (which lands a
|
|
356
|
+
* frame later, so it still wins where there is a chosen row to centre).
|
|
357
|
+
*/
|
|
358
|
+
const hasResyncedVirtualizer = useRef(false)
|
|
359
|
+
|
|
360
|
+
useEffect(() => {
|
|
361
|
+
if (!isVisible) {
|
|
362
|
+
hasResyncedVirtualizer.current = false
|
|
363
|
+
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (
|
|
368
|
+
hasResyncedVirtualizer.current ||
|
|
369
|
+
!isWindowed ||
|
|
370
|
+
listNode === null
|
|
371
|
+
) {
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
hasResyncedVirtualizer.current = true
|
|
376
|
+
|
|
377
|
+
// A `scroll` event, because that is the only thing the virtualizer
|
|
378
|
+
// listens to. `observeOffset` attaches its listener and never reads
|
|
379
|
+
// the element it just attached to, so a replaced element sitting at 0
|
|
380
|
+
// cannot correct an instance that still believes it is at 500 —
|
|
381
|
+
// nothing moves, so nothing fires. `scrollToOffset(0)` does not help
|
|
382
|
+
// for the same reason: it asks for a position the element already
|
|
383
|
+
// holds, which raises no event either.
|
|
384
|
+
listNode.dispatchEvent(new Event("scroll"))
|
|
385
|
+
}, [isVisible, isWindowed, listNode])
|
|
386
|
+
|
|
287
387
|
const hasSeededOnOpen = useRef(false)
|
|
288
388
|
|
|
289
389
|
/**
|
|
@@ -735,6 +835,7 @@ export const Combobox = ({
|
|
|
735
835
|
isActive={index === resolvedActiveIndex}
|
|
736
836
|
isSelected={selected.includes(option.value)}
|
|
737
837
|
item={option}
|
|
838
|
+
itemSize={itemSize}
|
|
738
839
|
key={option.value}
|
|
739
840
|
onSelect={commitValue}
|
|
740
841
|
posInSet={isWindowed ? index + 1 : undefined}
|
|
@@ -806,29 +907,43 @@ export const Combobox = ({
|
|
|
806
907
|
style={floatingStyles}
|
|
807
908
|
>
|
|
808
909
|
{isAttached ? null : (
|
|
809
|
-
//
|
|
810
|
-
//
|
|
811
|
-
//
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
910
|
+
// The `p-1` is the list container's own padding, repeated
|
|
911
|
+
// here so the search caret starts exactly where the option
|
|
912
|
+
// labels below it do — the alignment a hand-picked `px-3`
|
|
913
|
+
// used to approximate, and which broke the moment the row
|
|
914
|
+
// padding became a token. The border stays on the outer
|
|
915
|
+
// element, so it still spans the whole panel.
|
|
916
|
+
<div className="border-border-subtle border-b p-1">
|
|
917
|
+
<div
|
|
918
|
+
className={toClassName(
|
|
919
|
+
"flex items-center",
|
|
920
|
+
PANEL_ITEM_SIZE_CLASS[itemSize],
|
|
921
|
+
)}
|
|
922
|
+
>
|
|
923
|
+
<input
|
|
924
|
+
aria-activedescendant={
|
|
925
|
+
activeDescendantId
|
|
926
|
+
}
|
|
927
|
+
aria-autocomplete="list"
|
|
928
|
+
aria-controls={listboxId}
|
|
929
|
+
aria-expanded="true"
|
|
930
|
+
aria-labelledby={triggerId}
|
|
931
|
+
// No `text-*` of its own: the wrapper's row-size
|
|
932
|
+
// class sets the font size, and the input inherits
|
|
933
|
+
// it — so the query reads at exactly the size of
|
|
934
|
+
// the options it is filtering.
|
|
935
|
+
className="min-w-24 flex-1 bg-transparent text-content-primary outline-none placeholder:text-content-muted"
|
|
936
|
+
onChange={(changeEvent) => {
|
|
937
|
+
setQuery(changeEvent.target.value)
|
|
938
|
+
}}
|
|
939
|
+
onKeyDown={handleKeyDown}
|
|
940
|
+
placeholder={placeholder}
|
|
941
|
+
ref={internalInputElement}
|
|
942
|
+
role="combobox"
|
|
943
|
+
type="text"
|
|
944
|
+
value={currentQuery}
|
|
945
|
+
/>
|
|
946
|
+
</div>
|
|
832
947
|
</div>
|
|
833
948
|
)}
|
|
834
949
|
|
|
@@ -844,7 +959,7 @@ export const Combobox = ({
|
|
|
844
959
|
}
|
|
845
960
|
className="min-h-0 flex-1 overflow-auto p-1"
|
|
846
961
|
id={listboxId}
|
|
847
|
-
ref={
|
|
962
|
+
ref={attachListElement}
|
|
848
963
|
role="listbox"
|
|
849
964
|
>
|
|
850
965
|
{isLoading ? (
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import type { ControlSize } from "@charcuterie/tokens"
|
|
1
2
|
import type { ReactNode } from "react"
|
|
2
3
|
|
|
4
|
+
import { PANEL_ITEM_SIZE_CLASS } from "../controlStyles.ts"
|
|
3
5
|
import type { ListboxItem } from "../Listbox/Listbox.tsx"
|
|
4
6
|
import { toClassName } from "../toClassName.ts"
|
|
5
7
|
|
|
@@ -8,6 +10,8 @@ export type ComboboxOptionProps = {
|
|
|
8
10
|
id: string
|
|
9
11
|
isActive: boolean
|
|
10
12
|
isSelected: boolean
|
|
13
|
+
/** Already stepped down for a short window by `usePanelItemSize`. */
|
|
14
|
+
itemSize: ControlSize
|
|
11
15
|
item: ListboxItem
|
|
12
16
|
onSelect: (value: string) => void
|
|
13
17
|
/**
|
|
@@ -41,6 +45,7 @@ export const ComboboxOption = ({
|
|
|
41
45
|
isActive,
|
|
42
46
|
isSelected,
|
|
43
47
|
item,
|
|
48
|
+
itemSize,
|
|
44
49
|
onSelect,
|
|
45
50
|
posInSet,
|
|
46
51
|
setSize,
|
|
@@ -60,7 +65,12 @@ export const ComboboxOption = ({
|
|
|
60
65
|
// fill and the keyboard cursor was invisible (it read as "arrows
|
|
61
66
|
// do nothing"). A button's background is transparent by default;
|
|
62
67
|
// the tint classes only ever add one.
|
|
63
|
-
"flex w-full cursor-pointer items-center justify-between
|
|
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}
|
|
@@ -1,12 +1,16 @@
|
|
|
1
|
+
import type { ControlSize } from "@charcuterie/tokens"
|
|
1
2
|
import type { ReactNode } from "react"
|
|
2
3
|
import { useEffect } from "react"
|
|
3
4
|
|
|
5
|
+
import { PANEL_ITEM_SIZE_CLASS } from "../controlStyles.ts"
|
|
4
6
|
import { FOCUS_RING_CLASS } from "../intentStyles.ts"
|
|
5
7
|
import { toClassName } from "../toClassName.ts"
|
|
6
8
|
import type { ListboxItem } from "./Listbox.tsx"
|
|
7
9
|
|
|
8
10
|
export type ListboxOptionProps = {
|
|
9
11
|
isSelected: boolean
|
|
12
|
+
/** Already stepped down for a short window by `usePanelItemSize`. */
|
|
13
|
+
itemSize: ControlSize
|
|
10
14
|
item: ListboxItem
|
|
11
15
|
onSelect: (value: string) => void
|
|
12
16
|
/** `RovingFocus.register` — membership of the arrow-key group. */
|
|
@@ -44,6 +48,7 @@ export type ListboxOptionProps = {
|
|
|
44
48
|
export const ListboxOption = ({
|
|
45
49
|
isSelected,
|
|
46
50
|
item,
|
|
51
|
+
itemSize,
|
|
47
52
|
onSelect,
|
|
48
53
|
registerFocus,
|
|
49
54
|
registerSelection,
|
|
@@ -73,7 +78,11 @@ export const ListboxOption = ({
|
|
|
73
78
|
// and silently won at equal specificity, so the selected option's
|
|
74
79
|
// accent fill never showed (only its ✓ did). Default is
|
|
75
80
|
// transparent anyway; the tints below add a fill.
|
|
76
|
-
"flex w-full cursor-pointer items-center justify-between
|
|
81
|
+
"flex w-full cursor-pointer items-center justify-between rounded-sm text-start transition-colors duration-(--duration-fast) ease-standard",
|
|
82
|
+
// Height, inline padding, gap and type from the row-size system,
|
|
83
|
+
// so an option is at least as tall as the control that opened it
|
|
84
|
+
// and a rich two-line label still grows past that floor.
|
|
85
|
+
PANEL_ITEM_SIZE_CLASS[itemSize],
|
|
77
86
|
// The base sets no text colour: a plain `text-content-primary` in
|
|
78
87
|
// it clobbers a conditional `text-content-disabled` at equal
|
|
79
88
|
// specificity (Tailwind emits the base last), so a disabled option
|
package/src/Menu/Menu.mdx
CHANGED
|
@@ -122,3 +122,52 @@ an anchored panel and an unanchored one land in the same place, and the test ass
|
|
|
122
122
|
padding is still there before it asserts anything else.
|
|
123
123
|
|
|
124
124
|
<Canvas of={MenuStories.SharedTrigger} />
|
|
125
|
+
|
|
126
|
+
## The row is a click target, so it is `lg` by default
|
|
127
|
+
|
|
128
|
+
`itemSize` takes the same `sm` / `md` / `lg` a `Button` takes, from the same
|
|
129
|
+
density-aware `--control-height-*` tokens — so a `md` menu item and a `md` `Button`
|
|
130
|
+
measure the same, and the `kiosk` density grows both together.
|
|
131
|
+
|
|
132
|
+
It **defaults to `lg`**, which nothing else in the library does. The asymmetry is the
|
|
133
|
+
decision rather than an oversight: a menu item is a *pointer* target before it is a list
|
|
134
|
+
row. It is read once and then aimed at, usually in a hurry, and a menu rarely holds more
|
|
135
|
+
than about eight items — so it can spend the height a dense list cannot. At `comfortable`
|
|
136
|
+
density `lg` is 2.75rem, which is also the 44px WCAG 2.5.5 target; on the `kiosk` density
|
|
137
|
+
it is 3.75rem with no prop change.
|
|
138
|
+
|
|
139
|
+
The floor is a **minimum**, not a fixed height (`min-h-`, where a `Button` uses `h-`), so a
|
|
140
|
+
long label that wraps to a second line still grows past it.
|
|
141
|
+
|
|
142
|
+
<Canvas of={MenuStories.ItemSizes} />
|
|
143
|
+
|
|
144
|
+
## A short window steps the size back down, and the panel scrolls
|
|
145
|
+
|
|
146
|
+
Two separate protections, because they answer different failures.
|
|
147
|
+
|
|
148
|
+
A window shorter than **40rem** drops every panel row one step, and one shorter than
|
|
149
|
+
**30rem** takes them to `sm` — so a `lg` menu is never the reason its own last item is off
|
|
150
|
+
the screen. That is `usePanelItemSize`, a `matchMedia` read rather than a CSS media query:
|
|
151
|
+
`text-sm` → `text-md` is a Tailwind bridge onto `--font-size-*` rather than a variable a
|
|
152
|
+
media query can reassign, so stepping the whole `ControlSize` in JS is what keeps one
|
|
153
|
+
literal class string per size. It costs a re-render on resize, which is not the axis the
|
|
154
|
+
library keeps as a repaint.
|
|
155
|
+
|
|
156
|
+
Independently, the panel is now clamped to the space the viewport actually left it and
|
|
157
|
+
scrolls inside that. A menu had **no** clamp before — survivable at a 32px row, and not at
|
|
158
|
+
44px, where nine items are 400px and the panel simply ran off the bottom of the screen with
|
|
159
|
+
no way to reach the last one.
|
|
160
|
+
|
|
161
|
+
## A long label wraps, rather than pushing the panel off screen
|
|
162
|
+
|
|
163
|
+
A portalled panel is `position: fixed`, so its shrink-to-fit width stops at the
|
|
164
|
+
**viewport** — not at the space `shift` left it. A label long enough to reach that cap
|
|
165
|
+
therefore produced a menu exactly as wide as the window, positioned 8px in, with 8px of
|
|
166
|
+
itself off the right edge: clipped and unreachable. Measured at 390px.
|
|
167
|
+
|
|
168
|
+
That was pre-existing and size-independent, but a `lg` row's larger type is what makes a
|
|
169
|
+
real label reach it, so the clamp lands in the same change. `isWidthClamped` caps the panel
|
|
170
|
+
at floating-ui's shifted `availableWidth`, and the label wraps inside its row — which is
|
|
171
|
+
also why a row's height is a `min-h-` rather than an `h-`.
|
|
172
|
+
|
|
173
|
+
<Canvas of={MenuStories.LongLabel} />
|