@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.
- package/dist/index.d.ts +823 -135
- package/dist/index.js +2048 -358
- package/dist/index.js.map +1 -1
- package/dist/styles.css +44 -2
- package/dist/theme-v2.css +228 -0
- package/dist/tokens.css +123 -8
- package/package.json +1 -1
- package/src/__tests__/anchor.test.tsx +422 -0
- package/src/__tests__/combobox.test.tsx +677 -0
- package/src/__tests__/dropdown-menu.test.tsx +418 -0
- package/src/__tests__/helpers/geometry.ts +58 -0
- package/src/__tests__/layer-stack.test.tsx +228 -0
- package/src/__tests__/modal.test.tsx +180 -6
- package/src/__tests__/popover.test.tsx +460 -0
- package/src/__tests__/select.test.tsx +543 -0
- package/src/__tests__/tooltip.test.tsx +355 -0
- package/src/calculator-shell-v2.tsx +19 -39
- package/src/code-block.tsx +15 -26
- package/src/combobox.tsx +796 -0
- package/src/dropdown-menu.tsx +142 -152
- package/src/icons/brand.tsx +81 -2
- package/src/index.ts +111 -0
- package/src/lib/anchor.ts +427 -0
- package/src/lib/focus.ts +32 -0
- package/src/lib/layer-stack.ts +188 -0
- package/src/lib/refs.ts +31 -0
- package/src/metric-card.tsx +57 -22
- package/src/modal.tsx +149 -9
- package/src/page-shell.tsx +91 -2
- package/src/popover.tsx +407 -0
- package/src/segmented-pill.tsx +33 -10
- package/src/select.tsx +646 -0
- package/src/stat-row.tsx +108 -70
- package/src/styles.css +44 -2
- package/src/theme-v2.css +7 -245
- package/src/tokens.css +123 -8
- package/src/tooltip.tsx +297 -0
- package/src/react-syntax-highlighter-prism.d.ts +0 -34
- package/src/v2/README.md +0 -208
- package/src/v2/__demo__/showcase.tsx +0 -1045
- package/src/v2/action.tsx +0 -91
- package/src/v2/callout.tsx +0 -76
- package/src/v2/document-section.tsx +0 -82
- package/src/v2/document-shell.tsx +0 -0
- package/src/v2/field-row.tsx +0 -113
- package/src/v2/icons.tsx +0 -165
- package/src/v2/index.ts +0 -147
- package/src/v2/layout.tsx +0 -293
- package/src/v2/progress-track.tsx +0 -89
- package/src/v2/stat-tile.tsx +0 -129
- package/src/v2/states.tsx +0 -271
- package/src/v2/status-pill.tsx +0 -74
- package/src/v2/theme.css +0 -1861
- package/src/v2/timeline.tsx +0 -81
- package/src/v2/tokens.ts +0 -228
package/src/popover.tsx
ADDED
|
@@ -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
|
+
})
|
package/src/segmented-pill.tsx
CHANGED
|
@@ -9,12 +9,20 @@
|
|
|
9
9
|
* │ └──────────────┘ │
|
|
10
10
|
* ╰──────────────────────────────────────────────────────────────╯
|
|
11
11
|
*
|
|
12
|
-
* •
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* `--
|
|
17
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|