@lovett/ui 0.0.10 → 0.0.11

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.
@@ -8,45 +8,75 @@
8
8
  * BUILD_FRESH per ADR-025 DEVIATION-3: source ships on
9
9
  * `@base-ui/react/menu`, which is NOT in CLAUDE.md §1 allowed deps.
10
10
  * The compound-component surface (Root / Trigger / Content / Item /
11
- * CheckboxItem / Separator / Label / Group) carries over; the
12
- * implementation is a lightweight React Context + Portal + outside-
13
- * click + Escape handler.
11
+ * CheckboxItem / Separator / Label / Group) carries over.
14
12
  *
15
- * Trigger styling per ADR-026 D4 (DQ-4 LIFT): the default trigger
16
- * inherits the secondary Button visual (rounded-xl, `--bg-card` fill,
17
- * 1px `--border`). Consumers can pass `asChild`-equivalent custom
18
- * trigger content; the wrapper applies the button-style classes
19
- * unless `unstyled` is set.
13
+ * Rebuilt on the shared anchored-positioning kit per ADR-0030 Decision G
14
+ * (meta-ads-audit-dashboard task manager; the workspace app's 137 call
15
+ * sites are the other consumer). Same public API, same DOM roles and
16
+ * `data-slot`s; what changed underneath:
17
+ * Positioning is `useAnchoredPosition` (lib/anchor.ts) — the menu now
18
+ * FLIPS to the other side when it cannot fit, instead of being clamped
19
+ * over its own trigger; `side` accepts `left` / `right` too.
20
+ * • Escape goes through the shared dismiss-layer stack (lib/layer-stack.ts)
21
+ * that Modal uses: a menu opened inside a Modal now closes on its own,
22
+ * leaving the Modal open. Before, both closed on one keypress.
23
+ * • Outside-click is `useOutsideClick`, layer-aware — a Select or Popover
24
+ * opened from a menu item is not "outside".
25
+ * • Trigger, Content, Item and CheckboxItem forward refs.
26
+ *
27
+ * Trigger styling per ADR-026 D4 (DQ-4 LIFT): the default trigger inherits
28
+ * the secondary Button visual (rounded-xl, `--bg-card` fill, 1px
29
+ * `--border`). Consumers can pass custom trigger content; the wrapper
30
+ * applies the button-style classes unless `unstyled` is set.
31
+ *
32
+ * Focus stays on the trigger when the menu opens (menus do not steal
33
+ * focus here; items are reached with Tab). Escape closes and refocuses the
34
+ * trigger. Roving arrow-key focus is a follow-up.
35
+ *
36
+ * Token discipline: surface chrome is `POPOVER_SURFACE_STYLE` (shared with
37
+ * Popover — internal `--popover` tokens are allowed inside packages/ui);
38
+ * item hover / focus use public `--surface-overlay`; motion is
39
+ * `.ds-enter-pop`, gated on `positioned` so it never plays from the parked
40
+ * off-screen frame.
20
41
  *
21
42
  * Workspace ADR: ADR-025 (port) + ADR-026 D4 (trigger spec)
22
- * Ported 2026-05-18
43
+ * Ported 2026-05-18 · Rebuilt on lib/anchor 2026-09-05
23
44
  */
24
45
 
25
46
  import {
26
47
  createContext,
48
+ forwardRef,
27
49
  useCallback,
28
50
  useContext,
29
- useEffect,
30
51
  useId,
31
- useLayoutEffect,
32
52
  useMemo,
33
53
  useRef,
34
54
  useState,
35
55
  type ButtonHTMLAttributes,
36
56
  type HTMLAttributes,
37
57
  type ReactNode,
58
+ type RefObject,
38
59
  } from 'react'
39
60
  import { createPortal } from 'react-dom'
40
61
  import { Check } from 'lucide-react'
41
62
  import { cn } from './lib/utils'
63
+ import { composeRefs } from './lib/refs'
64
+ import { useLayer } from './lib/layer-stack'
65
+ import {
66
+ useAnchoredPosition,
67
+ useOutsideClick,
68
+ type AnchorAlign,
69
+ type AnchorSide,
70
+ } from './lib/anchor'
71
+ import { POPOVER_SURFACE_STYLE } from './popover'
42
72
 
43
- type Align = 'start' | 'center' | 'end'
44
- type Side = 'top' | 'bottom'
73
+ type Align = AnchorAlign
74
+ type Side = AnchorSide
45
75
 
46
76
  interface DropdownMenuContextValue {
47
77
  open: boolean
48
78
  setOpen: (next: boolean) => void
49
- triggerRef: React.RefObject<HTMLButtonElement | null>
79
+ triggerRef: RefObject<HTMLButtonElement | null>
50
80
  contentId: string
51
81
  }
52
82
 
@@ -119,23 +149,28 @@ export interface DropdownMenuTriggerProps
119
149
  * secondary Button visual (rounded-xl + `--bg-card` fill + 1px
120
150
  * `--border`). Set `unstyled` to wrap your own button shape.
121
151
  */
122
- export function DropdownMenuTrigger({
123
- unstyled,
124
- className,
125
- onClick,
126
- children,
127
- ...rest
128
- }: DropdownMenuTriggerProps) {
152
+ export const DropdownMenuTrigger = forwardRef<
153
+ HTMLButtonElement,
154
+ DropdownMenuTriggerProps
155
+ >(function DropdownMenuTrigger(
156
+ { unstyled, className, onClick, children, ...rest },
157
+ forwardedRef,
158
+ ) {
129
159
  const { open, setOpen, triggerRef, contentId } =
130
160
  useDropdownMenuCtx('DropdownMenuTrigger')
161
+ const ref = useMemo(
162
+ () => composeRefs<HTMLButtonElement>(forwardedRef, triggerRef),
163
+ [forwardedRef, triggerRef],
164
+ )
131
165
 
132
166
  return (
133
167
  <button
134
- ref={triggerRef}
168
+ ref={ref}
135
169
  type="button"
136
170
  aria-haspopup="menu"
137
171
  aria-expanded={open}
138
172
  aria-controls={open ? contentId : undefined}
173
+ data-state={open ? 'open' : 'closed'}
139
174
  className={cn(
140
175
  !unstyled && 'btn btn-secondary',
141
176
  className,
@@ -149,7 +184,7 @@ export function DropdownMenuTrigger({
149
184
  {children}
150
185
  </button>
151
186
  )
152
- }
187
+ })
153
188
 
154
189
  export interface DropdownMenuContentProps
155
190
  extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
@@ -158,142 +193,98 @@ export interface DropdownMenuContentProps
158
193
  align?: Align
159
194
  /** Pixel offset along the alignment axis. Default 0. */
160
195
  alignOffset?: number
161
- /** Place above (`top`) or below (`bottom`) the trigger. Default `bottom`. */
196
+ /**
197
+ * Preferred side of the trigger. Default `bottom`. Flips to the opposite
198
+ * side when it cannot fit (before the 2026-09-05 rebuild it was clamped
199
+ * over its own trigger instead; only that no-room case differs).
200
+ */
162
201
  side?: Side
163
202
  /** Pixel offset along the side axis. Default 6. */
164
203
  sideOffset?: number
165
204
  }
166
205
 
167
- export function DropdownMenuContent({
168
- children,
169
- align = 'start',
170
- alignOffset = 0,
171
- side = 'bottom',
172
- sideOffset = 6,
173
- className,
174
- style,
175
- ...rest
176
- }: DropdownMenuContentProps) {
206
+ export const DropdownMenuContent = forwardRef<
207
+ HTMLDivElement,
208
+ DropdownMenuContentProps
209
+ >(function DropdownMenuContent(
210
+ {
211
+ children,
212
+ align = 'start',
213
+ alignOffset = 0,
214
+ side = 'bottom',
215
+ sideOffset = 6,
216
+ className,
217
+ style,
218
+ ...rest
219
+ },
220
+ forwardedRef,
221
+ ) {
177
222
  const { open, setOpen, triggerRef, contentId } =
178
223
  useDropdownMenuCtx('DropdownMenuContent')
179
224
  const contentRef = useRef<HTMLDivElement | null>(null)
180
- const [position, setPosition] = useState<{ left: number; top: number } | null>(
181
- null,
182
- )
183
-
184
- // Position relative to the trigger viewport rect. We measure on
185
- // open + on layout changes (scroll / resize) so the panel tracks.
186
- useLayoutEffect(() => {
187
- if (!open) {
188
- setPosition(null)
189
- return
190
- }
191
- const measure = () => {
192
- const triggerEl = triggerRef.current
193
- const contentEl = contentRef.current
194
- if (!triggerEl || !contentEl) return
195
- const triggerRect = triggerEl.getBoundingClientRect()
196
- const contentRect = contentEl.getBoundingClientRect()
197
-
198
- let left = triggerRect.left + alignOffset
199
- if (align === 'end') left = triggerRect.right - contentRect.width - alignOffset
200
- else if (align === 'center')
201
- left =
202
- triggerRect.left +
203
- triggerRect.width / 2 -
204
- contentRect.width / 2 +
205
- alignOffset
206
-
207
- let top =
208
- side === 'bottom'
209
- ? triggerRect.bottom + sideOffset
210
- : triggerRect.top - contentRect.height - sideOffset
211
225
 
212
- // Keep on-screen — clamp to viewport with an 8px gutter.
213
- const gutter = 8
214
- const maxLeft = window.innerWidth - contentRect.width - gutter
215
- const maxTop = window.innerHeight - contentRect.height - gutter
216
- left = Math.max(gutter, Math.min(left, maxLeft))
217
- top = Math.max(gutter, Math.min(top, maxTop))
218
-
219
- setPosition({ left, top })
220
- }
226
+ const {
227
+ ref: positionRef,
228
+ style: positionStyle,
229
+ placement,
230
+ positioned,
231
+ } = useAnchoredPosition<HTMLDivElement>({
232
+ anchorRef: triggerRef,
233
+ side,
234
+ align,
235
+ offset: sideOffset,
236
+ alignOffset,
237
+ enabled: open,
238
+ })
239
+
240
+ // Topmost-wins Escape, shared with Modal and Popover.
241
+ const layer = useLayer({
242
+ enabled: open,
243
+ kind: 'popover',
244
+ elementRef: contentRef,
245
+ onEscape: () => {
246
+ setOpen(false)
247
+ triggerRef.current?.focus()
248
+ },
249
+ })
221
250
 
222
- measure()
223
- window.addEventListener('resize', measure)
224
- window.addEventListener('scroll', measure, true)
225
- return () => {
226
- window.removeEventListener('resize', measure)
227
- window.removeEventListener('scroll', measure, true)
228
- }
229
- }, [open, align, alignOffset, side, sideOffset, triggerRef])
251
+ useOutsideClick([triggerRef, contentRef], () => setOpen(false), {
252
+ enabled: open,
253
+ layer,
254
+ })
230
255
 
231
- // Outside-click + Escape dismissal.
232
- useEffect(() => {
233
- if (!open) return
234
- const onPointerDown = (event: PointerEvent) => {
235
- const target = event.target as Node | null
236
- if (!target) return
237
- if (contentRef.current?.contains(target)) return
238
- if (triggerRef.current?.contains(target)) return
239
- setOpen(false)
240
- }
241
- const onKeyDown = (event: KeyboardEvent) => {
242
- if (event.key === 'Escape') {
243
- event.stopPropagation()
244
- setOpen(false)
245
- triggerRef.current?.focus()
246
- }
247
- }
248
- document.addEventListener('pointerdown', onPointerDown)
249
- document.addEventListener('keydown', onKeyDown)
250
- return () => {
251
- document.removeEventListener('pointerdown', onPointerDown)
252
- document.removeEventListener('keydown', onKeyDown)
253
- }
254
- }, [open, setOpen, triggerRef])
256
+ const ref = useMemo(
257
+ () => composeRefs<HTMLDivElement>(forwardedRef, contentRef, positionRef),
258
+ [forwardedRef, positionRef],
259
+ )
255
260
 
256
261
  if (!open || typeof document === 'undefined') return null
257
262
 
258
- // Two-phase render: first paint measures the content invisibly at
259
- // (0,0) with opacity:0, useLayoutEffect computes the real position,
260
- // and the second paint applies the correct coords + entrance animation.
261
- // The animation classes are gated behind `isPositioned` so the
262
- // entrance never fires from the unpositioned location — that's what
263
- // caused the "flash at top of viewport then jump under the trigger"
264
- // glitch.
265
- const isPositioned = position !== null
266
-
263
+ // Two-phase render (see lib/anchor.ts): the hook parks the first paint
264
+ // off-screen and hidden, measures, then applies real coordinates. The
265
+ // entrance animation is gated on `positioned` so it never fires from the
266
+ // parked location that was the "flash at top of viewport then jump
267
+ // under the trigger" glitch.
267
268
  return createPortal(
268
269
  <div
269
- ref={contentRef}
270
+ ref={ref}
270
271
  id={contentId}
271
272
  role="menu"
272
273
  data-slot="dropdown-menu-content"
273
274
  data-state="open"
274
- data-positioned={isPositioned ? 'true' : 'false'}
275
+ data-side={placement?.side}
276
+ data-align={placement?.align}
277
+ data-positioned={positioned ? 'true' : 'false'}
275
278
  className={cn(
276
279
  'fixed z-[100] min-w-48 max-w-[min(28rem,calc(100vw-16px))]',
277
280
  'p-1.5 overflow-y-auto',
278
281
  'rounded-[var(--radius-lg)]',
279
- isPositioned && 'ds-enter-pop',
282
+ positioned && 'ds-enter-pop',
280
283
  className,
281
284
  )}
282
285
  style={{
283
- // Anchor the unpositioned phase WAY off-screen as a defense
284
- // against any browser/renderer that doesn't fully respect
285
- // visibility:hidden during a same-frame double-render. If the
286
- // first paint slips through, it's at (-99999, -99999), not at
287
- // viewport (0, 0) where a left-edge flash would be visible.
288
- left: position?.left ?? -99999,
289
- top: position?.top ?? -99999,
290
- opacity: isPositioned ? undefined : 0,
291
- pointerEvents: isPositioned ? undefined : 'none',
292
- visibility: isPositioned ? 'visible' : 'hidden',
293
- background: 'rgb(var(--popover))',
294
- color: 'rgb(var(--popover-foreground))',
295
- border: '1px solid rgb(var(--border))',
296
- boxShadow: 'var(--shadow-lg, 0 10px 32px rgba(0,0,0,0.18))',
286
+ ...positionStyle,
287
+ ...POPOVER_SURFACE_STYLE,
297
288
  ...style,
298
289
  }}
299
290
  {...rest}
@@ -302,7 +293,7 @@ export function DropdownMenuContent({
302
293
  </div>,
303
294
  document.body,
304
295
  )
305
- }
296
+ })
306
297
 
307
298
  export interface DropdownMenuItemProps
308
299
  extends ButtonHTMLAttributes<HTMLButtonElement> {
@@ -312,17 +303,17 @@ export interface DropdownMenuItemProps
312
303
  inset?: boolean
313
304
  }
314
305
 
315
- export function DropdownMenuItem({
316
- variant = 'default',
317
- inset,
318
- className,
319
- onClick,
320
- children,
321
- ...rest
322
- }: DropdownMenuItemProps) {
306
+ export const DropdownMenuItem = forwardRef<
307
+ HTMLButtonElement,
308
+ DropdownMenuItemProps
309
+ >(function DropdownMenuItem(
310
+ { variant = 'default', inset, className, onClick, children, ...rest },
311
+ forwardedRef,
312
+ ) {
323
313
  const { setOpen } = useDropdownMenuCtx('DropdownMenuItem')
324
314
  return (
325
315
  <button
316
+ ref={forwardedRef}
326
317
  role="menuitem"
327
318
  type="button"
328
319
  data-slot="dropdown-menu-item"
@@ -348,7 +339,7 @@ export function DropdownMenuItem({
348
339
  {children}
349
340
  </button>
350
341
  )
351
- }
342
+ })
352
343
 
353
344
  export interface DropdownMenuCheckboxItemProps
354
345
  extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onChange'> {
@@ -356,17 +347,16 @@ export interface DropdownMenuCheckboxItemProps
356
347
  onCheckedChange?: (checked: boolean) => void
357
348
  }
358
349
 
359
- export function DropdownMenuCheckboxItem({
360
- checked,
361
- onCheckedChange,
362
- className,
363
- children,
364
- onClick,
365
- disabled,
366
- ...rest
367
- }: DropdownMenuCheckboxItemProps) {
350
+ export const DropdownMenuCheckboxItem = forwardRef<
351
+ HTMLButtonElement,
352
+ DropdownMenuCheckboxItemProps
353
+ >(function DropdownMenuCheckboxItem(
354
+ { checked, onCheckedChange, className, children, onClick, disabled, ...rest },
355
+ forwardedRef,
356
+ ) {
368
357
  return (
369
358
  <button
359
+ ref={forwardedRef}
370
360
  role="menuitemcheckbox"
371
361
  type="button"
372
362
  data-slot="dropdown-menu-checkbox-item"
@@ -399,7 +389,7 @@ export function DropdownMenuCheckboxItem({
399
389
  ) : null}
400
390
  </button>
401
391
  )
402
- }
392
+ })
403
393
 
404
394
  export function DropdownMenuSeparator({
405
395
  className,
package/src/index.ts CHANGED
@@ -172,6 +172,112 @@ export {
172
172
  type EditingCell,
173
173
  } from './data-grid'
174
174
 
175
+ // Anchored-positioning kit + dismiss-layer stack — promoted per ADR-0030
176
+ // Decision G (meta-ads-audit-dashboard task manager: its pickers, sheets
177
+ // and palette need one positioning routine and one Escape order). The
178
+ // workspace app is the other consumer: DropdownMenu (137 call sites),
179
+ // Modal (51 files) and Popover are rebuilt on these in the same change.
180
+ // `useAnchoredPosition` measures / flips / clamps against an element ref
181
+ // OR a virtual rect (ContextMenu's right-click point); `useOutsideClick`
182
+ // is layer-aware; `useLayer` / `useEscapeKey` are the topmost-wins stack
183
+ // extracted from Modal's `openPanels`. Select / Combobox / Tooltip /
184
+ // ContextMenu build on them next; FilterDropdown / FolderTreePicker /
185
+ // TagChipInput still carry local copies of the positioning block
186
+ // (follow-up).
187
+ export {
188
+ useAnchoredPosition,
189
+ computeAnchoredPosition,
190
+ useOutsideClick,
191
+ type AnchorSide,
192
+ type AnchorAlign,
193
+ type AnchorRect,
194
+ type AnchorSize,
195
+ type AnchorPlacement,
196
+ type AnchoredPosition,
197
+ type AnchoredPositionOptions,
198
+ type UseAnchoredPositionOptions,
199
+ type UseAnchoredPositionResult,
200
+ type OutsideClickTarget,
201
+ type UseOutsideClickOptions,
202
+ } from './lib/anchor'
203
+ export {
204
+ useLayer,
205
+ useEscapeKey,
206
+ type LayerKind,
207
+ type LayerHandle,
208
+ type UseLayerOptions,
209
+ type UseEscapeKeyOptions,
210
+ } from './lib/layer-stack'
211
+
212
+ // Popover — anchored, non-modal surface: portal to body, `.ds-enter-pop`
213
+ // once positioned, focus into the content on open and back to the trigger
214
+ // on close, Escape closes the topmost layer only, outside-click closes.
215
+ // Compound: Popover + Popover.Trigger (`asChild` to wrap a <Button> or a
216
+ // cell) + Popover.Content (`role="dialog"` or `"none"`). Promoted per
217
+ // ADR-0030 Decision G; the workspace app's three hand-rolled popovers
218
+ // (sidebar user menu, group-settings colour / icon pickers, calculator
219
+ // AssumptionsPopover) are the migration targets. DropdownMenu shares its
220
+ // surface chrome (`POPOVER_SURFACE_STYLE`) and stack, but stays a sibling
221
+ // because a menu does not move focus into itself on open.
222
+ export {
223
+ Popover,
224
+ POPOVER_SURFACE_STYLE,
225
+ type PopoverProps,
226
+ type PopoverTriggerProps,
227
+ type PopoverContentProps,
228
+ } from './popover'
229
+
230
+ // Select — single-value picker: an `.input-shell` trigger (sm / md, sits
231
+ // flush beside <Input>) that opens a real listbox (APG select-only
232
+ // combobox: role=combobox trigger, aria-activedescendant, role=listbox /
233
+ // option, arrow / Home / End / Enter / Space / type-ahead, disabled
234
+ // options, optional icon + description). Promoted per ADR-0030 Decision G
235
+ // — the meta-ads-audit-dashboard task manager's status / priority / role
236
+ // pickers; its Users page hand-rolled a native <select> with a comment
237
+ // saying the package ships none. The workspace app is the second consumer
238
+ // (26 native <select>s, products/product-form.tsx says the same). Built on
239
+ // useAnchoredPosition + the layer stack; shares the popover surface chrome.
240
+ // `ListboxOption` / `LISTBOX_CLASS` are the row + popup chrome Combobox
241
+ // reuses, exported for the same reason POPOVER_SURFACE_STYLE is.
242
+ export {
243
+ Select,
244
+ ListboxOption,
245
+ LISTBOX_CLASS,
246
+ type SelectProps,
247
+ type SelectOption,
248
+ type SelectSize,
249
+ type ListboxOptionProps,
250
+ } from './select'
251
+
252
+ // Combobox — text input + filtered listbox (APG editable combobox with list
253
+ // autocomplete), single or multi-select with chips, optional grouped options
254
+ // with headings, a per-option live `count` slot and a `renderOption` slot.
255
+ // Async-friendly: `shouldFilter={false}` + `loading` + `onInputValueChange`;
256
+ // it never fetches. Promoted per ADR-0030 Decision G — the task manager's
257
+ // row / card / context-menu / palette pickers are "popover + search + live
258
+ // counts". Second consumer is the workspace app
259
+ // (sem-spec/location-autocomplete.tsx hand-rolls the role; TagChipInput
260
+ // carries the chip model this generalises).
261
+ export {
262
+ Combobox,
263
+ type ComboboxProps,
264
+ type ComboboxSingleProps,
265
+ type ComboboxMultipleProps,
266
+ type ComboboxOption,
267
+ type ComboboxGroup,
268
+ type ComboboxOptionState,
269
+ type ComboboxSize,
270
+ } from './combobox'
271
+
272
+ // Tooltip — hover (300 ms) / focus (immediate) triggered role=tooltip with
273
+ // aria-describedby wiring, Escape-dismissable through the layer stack, never
274
+ // focusable, pointer-events none, token-only inline surface like Kbd.
275
+ // Promoted per ADR-0030 Decision G (task manager: truncated cells, icon-only
276
+ // row actions, timestamps). Second consumer is the workspace app, which
277
+ // hand-rolls two today (tools/_shared/calc/info-tip.tsx,
278
+ // components/ui/sidebar/rail-tooltip.tsx).
279
+ export { Tooltip, type TooltipProps, type TooltipTriggerProps } from './tooltip'
280
+
175
281
  // DropdownButton — Button-styled trigger + rich popover menu. Composed
176
282
  // over <DropdownMenu> primitives. First consumer: Keywords dashboard's
177
283
  // "New Keyword Plan" CTA (Set/Group picker). Reusable across other