@charcuterie/ui 2.2.0 → 2.4.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.
Files changed (37) hide show
  1. package/dist/Combobox/Combobox.d.ts +22 -4
  2. package/dist/Combobox/Combobox.d.ts.map +1 -1
  3. package/dist/Combobox/Combobox.js +142 -24
  4. package/dist/Combobox/Combobox.js.map +1 -1
  5. package/dist/Combobox/ComboboxOption.d.ts.map +1 -1
  6. package/dist/Combobox/ComboboxOption.js +29 -2
  7. package/dist/Combobox/ComboboxOption.js.map +1 -1
  8. package/dist/EmptyState/EmptyState.d.ts +6 -1
  9. package/dist/EmptyState/EmptyState.d.ts.map +1 -1
  10. package/dist/EmptyState/EmptyState.js.map +1 -1
  11. package/dist/Listbox/ListboxOption.d.ts.map +1 -1
  12. package/dist/Listbox/ListboxOption.js +22 -2
  13. package/dist/Listbox/ListboxOption.js.map +1 -1
  14. package/dist/Overlay/useAnchoredOverlay.d.ts +17 -5
  15. package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -1
  16. package/dist/Overlay/useAnchoredOverlay.js +23 -3
  17. package/dist/Overlay/useAnchoredOverlay.js.map +1 -1
  18. package/dist/Select/Select.d.ts.map +1 -1
  19. package/dist/Select/Select.js +8 -2
  20. package/dist/Select/Select.js.map +1 -1
  21. package/dist/controlStyles.d.ts +8 -0
  22. package/dist/controlStyles.d.ts.map +1 -1
  23. package/dist/controlStyles.js +8 -0
  24. package/dist/controlStyles.js.map +1 -1
  25. package/package.json +2 -2
  26. package/src/Combobox/Combobox.mdx +30 -0
  27. package/src/Combobox/Combobox.stories.tsx +150 -5
  28. package/src/Combobox/Combobox.test.tsx +120 -1
  29. package/src/Combobox/Combobox.tsx +273 -72
  30. package/src/Combobox/ComboboxOption.tsx +30 -5
  31. package/src/EmptyState/EmptyState.stories.tsx +15 -0
  32. package/src/EmptyState/EmptyState.test.tsx +16 -1
  33. package/src/EmptyState/EmptyState.tsx +6 -1
  34. package/src/Listbox/ListboxOption.tsx +22 -4
  35. package/src/Overlay/useAnchoredOverlay.ts +49 -8
  36. package/src/Select/Select.tsx +6 -5
  37. package/src/controlStyles.ts +8 -0
@@ -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)"