@charcuterie/ui 2.2.0 → 2.3.0

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.
@@ -3,7 +3,12 @@ import {
3
3
  FloatingPortal,
4
4
  } from "@floating-ui/react"
5
5
  import { useVirtualizer } from "@tanstack/react-virtual"
6
- import type { ReactElement, ReactNode } from "react"
6
+ import type {
7
+ ReactElement,
8
+ KeyboardEvent as ReactKeyboardEvent,
9
+ ReactNode,
10
+ RefObject,
11
+ } from "react"
7
12
  import { useEffect, useRef, useState } from "react"
8
13
 
9
14
  import type { ListboxItem } from "../Listbox/Listbox.tsx"
@@ -20,6 +25,20 @@ export type ComboboxProps = {
20
25
  error?: ReactNode
21
26
  /** A sticky footer hint (`LinkPicker`'s "type a path directly"). */
22
27
  footer?: ReactNode
28
+ /**
29
+ * Attached-input mode. Supply the consumer's own `<input>` and
30
+ * Combobox stops rendering its internal input/chip bar: it anchors the
31
+ * popup to this element, drives arrow/Enter/Tab/Escape off it, and
32
+ * mirrors the active option onto it for a screen reader. The consumer
33
+ * owns the input's `value`/`onChange` (wire them to the controlled
34
+ * `query`, which is then required) **and** `isVisible` — so a select
35
+ * does NOT auto-dismiss in this mode; staying open (a folder-picker
36
+ * drilling in) or closing (`onSelect` calls the consumer's own close)
37
+ * is the consumer's call. Options arrive pre-filtered (as with
38
+ * `onQueryChange`). Single-select only; `isMultiple` is not supported
39
+ * here.
40
+ */
41
+ inputRef?: RefObject<HTMLInputElement | null>
23
42
  /** Enter commits the raw query as a value (`AssFieldPicker`). */
24
43
  isCreatable?: boolean
25
44
  /** The loading panel state. */
@@ -42,8 +61,12 @@ export type ComboboxProps = {
42
61
  /** Controlled query; uncontrolled when absent. */
43
62
  query?: string
44
63
  selectedValue?: readonly string[] | string
45
- /** The control the popup hangs off. **Cloned, not wrapped.** */
46
- trigger: ReactElement
64
+ /**
65
+ * The control the popup hangs off. **Cloned, not wrapped.** Omit only
66
+ * in attached-input mode (`inputRef`), where the consumer's `<input>`
67
+ * is the anchor.
68
+ */
69
+ trigger?: ReactElement
47
70
  }
48
71
 
49
72
  const AUTO_VIRTUALIZE_THRESHOLD = 100
@@ -94,6 +117,7 @@ export const Combobox = ({
94
117
  emptyLabel = "No matches",
95
118
  error,
96
119
  footer,
120
+ inputRef,
97
121
  isCreatable = false,
98
122
  isLoading = false,
99
123
  isMultiple = false,
@@ -108,7 +132,19 @@ export const Combobox = ({
108
132
  selectedValue,
109
133
  trigger,
110
134
  }: ComboboxProps): ReactNode => {
111
- const inputElement = useRef<HTMLInputElement>(null)
135
+ // Attached-input mode: the consumer's `<input>` is the reference,
136
+ // query source, focus holder and keyboard target; Combobox renders the
137
+ // list-only panel.
138
+ const isAttached = inputRef !== undefined
139
+
140
+ const internalInputElement =
141
+ useRef<HTMLInputElement>(null)
142
+
143
+ // The input Combobox drives — the consumer's in attached mode, its own
144
+ // otherwise.
145
+ const inputElement = isAttached
146
+ ? inputRef
147
+ : internalInputElement
112
148
 
113
149
  const listElement = useRef<HTMLDivElement>(null)
114
150
 
@@ -128,10 +164,11 @@ export const Combobox = ({
128
164
  : [...selectedValue],
129
165
  )
130
166
 
131
- // The consumer owns filtering when it owns the query; otherwise it is
132
- // a plain `textValue` contains-match.
167
+ // The consumer owns filtering when it owns the query or drives an
168
+ // attached input (the query is a full path, not an option-label match);
169
+ // otherwise it is a plain `textValue` contains-match.
133
170
  const filtered =
134
- onQueryChange === undefined
171
+ onQueryChange === undefined && !isAttached
135
172
  ? options.filter((option) =>
136
173
  getOptionText(option).includes(
137
174
  currentQuery.toLowerCase(),
@@ -151,6 +188,9 @@ export const Combobox = ({
151
188
  setFloating,
152
189
  triggerId,
153
190
  } = useAnchoredOverlay({
191
+ // Attached mode anchors to the consumer's input; classic mode clones
192
+ // the trigger.
193
+ anchorRef: isAttached ? inputRef : undefined,
154
194
  // Escape is handled here so it can clear the query before closing.
155
195
  isEscapeDismissable: false,
156
196
  isVisible,
@@ -158,7 +198,7 @@ export const Combobox = ({
158
198
  offsetValue: 4,
159
199
  onDismiss,
160
200
  role: "listbox",
161
- trigger,
201
+ trigger: isAttached ? undefined : trigger,
162
202
  })
163
203
 
164
204
  // The panel wraps an input *and* a listbox, so the wrapper is a
@@ -185,10 +225,21 @@ export const Combobox = ({
185
225
  }
186
226
 
187
227
  useEffect(() => {
188
- if (isVisible) {
228
+ // In attached mode focus stays where the user is typing (the
229
+ // consumer's input); Combobox never grabs it.
230
+ if (isVisible && !isAttached) {
189
231
  inputElement.current?.focus()
190
232
  }
191
- }, [isVisible])
233
+ }, [isVisible, isAttached, inputElement])
234
+
235
+ // Attached mode's query lives on the consumer's input, so `setQuery`
236
+ // (which reseeds the highlight) never runs on a keystroke — reseed here
237
+ // when the controlled query changes.
238
+ useEffect(() => {
239
+ if (isAttached) {
240
+ setActiveIndex(0)
241
+ }
242
+ }, [isAttached])
192
243
 
193
244
  const rowVirtualizer = useVirtualizer({
194
245
  count: filtered.length,
@@ -236,6 +287,22 @@ export const Combobox = ({
236
287
  return
237
288
  }
238
289
 
290
+ if (isAttached) {
291
+ // The consumer owns the value, the query and `isVisible`: report the
292
+ // pick and let it update the controlled query (→ refetch) and decide
293
+ // whether to stay open (drill-down) or close. No `onDismiss`, no
294
+ // `setQuery` (that would double-fire the consumer's query), no
295
+ // `setSelected` (the picked row leaves the list on refetch).
296
+ onSelect(value)
297
+
298
+ // Reseed the highlight for the list that is about to arrive.
299
+ setActiveIndex(0)
300
+
301
+ inputElement.current?.focus()
302
+
303
+ return
304
+ }
305
+
239
306
  setSelected([value])
240
307
 
241
308
  onSelect(value)
@@ -243,8 +310,42 @@ export const Combobox = ({
243
310
  onDismiss()
244
311
  }
245
312
 
313
+ // A chip's face is the option's human label, not its `value` — a
314
+ // language chip reads "English", not "eng". `textValue` (the rich-label
315
+ // fallback) wins, then a plain-string `label`, then the raw value only
316
+ // when the option has since left `options`.
317
+ const chipLabel = (value: string) => {
318
+ const item = options.find(
319
+ (option) => option.value === value,
320
+ )
321
+
322
+ if (item === undefined) {
323
+ return value
324
+ }
325
+
326
+ return (
327
+ item.textValue ??
328
+ (typeof item.label === "string" ? item.label : value)
329
+ )
330
+ }
331
+
332
+ const removeValue = (value: string) => {
333
+ setSelected((previous) =>
334
+ previous.filter((one) => one !== value),
335
+ )
336
+
337
+ // The parent tracks selection through `onSelect`, so removing a chip
338
+ // reports the same value the list toggle would — a symmetric toggle.
339
+ onSelect(value)
340
+ }
341
+
342
+ // Typed as either event so the same logic serves the classic mode's
343
+ // React `onKeyDown` and attached mode's native `keydown` listener —
344
+ // both carry `key`/`shiftKey`/`preventDefault`.
246
345
  const handleKeyDown = (
247
- keyEvent: React.KeyboardEvent<HTMLInputElement>,
346
+ keyEvent:
347
+ | ReactKeyboardEvent<HTMLInputElement>
348
+ | KeyboardEvent,
248
349
  ) => {
249
350
  if (keyEvent.key === "ArrowDown") {
250
351
  keyEvent.preventDefault()
@@ -299,6 +400,17 @@ export const Combobox = ({
299
400
  }
300
401
 
301
402
  if (keyEvent.key === "Escape") {
403
+ // Attached mode's query lives on the consumer's input, which
404
+ // `setQuery` cannot clear — so Escape closes outright (the
405
+ // `PathPicker` contract).
406
+ if (isAttached) {
407
+ keyEvent.preventDefault()
408
+
409
+ onDismiss()
410
+
411
+ return
412
+ }
413
+
302
414
  // Clears the query first, closes only when it is already empty.
303
415
  if (currentQuery.length > 0) {
304
416
  keyEvent.preventDefault()
@@ -341,6 +453,80 @@ export const Combobox = ({
341
453
  rowVirtualizer.scrollToIndex,
342
454
  ])
343
455
 
456
+ // ─── Attached mode: drive the consumer's input ──────────────────────
457
+ //
458
+ // Combobox does not render the input, so its keyboard handler and ARIA
459
+ // are applied to the consumer's element. A ref-to-latest keeps the
460
+ // native listener stable across keystrokes (the `PathPicker` pattern).
461
+ const handleKeyDownRef = useRef(handleKeyDown)
462
+ handleKeyDownRef.current = handleKeyDown
463
+
464
+ useEffect(() => {
465
+ if (!isAttached || !isVisible) {
466
+ return
467
+ }
468
+
469
+ const element = inputRef.current
470
+
471
+ if (!element) {
472
+ return
473
+ }
474
+
475
+ const listener = (keyEvent: KeyboardEvent) => {
476
+ handleKeyDownRef.current(keyEvent)
477
+ }
478
+
479
+ element.addEventListener("keydown", listener)
480
+
481
+ return () => {
482
+ element.removeEventListener("keydown", listener)
483
+ }
484
+ }, [isAttached, isVisible, inputRef])
485
+
486
+ // Mirror the combobox ARIA onto the consumer's input. It owns
487
+ // `value`/`onChange`; these attributes are React-unmanaged, so setting
488
+ // them imperatively is stable across the consumer's re-renders.
489
+ useEffect(() => {
490
+ if (!isAttached) {
491
+ return
492
+ }
493
+
494
+ const element = inputRef.current
495
+
496
+ if (!element) {
497
+ return
498
+ }
499
+
500
+ element.setAttribute("role", "combobox")
501
+ element.setAttribute("aria-autocomplete", "list")
502
+
503
+ if (isVisible) {
504
+ element.setAttribute("aria-expanded", "true")
505
+ element.setAttribute("aria-controls", listboxId)
506
+
507
+ if (activeValue === undefined) {
508
+ element.removeAttribute("aria-activedescendant")
509
+ } else {
510
+ // Inlined rather than via `optionDomId` so the effect depends on
511
+ // the stable `listboxId`, not a per-render closure.
512
+ element.setAttribute(
513
+ "aria-activedescendant",
514
+ `${listboxId}-opt-${activeValue}`,
515
+ )
516
+ }
517
+ } else {
518
+ element.setAttribute("aria-expanded", "false")
519
+ element.removeAttribute("aria-controls")
520
+ element.removeAttribute("aria-activedescendant")
521
+ }
522
+ }, [
523
+ isAttached,
524
+ isVisible,
525
+ activeValue,
526
+ listboxId,
527
+ inputRef,
528
+ ])
529
+
344
530
  const renderOption = (
345
531
  option: ListboxItem,
346
532
  index: number,
@@ -359,12 +545,47 @@ export const Combobox = ({
359
545
 
360
546
  return (
361
547
  <>
548
+ {/*
549
+ Multi-select chips live *here*, above the trigger and outside the
550
+ `isVisible` gate — so a picked value stays on screen as a removable
551
+ tag after the popup closes. The previous build rendered them inside
552
+ the panel, where they vanished with it and the field read as empty
553
+ (the "no way to see or remove what I picked" report). Attached mode
554
+ is single-select, so it never grows a chip row.
555
+ */}
556
+ {isMultiple && !isAttached && selected.length > 0 ? (
557
+ <div className="mb-1 flex flex-wrap items-center gap-1">
558
+ {selected.map((value) => (
559
+ <span
560
+ className="inline-flex items-center gap-1 rounded-sm bg-intent-neutral-surface-hover px-1.5 py-0.5 text-content-primary text-xs"
561
+ key={value}
562
+ >
563
+ {chipLabel(value)}
564
+
565
+ <button
566
+ aria-label={`Remove ${chipLabel(value)}`}
567
+ className="cursor-pointer text-content-secondary hover:text-content-primary"
568
+ onClick={() => {
569
+ removeValue(value)
570
+ }}
571
+ type="button"
572
+ >
573
+ ✕
574
+ </button>
575
+ </span>
576
+ ))}
577
+ </div>
578
+ ) : null}
579
+
362
580
  {clonedTrigger}
363
581
 
364
582
  {isVisible ? (
365
583
  <FloatingPortal>
366
584
  <FloatingFocusManager
367
585
  context={context}
586
+ // Attached mode keeps focus in the consumer's input — the
587
+ // panel must never grab it.
588
+ disabled={isAttached}
368
589
  initialFocus={inputElement}
369
590
  modal={false}
370
591
  >
@@ -378,69 +599,42 @@ export const Combobox = ({
378
599
  ref={setFloating}
379
600
  style={floatingStyles}
380
601
  >
381
- <div className="flex flex-wrap items-center gap-1 border-border-subtle border-b p-2">
382
- {isMultiple
383
- ? selected.map((value) => {
384
- const chip = options.find(
385
- (option) => option.value === value,
386
- )
387
-
388
- return (
389
- <span
390
- className="inline-flex items-center gap-1 rounded-sm bg-intent-neutral-surface px-1.5 py-0.5 text-content-primary text-xs"
391
- key={value}
392
- >
393
- {chip?.textValue ?? value}
394
-
395
- <button
396
- aria-label={`Remove ${chip?.textValue ?? value}`}
397
- className="cursor-pointer text-content-secondary hover:text-content-primary"
398
- onClick={() => {
399
- setSelected((previous) =>
400
- previous.filter(
401
- (one) => one !== value,
402
- ),
403
- )
404
-
405
- // Mirror the list toggle-off: the parent
406
- // tracks selection through `onSelect`, so a
407
- // chip removal must report the same value.
408
- onSelect(value)
409
- }}
410
- type="button"
411
- >
412
- ✕
413
- </button>
414
- </span>
415
- )
416
- })
417
- : null}
418
-
419
- <input
420
- aria-activedescendant={
421
- activeValue === undefined
422
- ? undefined
423
- : optionDomId(activeValue)
424
- }
425
- aria-autocomplete="list"
426
- aria-controls={listboxId}
427
- aria-expanded="true"
428
- aria-labelledby={triggerId}
429
- className="min-w-24 flex-1 bg-transparent text-content-primary text-sm outline-none placeholder:text-content-muted"
430
- onChange={(changeEvent) => {
431
- setQuery(changeEvent.target.value)
432
- }}
433
- onKeyDown={handleKeyDown}
434
- placeholder={placeholder}
435
- ref={inputElement}
436
- role="combobox"
437
- type="text"
438
- value={currentQuery}
439
- />
440
- </div>
602
+ {isAttached ? null : (
603
+ <div className="flex items-center gap-1 border-border-subtle border-b p-2">
604
+ <input
605
+ aria-activedescendant={
606
+ activeValue === undefined
607
+ ? undefined
608
+ : optionDomId(activeValue)
609
+ }
610
+ aria-autocomplete="list"
611
+ aria-controls={listboxId}
612
+ aria-expanded="true"
613
+ aria-labelledby={triggerId}
614
+ className="min-w-24 flex-1 bg-transparent text-content-primary text-sm outline-none placeholder:text-content-muted"
615
+ onChange={(changeEvent) => {
616
+ setQuery(changeEvent.target.value)
617
+ }}
618
+ onKeyDown={handleKeyDown}
619
+ placeholder={placeholder}
620
+ ref={internalInputElement}
621
+ role="combobox"
622
+ type="text"
623
+ value={currentQuery}
624
+ />
625
+ </div>
626
+ )}
441
627
 
442
628
  <div
443
- aria-labelledby={triggerId}
629
+ // Classic mode names the listbox after its trigger;
630
+ // attached mode has no rendered trigger to point at, so it
631
+ // carries its own label.
632
+ aria-label={
633
+ isAttached ? "Suggestions" : undefined
634
+ }
635
+ aria-labelledby={
636
+ isAttached ? undefined : triggerId
637
+ }
444
638
  className="min-h-0 flex-1 overflow-auto p-1"
445
639
  id={listboxId}
446
640
  ref={listElement}
@@ -53,9 +53,28 @@ export const ComboboxOption = ({
53
53
  aria-selected={isSelected}
54
54
  aria-setsize={setSize}
55
55
  className={toClassName(
56
- "flex w-full cursor-pointer items-center justify-between gap-2 rounded-sm bg-transparent px-2 py-1.5 text-start text-content-primary text-sm",
57
- isActive && "bg-intent-neutral-surface",
58
- isSelected && "bg-intent-accent-surface",
56
+ // No base `bg-transparent`: it is a plain `background-color`
57
+ // utility at the same specificity as the state tints below, and
58
+ // Tailwind emits it *after* them, so it silently won every row —
59
+ // the active option and the selected option both rendered with no
60
+ // fill and the keyboard cursor was invisible (it read as "arrows
61
+ // do nothing"). A button's background is transparent by default;
62
+ // the tint classes only ever add one.
63
+ "flex w-full cursor-pointer items-center justify-between gap-2 rounded-sm px-2 py-1.5 text-start text-content-primary text-sm",
64
+ // The row highlight sits on `surface-overlay`, and on that panel
65
+ // `intent-neutral-surface` is *darker* than the surface in every
66
+ // dark scheme (it is a base-surface tint) — so the visible token
67
+ // is `-hover`. Active is the keyboard cursor; hover is the
68
+ // pointer's; both use it.
69
+ !isDisabled &&
70
+ "hover:bg-intent-neutral-surface-hover",
71
+ isActive && "bg-intent-neutral-surface-hover",
72
+ // The accent tint marks selection when the cursor is elsewhere;
73
+ // the active row keeps its own highlight (the ✓ still marks it as
74
+ // selected), so the two are never fighting for one row's fill.
75
+ isSelected &&
76
+ !isActive &&
77
+ "bg-intent-accent-surface",
59
78
  isDisabled &&
60
79
  "cursor-not-allowed text-content-disabled",
61
80
  )}
@@ -69,11 +69,19 @@ export const ListboxOption = ({
69
69
  <button
70
70
  aria-selected={isSelected}
71
71
  className={toClassName(
72
- "flex w-full cursor-pointer items-center justify-between gap-2 rounded-sm bg-transparent px-2 py-1.5 text-start text-content-primary text-sm transition-colors duration-(--duration-fast) ease-standard",
73
- "hover:bg-intent-neutral-surface",
72
+ // No base `bg-transparent`: it is emitted after the state tints
73
+ // and silently won at equal specificity, so the selected option's
74
+ // accent fill never showed (only its ✓ did). Default is
75
+ // transparent anyway; the tints below add a fill.
76
+ "flex w-full cursor-pointer items-center justify-between gap-2 rounded-sm px-2 py-1.5 text-start text-content-primary text-sm transition-colors duration-(--duration-fast) ease-standard",
77
+ // `-hover`, not plain `intent-neutral-surface`: on the
78
+ // `surface-overlay` panel the plain tint is darker than the
79
+ // surface in dark schemes and the hover reads as no change.
80
+ !isDisabled &&
81
+ "hover:bg-intent-neutral-surface-hover",
74
82
  isSelected && "bg-intent-accent-surface",
75
83
  isDisabled &&
76
- "cursor-not-allowed text-content-disabled hover:bg-transparent",
84
+ "cursor-not-allowed text-content-disabled",
77
85
  FOCUS_RING_CLASS,
78
86
  )}
79
87
  disabled={isDisabled}
@@ -14,7 +14,8 @@ import {
14
14
  useInteractions,
15
15
  useRole,
16
16
  } from "@floating-ui/react"
17
- import type { ReactElement } from "react"
17
+ import type { ReactElement, RefObject } from "react"
18
+ import { createElement, useEffect } from "react"
18
19
 
19
20
  /**
20
21
  * The floating-ui block every anchored overlay — `Popover`, `Menu`,
@@ -67,6 +68,15 @@ export type AnchoredOverlayRole =
67
68
  | "menu"
68
69
 
69
70
  export type UseAnchoredOverlayOptions = {
71
+ /**
72
+ * Anchor the panel to a **pre-existing** element the consumer already
73
+ * rendered, instead of cloning a `trigger`. For the attached-input
74
+ * `Combobox`, whose reference is the consumer's own `<input>` (both the
75
+ * value and the query) — there is nothing to clone. Supply exactly one
76
+ * of `anchorRef` / `trigger`; with `anchorRef`, `clonedTrigger` is
77
+ * `null`.
78
+ */
79
+ anchorRef?: RefObject<HTMLElement | null>
70
80
  /**
71
81
  * Both default **on** — Escape and outside-press are what makes an
72
82
  * overlay an overlay. Switch one off for the honest exception (a
@@ -86,11 +96,21 @@ export type UseAnchoredOverlayOptions = {
86
96
  onDismiss: () => void
87
97
  placement?: Placement
88
98
  role: AnchoredOverlayRole
89
- /** The control the panel hangs off. **Cloned, not wrapped.** */
90
- trigger: ReactElement
99
+ /**
100
+ * The control the panel hangs off. **Cloned, not wrapped.** Omit only
101
+ * when `anchorRef` is supplied instead.
102
+ */
103
+ trigger?: ReactElement
91
104
  }
92
105
 
106
+ // `useClonedChild` insists on exactly one element, and hooks cannot be
107
+ // called conditionally — so in `anchorRef` mode we still call it, on this
108
+ // throwaway, and discard the result. It is never rendered and never
109
+ // receives the floating reference (that goes to `anchorRef`).
110
+ const PLACEHOLDER_TRIGGER = createElement("span")
111
+
93
112
  export const useAnchoredOverlay = ({
113
+ anchorRef,
94
114
  isEscapeDismissable = true,
95
115
  isOutsidePressDismissable = true,
96
116
  isTriggerWidthMatched = false,
@@ -128,6 +148,8 @@ export const useAnchoredOverlay = ({
128
148
  )
129
149
  }
130
150
 
151
+ const isAnchored = anchorRef !== undefined
152
+
131
153
  const { context, floatingStyles, refs } = useFloating({
132
154
  // Read-only. Charcuterie remains the sole owner; floating-ui is
133
155
  // told what is true and never decides it.
@@ -180,11 +202,30 @@ export const useAnchoredOverlay = ({
180
202
  (referenceProps.id as string | undefined) ??
181
203
  generatedTriggerId
182
204
 
183
- const clonedTrigger = useClonedChild(trigger, {
184
- ...referenceProps,
185
- id: triggerId,
186
- ref: refs.setReference,
187
- })
205
+ // In anchor mode floating-ui's reference is the consumer's element, set
206
+ // imperatively — there is no cloned trigger to carry `refs.setReference`.
207
+ // `setReference` is stable; re-running on `isVisible` re-applies it when
208
+ // the panel opens (and after the anchor element mounts).
209
+ useEffect(() => {
210
+ if (!isAnchored) {
211
+ return
212
+ }
213
+
214
+ refs.setReference(anchorRef.current)
215
+ }, [isAnchored, anchorRef, refs])
216
+
217
+ const clonedChild = useClonedChild(
218
+ trigger ?? PLACEHOLDER_TRIGGER,
219
+ {
220
+ ...referenceProps,
221
+ id: triggerId,
222
+ // The anchor element already exists; the clone (a discarded
223
+ // placeholder here) must not also claim the floating reference.
224
+ ...(isAnchored ? {} : { ref: refs.setReference }),
225
+ },
226
+ )
227
+
228
+ const clonedTrigger = isAnchored ? null : clonedChild
188
229
 
189
230
  return {
190
231
  clonedTrigger,
@@ -5,10 +5,7 @@ import type {
5
5
  ReactNode,
6
6
  } from "react"
7
7
 
8
- import {
9
- CONTROL_SIZE_CLASS,
10
- MIN_TOUCH_TARGET_CLASS,
11
- } from "../controlStyles.ts"
8
+ import { CONTROL_SIZE_CLASS } from "../controlStyles.ts"
12
9
  import {
13
10
  DISABLED_CLASS,
14
11
  FOCUS_RING_CLASS,
@@ -139,8 +136,12 @@ export const Select = ({
139
136
  // removes, and forgetting to put one back leaves a control
140
137
  // that looks exactly like a text input.
141
138
  "pe-9",
139
+ // Height comes from the shared control-size/density system ONLY —
140
+ // no per-component touch-target floor. Touch is the density axis's
141
+ // job (`[data-density]` scales `--control-height-*` past 44px on a
142
+ // kiosk), so forcing 44px here made Select taller than Button et al.
143
+ // at desktop density. See the controls-share-one-height decision.
142
144
  CONTROL_SIZE_CLASS[size],
143
- MIN_TOUCH_TARGET_CLASS,
144
145
  "hover:border-border-strong",
145
146
  FOCUS_RING_CLASS,
146
147
  DISABLED_CLASS,
@@ -46,6 +46,14 @@ export const ICON_CONTROL_SIZE_CLASS: Record<
46
46
  * density — a finger does not get smaller because the list is
47
47
  * dense — so this is opt-in per control rather than baked into the
48
48
  * size classes, which would make `compact` a lie.
49
+ *
50
+ * **Do NOT add this to a control that shares a form row** (`Button`,
51
+ * `Select`, a `Listbox`/`Combobox` trigger, `SegmentedControl`, …). It
52
+ * overrides the shared `CONTROL_SIZE_CLASS` height and makes that one
53
+ * control taller than its neighbours at desktop density — touch sizing is
54
+ * the density axis's job. Reserve this for a genuinely standalone tap
55
+ * target. See
56
+ * `docs/decisions/2026-08-05-controls-share-one-height-no-per-component-touch-floor.md`.
49
57
  */
50
58
  export const MIN_TOUCH_TARGET_CLASS =
51
59
  "min-h-(--control-min-touch-target) min-w-(--control-min-touch-target)"