@charcuterie/ui 3.17.0 → 3.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Combobox/Combobox.d.ts +14 -1
- package/dist/Combobox/Combobox.d.ts.map +1 -1
- package/dist/Combobox/Combobox.js +124 -56
- 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/MarkdownView.d.ts +24 -1
- package/dist/markdownEditorCodeMirror/MarkdownView.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/MarkdownView.js +14 -2
- package/dist/markdownEditorCodeMirror/MarkdownView.js.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreview.d.ts +7 -0
- package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreview.js +28 -2
- package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts +22 -2
- package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreviewRanges.js +26 -7
- package/dist/markdownEditorCodeMirror/livePreviewRanges.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 +2 -2
- package/src/Combobox/Combobox.mdx +26 -0
- package/src/Combobox/Combobox.stories.tsx +65 -0
- package/src/Combobox/Combobox.test.tsx +135 -0
- package/src/Combobox/Combobox.tsx +176 -83
- 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/MarkdownView.tsx +40 -1
- package/src/markdownEditorCodeMirror/livePreview.ts +40 -3
- package/src/markdownEditorCodeMirror/livePreviewRanges.test.ts +137 -0
- package/src/markdownEditorCodeMirror/livePreviewRanges.ts +50 -6
- 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.
|
|
@@ -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
|
})
|
|
@@ -286,32 +323,40 @@ export const Combobox = ({
|
|
|
286
323
|
|
|
287
324
|
const hasSeededOnOpen = useRef(false)
|
|
288
325
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
326
|
+
/**
|
|
327
|
+
* The open seed's outstanding scroll — the row to centre, and how many
|
|
328
|
+
* frames it has waited for a panel it can move.
|
|
329
|
+
*
|
|
330
|
+
* **State, and a fresh object on every open.** The obvious build hangs
|
|
331
|
+
* the scroll off `activeIndex` changing, and that is a bug the second
|
|
332
|
+
* time the same picker opens: `activeIndex` still holds the index the
|
|
333
|
+
* *last* open seeded, so setting it again is a no-op, React re-renders
|
|
334
|
+
* nothing, and the effect that would have scrolled never runs. The
|
|
335
|
+
* first open worked and every reopen showed the top of the list.
|
|
336
|
+
*
|
|
337
|
+
* A new object per open cannot be a no-op, so the scroll no longer
|
|
338
|
+
* depends on the highlight having moved.
|
|
339
|
+
*
|
|
340
|
+
* The wait exists because `useAnchoredOverlay` caps the panel's height
|
|
341
|
+
* by writing `style.maxHeight` straight onto the floating element
|
|
342
|
+
* inside floating-ui's `size` middleware — deliberately outside the
|
|
343
|
+
* `floatingStyles` React manages, so a keystroke cannot wipe it. It
|
|
344
|
+
* lands a frame after the seed and re-renders nothing to announce
|
|
345
|
+
* itself, and until it does the list stands at its full content height
|
|
346
|
+
* with **nothing to scroll**, so a scroll issued then is dropped
|
|
347
|
+
* without a trace.
|
|
348
|
+
*/
|
|
349
|
+
const [seedScroll, setSeedScroll] = useState<null | {
|
|
350
|
+
attempt: number
|
|
351
|
+
index: number
|
|
352
|
+
value: string
|
|
353
|
+
}>(null)
|
|
310
354
|
|
|
311
355
|
useEffect(() => {
|
|
312
356
|
if (!isVisible) {
|
|
313
357
|
hasSeededOnOpen.current = false
|
|
314
|
-
|
|
358
|
+
|
|
359
|
+
setSeedScroll(null)
|
|
315
360
|
|
|
316
361
|
return
|
|
317
362
|
}
|
|
@@ -337,9 +382,15 @@ export const Combobox = ({
|
|
|
337
382
|
|
|
338
383
|
hasSeededOnOpen.current = true
|
|
339
384
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
385
|
+
setSeedScroll({
|
|
386
|
+
attempt: 0,
|
|
387
|
+
index: openSeedIndex,
|
|
388
|
+
// Carried rather than looked up in the effect. `filtered` is a new
|
|
389
|
+
// array on every render, and as a dependency it would cancel and
|
|
390
|
+
// reschedule the pending frame each time — a wait that can never
|
|
391
|
+
// end while anything else re-renders.
|
|
392
|
+
value: seedValue,
|
|
393
|
+
})
|
|
343
394
|
|
|
344
395
|
setActiveIndex(openSeedIndex)
|
|
345
396
|
}, [
|
|
@@ -540,38 +591,41 @@ export const Combobox = ({
|
|
|
540
591
|
}
|
|
541
592
|
}
|
|
542
593
|
|
|
543
|
-
//
|
|
544
|
-
//
|
|
594
|
+
// The open seed's own scroll, centred, and the only one allowed to run
|
|
595
|
+
// until it lands — an arrow move's `block: "nearest"` would drag the
|
|
596
|
+
// list straight back off the chosen row.
|
|
545
597
|
useEffect(() => {
|
|
546
|
-
if (!isVisible) {
|
|
598
|
+
if (!isVisible || seedScroll === null) {
|
|
547
599
|
return
|
|
548
600
|
}
|
|
549
601
|
|
|
550
|
-
const
|
|
602
|
+
const list = listElement.current
|
|
551
603
|
|
|
552
|
-
if (
|
|
553
|
-
isSeeding &&
|
|
554
|
-
pendingSeedIndex.current !== resolvedActiveIndex
|
|
555
|
-
) {
|
|
556
|
-
// The seeded index has not reached this render yet. Scrolling the
|
|
557
|
-
// stale highlight now would drag the list off the chosen row.
|
|
604
|
+
if (list === null) {
|
|
558
605
|
return
|
|
559
606
|
}
|
|
560
607
|
|
|
561
|
-
const list = listElement.current
|
|
562
|
-
|
|
563
608
|
if (
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
list.scrollHeight <= list.clientHeight
|
|
609
|
+
seedScroll.attempt < SEED_SCROLL_ATTEMPTS &&
|
|
610
|
+
(seedScroll.attempt === 0 ||
|
|
611
|
+
list.scrollHeight <= list.clientHeight)
|
|
568
612
|
) {
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
//
|
|
613
|
+
// Never scroll on the opening commit. Two separate things are
|
|
614
|
+
// still missing then, and each drops the scroll silently: the
|
|
615
|
+
// panel has no height cap yet, so the list stands at full height
|
|
616
|
+
// with nothing to move; and the virtualizer measures its scroll
|
|
617
|
+
// element in an effect of its own, so `scrollToIndex` has nothing
|
|
618
|
+
// to measure against. One frame settles both.
|
|
619
|
+
//
|
|
620
|
+
// After that, keep waiting only while the list cannot move.
|
|
621
|
+
// Bounded, so a list that genuinely fits its panel stops instead
|
|
622
|
+
// of spinning.
|
|
573
623
|
const frame = requestAnimationFrame(() => {
|
|
574
|
-
|
|
624
|
+
setSeedScroll({
|
|
625
|
+
attempt: seedScroll.attempt + 1,
|
|
626
|
+
index: seedScroll.index,
|
|
627
|
+
value: seedScroll.value,
|
|
628
|
+
})
|
|
575
629
|
})
|
|
576
630
|
|
|
577
631
|
return () => {
|
|
@@ -579,17 +633,43 @@ export const Combobox = ({
|
|
|
579
633
|
}
|
|
580
634
|
}
|
|
581
635
|
|
|
582
|
-
|
|
636
|
+
// Centred, where an arrow move only pulls the next row just into
|
|
637
|
+
// view. "nearest" on open would park the chosen row against the
|
|
638
|
+
// panel's bottom edge with nothing after it, so the neighbours the
|
|
639
|
+
// list was reopened to reach would still be off screen.
|
|
640
|
+
if (isWindowed) {
|
|
641
|
+
rowVirtualizer.scrollToIndex(seedScroll.index, {
|
|
642
|
+
align: "center",
|
|
643
|
+
})
|
|
644
|
+
} else {
|
|
645
|
+
list
|
|
646
|
+
.querySelector(
|
|
647
|
+
`#${CSS.escape(`${listboxId}-opt-${seedScroll.value}`)}`,
|
|
648
|
+
)
|
|
649
|
+
?.scrollIntoView({ block: "center" })
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
setSeedScroll(null)
|
|
653
|
+
}, [
|
|
654
|
+
seedScroll,
|
|
655
|
+
isVisible,
|
|
656
|
+
isWindowed,
|
|
657
|
+
listboxId,
|
|
658
|
+
rowVirtualizer.scrollToIndex,
|
|
659
|
+
])
|
|
660
|
+
|
|
661
|
+
// Keep the active row in view — a real bug in every fleet picker:
|
|
662
|
+
// arrowing past the panel edge did not scroll.
|
|
663
|
+
useEffect(() => {
|
|
664
|
+
// While the seed is outstanding the row it wants is not on screen
|
|
665
|
+
// yet, and pulling the highlight into view with `nearest` would
|
|
666
|
+
// settle the list somewhere the seed then has to undo.
|
|
667
|
+
if (!isVisible || seedScroll !== null) {
|
|
668
|
+
return
|
|
669
|
+
}
|
|
583
670
|
|
|
584
|
-
// The open seed centres its row; an arrow move only pulls the next
|
|
585
|
-
// one just into view. "nearest" on open would park the chosen row
|
|
586
|
-
// against the panel's bottom edge with nothing after it, so the
|
|
587
|
-
// neighbours the list was reopened to reach stay off screen.
|
|
588
671
|
if (isWindowed) {
|
|
589
|
-
rowVirtualizer.scrollToIndex(
|
|
590
|
-
resolvedActiveIndex,
|
|
591
|
-
isSeeding ? { align: "center" } : undefined,
|
|
592
|
-
)
|
|
672
|
+
rowVirtualizer.scrollToIndex(resolvedActiveIndex)
|
|
593
673
|
|
|
594
674
|
return
|
|
595
675
|
}
|
|
@@ -599,9 +679,7 @@ export const Combobox = ({
|
|
|
599
679
|
?.querySelector(
|
|
600
680
|
`#${CSS.escape(`${listboxId}-opt-${activeValue}`)}`,
|
|
601
681
|
)
|
|
602
|
-
?.scrollIntoView({
|
|
603
|
-
block: isSeeding ? "center" : "nearest",
|
|
604
|
-
})
|
|
682
|
+
?.scrollIntoView({ block: "nearest" })
|
|
605
683
|
}
|
|
606
684
|
}, [
|
|
607
685
|
resolvedActiveIndex,
|
|
@@ -610,7 +688,7 @@ export const Combobox = ({
|
|
|
610
688
|
isWindowed,
|
|
611
689
|
listboxId,
|
|
612
690
|
rowVirtualizer.scrollToIndex,
|
|
613
|
-
|
|
691
|
+
seedScroll,
|
|
614
692
|
])
|
|
615
693
|
|
|
616
694
|
// ─── Attached mode: drive the consumer's input ──────────────────────
|
|
@@ -694,6 +772,7 @@ export const Combobox = ({
|
|
|
694
772
|
isActive={index === resolvedActiveIndex}
|
|
695
773
|
isSelected={selected.includes(option.value)}
|
|
696
774
|
item={option}
|
|
775
|
+
itemSize={itemSize}
|
|
697
776
|
key={option.value}
|
|
698
777
|
onSelect={commitValue}
|
|
699
778
|
posInSet={isWindowed ? index + 1 : undefined}
|
|
@@ -765,29 +844,43 @@ export const Combobox = ({
|
|
|
765
844
|
style={floatingStyles}
|
|
766
845
|
>
|
|
767
846
|
{isAttached ? null : (
|
|
768
|
-
//
|
|
769
|
-
//
|
|
770
|
-
//
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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>
|
|
791
884
|
</div>
|
|
792
885
|
)}
|
|
793
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}
|
|
@@ -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
|