@lovett/ui 0.0.9 → 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.
Files changed (55) hide show
  1. package/dist/index.d.ts +823 -135
  2. package/dist/index.js +2048 -358
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +44 -2
  5. package/dist/theme-v2.css +228 -0
  6. package/dist/tokens.css +123 -8
  7. package/package.json +1 -1
  8. package/src/__tests__/anchor.test.tsx +422 -0
  9. package/src/__tests__/combobox.test.tsx +677 -0
  10. package/src/__tests__/dropdown-menu.test.tsx +418 -0
  11. package/src/__tests__/helpers/geometry.ts +58 -0
  12. package/src/__tests__/layer-stack.test.tsx +228 -0
  13. package/src/__tests__/modal.test.tsx +180 -6
  14. package/src/__tests__/popover.test.tsx +460 -0
  15. package/src/__tests__/select.test.tsx +543 -0
  16. package/src/__tests__/tooltip.test.tsx +355 -0
  17. package/src/calculator-shell-v2.tsx +19 -39
  18. package/src/code-block.tsx +15 -26
  19. package/src/combobox.tsx +796 -0
  20. package/src/dropdown-menu.tsx +142 -152
  21. package/src/icons/brand.tsx +81 -2
  22. package/src/index.ts +111 -0
  23. package/src/lib/anchor.ts +427 -0
  24. package/src/lib/focus.ts +32 -0
  25. package/src/lib/layer-stack.ts +188 -0
  26. package/src/lib/refs.ts +31 -0
  27. package/src/metric-card.tsx +57 -22
  28. package/src/modal.tsx +149 -9
  29. package/src/page-shell.tsx +91 -2
  30. package/src/popover.tsx +407 -0
  31. package/src/segmented-pill.tsx +33 -10
  32. package/src/select.tsx +646 -0
  33. package/src/stat-row.tsx +108 -70
  34. package/src/styles.css +44 -2
  35. package/src/theme-v2.css +7 -245
  36. package/src/tokens.css +123 -8
  37. package/src/tooltip.tsx +297 -0
  38. package/src/react-syntax-highlighter-prism.d.ts +0 -34
  39. package/src/v2/README.md +0 -208
  40. package/src/v2/__demo__/showcase.tsx +0 -1045
  41. package/src/v2/action.tsx +0 -91
  42. package/src/v2/callout.tsx +0 -76
  43. package/src/v2/document-section.tsx +0 -82
  44. package/src/v2/document-shell.tsx +0 -0
  45. package/src/v2/field-row.tsx +0 -113
  46. package/src/v2/icons.tsx +0 -165
  47. package/src/v2/index.ts +0 -147
  48. package/src/v2/layout.tsx +0 -293
  49. package/src/v2/progress-track.tsx +0 -89
  50. package/src/v2/stat-tile.tsx +0 -129
  51. package/src/v2/states.tsx +0 -271
  52. package/src/v2/status-pill.tsx +0 -74
  53. package/src/v2/theme.css +0 -1861
  54. package/src/v2/timeline.tsx +0 -81
  55. package/src/v2/tokens.ts +0 -228
@@ -0,0 +1,407 @@
1
+ /**
2
+ * Popover — an anchored, non-modal floating surface with focus management.
3
+ *
4
+ * Compound: `<Popover>` (state) + `<Popover.Trigger>` + `<Popover.Content>`.
5
+ * Controlled (`open` + `onOpenChange`) or uncontrolled (`defaultOpen`).
6
+ * Content portals to `document.body`, positions through
7
+ * `useAnchoredPosition` (flip + clamp), enters with `.ds-enter-pop` once
8
+ * positioned, and registers on the shared dismiss-layer stack — so Escape
9
+ * closes ONLY the topmost surface (a Popover inside a Modal closes before
10
+ * the Modal does) and an outside pointerdown closes it unless the pointer
11
+ * landed in a layer stacked above (a Select opened from inside it).
12
+ *
13
+ * Focus: opening moves focus into the content — the `[data-autofocus]`
14
+ * control if one is marked, else the first tabbable, else the content
15
+ * itself (`tabIndex={-1}`) — once the content is POSITIONED, not on the
16
+ * first frame: that frame is parked off-screen with `visibility: hidden`,
17
+ * and no browser will focus into a hidden subtree (the call is a silent
18
+ * no-op). Closing returns focus to the trigger, EXCEPT
19
+ * after an outside click, where the user has already put focus where they
20
+ * want it and yanking it back to the trigger would be wrong.
21
+ *
22
+ * Promoted per ADR-0030 Decision G (meta-ads-audit-dashboard task manager:
23
+ * the row / card / context-menu / palette pickers all sit in a popover with
24
+ * search + live counts). Second consumer is the workspace app, which has
25
+ * three hand-rolled popovers today (sidebar user menu, group-settings
26
+ * colour and icon pickers, CalculatorShell's AssumptionsPopover) — each a
27
+ * migration target. DropdownMenu stays a sibling on the same hook and stack
28
+ * rather than a Popover child: a menu does NOT move focus into itself on
29
+ * open, and 137 call sites depend on that.
30
+ *
31
+ * Trigger is an unstyled `<button type="button">` by default — Popover is a
32
+ * generic surface, the caller chooses the trigger's look. Pass `asChild` to
33
+ * make the single child element the trigger instead (a `<Button>`, a table
34
+ * cell, a chip): it receives the ref, the click handler and the ARIA
35
+ * attributes, and the DOM stays one element. The child must be keyboard-
36
+ * operable ON ITS OWN: Trigger adds ARIA + click only, no role, tabIndex or
37
+ * key handler. A native `<button>` / `<a href>` already is; a `<td>` or
38
+ * `<span>` needs `role="button"`, `tabIndex={0}` and its own Enter / Space
39
+ * handling, or only mouse users can open the popover.
40
+ *
41
+ * Content is `role="dialog"` (non-modal — no `aria-modal`) and needs a name
42
+ * from the caller (`aria-label` / `aria-labelledby`). Pass `role="none"`
43
+ * when the content IS the widget (a listbox, a menu) and would otherwise
44
+ * nest roles.
45
+ *
46
+ * Token discipline: the surface reads the INTERNAL `--popover` /
47
+ * `--popover-foreground` (allowed in packages/ui, ESLint-enforced elsewhere)
48
+ * plus public `--border`, `--shadow-lg`, `--radius-lg`. Geometry comes from
49
+ * the hook; motion from `.ds-enter-pop` (`--dur-fast` / `--ease-out`, honours
50
+ * prefers-reduced-motion in styles.css). No hex, no raw px in classes.
51
+ *
52
+ * Usage:
53
+ *
54
+ * <Popover>
55
+ * <Popover.Trigger asChild>
56
+ * <Button variant="secondary">Assignee</Button>
57
+ * </Popover.Trigger>
58
+ * <Popover.Content aria-label="Pick an assignee" align="start">
59
+ * <input data-autofocus placeholder="Search people…" />
60
+ * …
61
+ * </Popover.Content>
62
+ * </Popover>
63
+ *
64
+ * // Virtual anchor (ContextMenu later): no Trigger, position at a point.
65
+ * <Popover open={!!point} onOpenChange={…} anchorRect={point}>
66
+ * <Popover.Content role="none">…</Popover.Content>
67
+ * </Popover>
68
+ */
69
+
70
+ import {
71
+ cloneElement,
72
+ createContext,
73
+ forwardRef,
74
+ isValidElement,
75
+ useCallback,
76
+ useContext,
77
+ useEffect,
78
+ useId,
79
+ useMemo,
80
+ useRef,
81
+ useState,
82
+ type ButtonHTMLAttributes,
83
+ type CSSProperties,
84
+ type HTMLAttributes,
85
+ type MouseEvent as ReactMouseEvent,
86
+ type ReactNode,
87
+ type Ref,
88
+ type RefObject,
89
+ } from 'react'
90
+ import { createPortal } from 'react-dom'
91
+ import { cn } from './lib/utils'
92
+ import { composeRefs } from './lib/refs'
93
+ import { tabbablesWithin } from './lib/focus'
94
+ import { useLayer } from './lib/layer-stack'
95
+ import {
96
+ useAnchoredPosition,
97
+ useOutsideClick,
98
+ type AnchorAlign,
99
+ type AnchorRect,
100
+ type AnchorSide,
101
+ } from './lib/anchor'
102
+
103
+ /**
104
+ * The popover surface chrome, shared with DropdownMenuContent so a menu and
105
+ * a popover read as the same family. Inline rather than a stylesheet class
106
+ * because the surface tokens are internal and the primitives already own
107
+ * them here (Kbd, DropdownMenu follow the same pattern).
108
+ */
109
+ export const POPOVER_SURFACE_STYLE: CSSProperties = {
110
+ background: 'rgb(var(--popover))',
111
+ color: 'rgb(var(--popover-foreground))',
112
+ border: '1px solid rgb(var(--border))',
113
+ boxShadow: 'var(--shadow-lg)',
114
+ }
115
+
116
+ interface PopoverContextValue {
117
+ open: boolean
118
+ setOpen: (next: boolean) => void
119
+ triggerRef: RefObject<HTMLElement | null>
120
+ contentId: string
121
+ anchorRect: AnchorRect | null
122
+ /** Set when the close came from an outside pointerdown; skips focus return. */
123
+ interactedOutsideRef: RefObject<boolean>
124
+ }
125
+
126
+ const PopoverCtx = createContext<PopoverContextValue | null>(null)
127
+
128
+ function usePopoverCtx(consumer: string): PopoverContextValue {
129
+ const ctx = useContext(PopoverCtx)
130
+ if (!ctx) {
131
+ throw new Error(
132
+ `${consumer} must be rendered inside <Popover>. ` +
133
+ `Wrap your trigger + content with the Popover root component.`,
134
+ )
135
+ }
136
+ return ctx
137
+ }
138
+
139
+ export interface PopoverProps {
140
+ /** Controlled open state. Omit for internal state. */
141
+ open?: boolean
142
+ defaultOpen?: boolean
143
+ /** Fires whenever open transitions. */
144
+ onOpenChange?: (open: boolean) => void
145
+ /**
146
+ * Virtual anchor. When non-null, Content positions against this rect
147
+ * instead of the Trigger — a right-click point, a caret, a cell.
148
+ */
149
+ anchorRect?: AnchorRect | null
150
+ children: ReactNode
151
+ }
152
+
153
+ function PopoverRoot({
154
+ open: controlledOpen,
155
+ defaultOpen,
156
+ onOpenChange,
157
+ anchorRect = null,
158
+ children,
159
+ }: PopoverProps) {
160
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(Boolean(defaultOpen))
161
+ const isControlled = controlledOpen !== undefined
162
+ const open = isControlled ? Boolean(controlledOpen) : uncontrolledOpen
163
+ const triggerRef = useRef<HTMLElement | null>(null)
164
+ const interactedOutsideRef = useRef(false)
165
+ const generatedId = useId()
166
+ const contentId = `popover-${generatedId.replace(/:/g, '')}`
167
+
168
+ const setOpen = useCallback(
169
+ (next: boolean) => {
170
+ if (!isControlled) setUncontrolledOpen(next)
171
+ onOpenChange?.(next)
172
+ },
173
+ [isControlled, onOpenChange],
174
+ )
175
+
176
+ const value = useMemo<PopoverContextValue>(
177
+ () => ({ open, setOpen, triggerRef, contentId, anchorRect, interactedOutsideRef }),
178
+ [open, setOpen, contentId, anchorRect],
179
+ )
180
+
181
+ return <PopoverCtx.Provider value={value}>{children}</PopoverCtx.Provider>
182
+ }
183
+
184
+ export interface PopoverTriggerProps extends ButtonHTMLAttributes<HTMLButtonElement> {
185
+ /**
186
+ * Render the single child element as the trigger instead of a `<button>`.
187
+ * The child receives the ref, click handler and ARIA attributes; the DOM
188
+ * stays one element. It must be focusable and keyboard-operable on its
189
+ * own (a `<button>`, an `<a href>`, or `role="button"` + `tabIndex={0}` +
190
+ * Enter / Space handling): Trigger adds no role, tabIndex or key handler.
191
+ */
192
+ asChild?: boolean
193
+ }
194
+
195
+ type TriggerChildProps = HTMLAttributes<HTMLElement> & {
196
+ ref?: Ref<HTMLElement>
197
+ 'data-state'?: string
198
+ }
199
+
200
+ const PopoverTrigger = forwardRef<HTMLButtonElement, PopoverTriggerProps>(
201
+ function PopoverTrigger({ asChild, onClick, children, ...rest }, forwardedRef) {
202
+ const { open, setOpen, triggerRef, contentId } = usePopoverCtx('Popover.Trigger')
203
+
204
+ const ariaProps = {
205
+ 'aria-haspopup': 'dialog',
206
+ 'aria-expanded': open,
207
+ 'aria-controls': open ? contentId : undefined,
208
+ 'data-state': open ? 'open' : 'closed',
209
+ } as const
210
+
211
+ if (asChild) {
212
+ if (!isValidElement<TriggerChildProps>(children)) {
213
+ throw new Error('Popover.Trigger with `asChild` expects exactly one element child.')
214
+ }
215
+ const child = children
216
+ const childOnClick = child.props.onClick
217
+ return cloneElement(child, {
218
+ ...rest,
219
+ ...ariaProps,
220
+ ref: composeRefs<HTMLElement>(child.props.ref, forwardedRef, triggerRef),
221
+ onClick: (event: ReactMouseEvent<HTMLElement>) => {
222
+ childOnClick?.(event)
223
+ onClick?.(event as ReactMouseEvent<HTMLButtonElement>)
224
+ if (!event.defaultPrevented) setOpen(!open)
225
+ },
226
+ })
227
+ }
228
+
229
+ return (
230
+ <button
231
+ ref={composeRefs<HTMLElement>(forwardedRef, triggerRef)}
232
+ type="button"
233
+ {...ariaProps}
234
+ onClick={(event) => {
235
+ onClick?.(event)
236
+ if (!event.defaultPrevented) setOpen(!open)
237
+ }}
238
+ {...rest}
239
+ >
240
+ {children}
241
+ </button>
242
+ )
243
+ },
244
+ )
245
+
246
+ export interface PopoverContentProps
247
+ extends Omit<HTMLAttributes<HTMLDivElement>, 'role' | 'children'> {
248
+ children: ReactNode
249
+ /** Preferred side of the anchor. Default `bottom`. Flips when it cannot fit. */
250
+ side?: AnchorSide
251
+ /** Alignment along the anchor's cross axis. Default `start`. */
252
+ align?: AnchorAlign
253
+ /** Gap from the anchor, px. Default 6. */
254
+ offset?: number
255
+ /** Shift along the alignment axis, px. Default 0. */
256
+ alignOffset?: number
257
+ /** Default true. */
258
+ flip?: boolean
259
+ /** Default true. */
260
+ clampToViewport?: boolean
261
+ /**
262
+ * `dialog` (default) — a named, non-modal dialog; give it `aria-label` or
263
+ * `aria-labelledby`. `none` — no role attribute, for content that is
264
+ * itself the widget.
265
+ */
266
+ role?: 'dialog' | 'none'
267
+ /** Skip the surface chrome (background / border / shadow / radius / padding). */
268
+ unstyled?: boolean
269
+ }
270
+
271
+ const PopoverContent = forwardRef<HTMLDivElement, PopoverContentProps>(
272
+ function PopoverContent(
273
+ {
274
+ children,
275
+ side = 'bottom',
276
+ align = 'start',
277
+ offset = 6,
278
+ alignOffset = 0,
279
+ flip = true,
280
+ clampToViewport = true,
281
+ role = 'dialog',
282
+ unstyled,
283
+ className,
284
+ style,
285
+ ...rest
286
+ },
287
+ forwardedRef,
288
+ ) {
289
+ const { open, setOpen, triggerRef, contentId, anchorRect, interactedOutsideRef } =
290
+ usePopoverCtx('Popover.Content')
291
+ const contentRef = useRef<HTMLDivElement | null>(null)
292
+
293
+ const {
294
+ ref: positionRef,
295
+ style: positionStyle,
296
+ placement,
297
+ positioned,
298
+ } = useAnchoredPosition<HTMLDivElement>({
299
+ anchorRef: triggerRef,
300
+ anchorRect,
301
+ side,
302
+ align,
303
+ offset,
304
+ alignOffset,
305
+ flip,
306
+ clampToViewport,
307
+ enabled: open,
308
+ })
309
+
310
+ const layer = useLayer({
311
+ enabled: open,
312
+ kind: 'popover',
313
+ elementRef: contentRef,
314
+ onEscape: () => setOpen(false),
315
+ })
316
+
317
+ useOutsideClick(
318
+ [triggerRef, contentRef],
319
+ () => {
320
+ interactedOutsideRef.current = true
321
+ setOpen(false)
322
+ },
323
+ { enabled: open, layer },
324
+ )
325
+
326
+ // Focus in once the content is POSITIONED, back to the trigger on close.
327
+ // Not on `open`: the first open frame is the parked one (off-screen,
328
+ // `visibility: hidden`), and Chromium, Firefox and WebKit all refuse to
329
+ // focus into a visibility:hidden subtree, so a focus() there is a silent
330
+ // no-op and focus stays on the trigger. `positioned` flips true in the
331
+ // re-render the measuring layout effect triggers, after the parked frame
332
+ // has already flushed its passive effects. jsdom does not enforce the
333
+ // rule, which is why the test records `data-positioned` at focus time.
334
+ // Keyed on state, not an inline callback, so `onOpenChange` identity
335
+ // churn never re-runs it mid-interaction.
336
+ useEffect(() => {
337
+ if (!open || !positioned) return
338
+ const node = contentRef.current
339
+ if (!node) return
340
+ interactedOutsideRef.current = false
341
+ // Captured now, not in the cleanup: the trigger does not change while
342
+ // the popover is open, and a remounted one would be disconnected.
343
+ const trigger = triggerRef.current
344
+ const previous =
345
+ document.activeElement instanceof HTMLElement &&
346
+ document.activeElement !== document.body
347
+ ? document.activeElement
348
+ : null
349
+ const initial =
350
+ node.querySelector<HTMLElement>('[data-autofocus]') ??
351
+ tabbablesWithin(node)[0] ??
352
+ node
353
+ initial.focus()
354
+
355
+ return () => {
356
+ if (interactedOutsideRef.current) return
357
+ // The content is gone by now, so a focus that was inside it has
358
+ // already fallen to <body>. Anything else means the user moved on.
359
+ const active = document.activeElement
360
+ if (active !== null && active !== document.body && !node.contains(active)) return
361
+ const target = trigger ?? previous
362
+ if (target?.isConnected) target.focus()
363
+ }
364
+ }, [open, positioned, triggerRef, interactedOutsideRef])
365
+
366
+ const ref = useMemo(
367
+ () => composeRefs<HTMLDivElement>(forwardedRef, contentRef, positionRef),
368
+ [forwardedRef, positionRef],
369
+ )
370
+
371
+ if (!open || typeof document === 'undefined') return null
372
+
373
+ return createPortal(
374
+ <div
375
+ ref={ref}
376
+ id={contentId}
377
+ role={role === 'none' ? undefined : role}
378
+ tabIndex={-1}
379
+ data-slot="popover-content"
380
+ data-state="open"
381
+ data-side={placement?.side}
382
+ data-align={placement?.align}
383
+ data-positioned={positioned ? 'true' : 'false'}
384
+ className={cn(
385
+ 'fixed z-[100] outline-none',
386
+ !unstyled && 'rounded-[var(--radius-lg)] p-3',
387
+ positioned && 'ds-enter-pop',
388
+ className,
389
+ )}
390
+ style={{
391
+ ...positionStyle,
392
+ ...(unstyled ? undefined : POPOVER_SURFACE_STYLE),
393
+ ...style,
394
+ }}
395
+ {...rest}
396
+ >
397
+ {children}
398
+ </div>,
399
+ document.body,
400
+ )
401
+ },
402
+ )
403
+
404
+ export const Popover = Object.assign(PopoverRoot, {
405
+ Trigger: PopoverTrigger,
406
+ Content: PopoverContent,
407
+ })
@@ -9,12 +9,20 @@
9
9
  * │ └──────────────┘ │
10
10
  * ╰──────────────────────────────────────────────────────────────╯
11
11
  *
12
- * • Whole strip sits in a subtle `--surface-overlay-soft` rounded
13
- * container so the inactive segments read as text rather than
14
- * borderless buttons.
15
- * The active segment is wrapped in a 1px accent border with an
16
- * `--accent-subtle` fill reads as an "outlined pill" sitting
17
- * inside the strip.
12
+ * • The track is an OPAQUE recessed well (`--surface-frame`) so the
13
+ * inactive segments read as text rather than borderless buttons.
14
+ * The active segment steps UP off the track: an opaque
15
+ * `--surface-card` fill sandwiched between the track and a 1px
16
+ * accent border, plus `--shadow-sm`. It reads as a raised pill,
17
+ * not a tinted one.
18
+ *
19
+ * Both were translucent until 2026-08-30 (`--surface-overlay-soft`
20
+ * track, `--accent-subtle` pill). Stacked, they composited to a 245
21
+ * pill inside a 247 track in light mode — the active segment was
22
+ * DARKER than the thing it sits in, so the raised element read as
23
+ * recessed and the control looked washed out. Opaque surfaces cannot
24
+ * invert like that, because their relationship does not depend on
25
+ * whatever happens to be behind them.
18
26
  * • Each segment is a button with an optional leading icon.
19
27
  *
20
28
  * Generic — accepts a typed `items` array. First consumer: Keywords
@@ -79,7 +87,14 @@ export function SegmentedPill<Id extends string = string>({
79
87
  className,
80
88
  )}
81
89
  style={{
82
- background: 'rgb(var(--surface-overlay-soft))',
90
+ // OPAQUE. This was --surface-overlay-soft, a translucent tint, with a
91
+ // translucent --accent-subtle pill on top of it — two alpha layers
92
+ // stacked. In light that composited to a 245 pill inside a 247 strip:
93
+ // the ACTIVE segment rendered DARKER than the track it sits in, so the
94
+ // raised element read as recessed and the whole control looked washed
95
+ // out. --surface-frame is the recessed-well token and is darker than
96
+ // --surface-card in BOTH themes, so the direction holds either way.
97
+ background: 'rgb(var(--surface-frame))',
83
98
  border: '1px solid rgb(var(--border))',
84
99
  borderRadius: 'var(--radius-full)',
85
100
  }}
@@ -110,9 +125,17 @@ export function SegmentedPill<Id extends string = string>({
110
125
  borderRadius: 'var(--radius-full)',
111
126
  ...(isActive
112
127
  ? {
113
- background: 'rgb(var(--accent-subtle))',
128
+ // The active segment STEPS UP off the track: an opaque
129
+ // surface sandwiched between the accent border and the
130
+ // strip, plus a small shadow. --surface-card is +8 on the
131
+ // frame in light and +6 in dark, so it reads raised in both.
132
+ background: 'rgb(var(--surface-card))',
114
133
  border: '1px solid rgb(var(--accent))',
115
- color: 'rgb(var(--accent))',
134
+ // --accent-ink, not --accent: the brand red is 5.64:1 on a
135
+ // light card but 3.05:1 on a dark one, i.e. an accent LABEL
136
+ // fails AA in dark. --accent-ink is the theme-tuned form.
137
+ color: 'rgb(var(--accent-ink))',
138
+ boxShadow: 'var(--shadow-sm)',
116
139
  }
117
140
  : {
118
141
  background: 'transparent',
@@ -126,7 +149,7 @@ export function SegmentedPill<Id extends string = string>({
126
149
  className="inline-flex shrink-0"
127
150
  style={{
128
151
  color: isActive
129
- ? 'rgb(var(--accent))'
152
+ ? 'rgb(var(--accent-ink))'
130
153
  : 'rgb(var(--text-muted))',
131
154
  }}
132
155
  aria-hidden="true"