@lovett/ui 0.0.10 → 0.1.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.
- package/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +6064 -80
- package/dist/index.js +21561 -9696
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1586 -2
- package/dist/theme-v2.css +228 -0
- package/dist/tokens.css +121 -0
- package/package.json +8 -1
- package/src/__tests__/anchor.test.tsx +422 -0
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/combobox.test.tsx +677 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +208 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +572 -0
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/helpers/geometry.ts +58 -0
- package/src/__tests__/layer-stack.test.tsx +228 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/popover.test.tsx +460 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/select.test.tsx +543 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/__tests__/tooltip.test.tsx +355 -0
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/calculator-shell-v2.tsx +19 -39
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/combobox.tsx +796 -0
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +186 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +507 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +242 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +412 -151
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +402 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/anchor.ts +427 -0
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +74 -0
- package/src/lib/layer-stack.ts +215 -0
- package/src/lib/refs.ts +31 -0
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/metric-card.tsx +57 -22
- package/src/modal.tsx +73 -66
- package/src/popover.tsx +407 -0
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/select.tsx +646 -0
- package/src/sortable.tsx +315 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-row.tsx +108 -70
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1586 -2
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +235 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +662 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +915 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +218 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- package/src/tokens.css +121 -0
- package/src/tooltip.tsx +297 -0
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
|
+
})
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProgressLedger — the per-task block bar.
|
|
3
|
+
*
|
|
4
|
+
* ADR-146's one piece of boldness, and the reason the port keeps a ledger
|
|
5
|
+
* rather than a percentage bar: it is COUNTABLE. You read 7 of 11 without
|
|
6
|
+
* reading a number, and each segment doubles as navigation into that group.
|
|
7
|
+
*
|
|
8
|
+
* Three rules it must not lose:
|
|
9
|
+
*
|
|
10
|
+
* 1. **Never colour-only.** The label row always carries the count AND the
|
|
11
|
+
* percentage as text (interface-standards §6), so completion is legible
|
|
12
|
+
* without perceiving hue.
|
|
13
|
+
* 2. **The track is grey structure, not a surface.** `--surface-inset` +
|
|
14
|
+
* 1px `--border`, exactly the "grey structure inside an opaque card" the
|
|
15
|
+
* data-display recipe requires (CLAUDE.md §2). Filled cells take the
|
|
16
|
+
* column hue off the tint ladder, so they composite opaque in both themes.
|
|
17
|
+
* 3. **A cell settles, it never pops.** Colour transitions over `--dur-base`
|
|
18
|
+
* (`.ds-ledger-cell` in styles.css) and `prefers-reduced-motion` jumps to
|
|
19
|
+
* the end state.
|
|
20
|
+
*
|
|
21
|
+
* Radius is the documented exception to the concentric ladder: cells are 3px
|
|
22
|
+
* because squareness is what makes them countable. The track is 5px so that
|
|
23
|
+
* 5 − 2px of padding = the cells' 3px and the chain still holds locally.
|
|
24
|
+
*
|
|
25
|
+
* The hue comes from an ancestor's `.hue hue-<key>` classes (see `hue.ts`);
|
|
26
|
+
* this primitive reads `--tint-fill` / `--tint-task` / `--h` and never names a
|
|
27
|
+
* colour of its own.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { CSSProperties, ReactNode } from 'react'
|
|
31
|
+
import { cn } from './lib/utils'
|
|
32
|
+
import { StatusRing } from './status'
|
|
33
|
+
|
|
34
|
+
/** One cell — a single task. */
|
|
35
|
+
export interface LedgerCell {
|
|
36
|
+
id: string
|
|
37
|
+
done: boolean
|
|
38
|
+
/** The task in flight. Rendered as an outlined cell, not a filled one. */
|
|
39
|
+
current?: boolean
|
|
40
|
+
/** Falls back to the cell id in the cell's title attribute. */
|
|
41
|
+
label?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One phase / group. A divider is drawn at every boundary between segments. */
|
|
45
|
+
export interface LedgerSegment {
|
|
46
|
+
id: string
|
|
47
|
+
label: string
|
|
48
|
+
cells: readonly LedgerCell[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Done, total and the rounded percentage over a set of segments. */
|
|
52
|
+
export interface LedgerProgress {
|
|
53
|
+
done: number
|
|
54
|
+
total: number
|
|
55
|
+
/** 0–100, rounded. `0` when there is nothing to measure. */
|
|
56
|
+
percent: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* THE completion calculation — one function, every consumer.
|
|
61
|
+
*
|
|
62
|
+
* The ledger prints these numbers as text, the card's ring draws them, and a
|
|
63
|
+
* board column aggregates its lane's items through this same function. A
|
|
64
|
+
* second implementation anywhere is how a filled meter ends up disagreeing
|
|
65
|
+
* with the percentage printed beside it (header-primitives §1, last row).
|
|
66
|
+
*/
|
|
67
|
+
export function ledgerProgress(segments: readonly LedgerSegment[]): LedgerProgress {
|
|
68
|
+
const cells = segments.flatMap((segment) => segment.cells)
|
|
69
|
+
const total = cells.length
|
|
70
|
+
const done = cells.filter((cell) => cell.done).length
|
|
71
|
+
return { done, total, percent: total === 0 ? 0 : Math.round((done / total) * 100) }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ProgressLedgerProps {
|
|
75
|
+
segments: readonly LedgerSegment[]
|
|
76
|
+
/** Leading word in the label row. Sentence case. */
|
|
77
|
+
label?: string
|
|
78
|
+
/** Optional middle term — the phase the item is in. */
|
|
79
|
+
phase?: string
|
|
80
|
+
/** `lg` is the detail-drawer size; `sm` is the card size. */
|
|
81
|
+
size?: 'sm' | 'lg'
|
|
82
|
+
/**
|
|
83
|
+
* Emitted when a segment is activated. Interactivity is OPT-IN: without
|
|
84
|
+
* this, segments render as inert `<span>`s and add no tab stops.
|
|
85
|
+
*
|
|
86
|
+
* Supplying it is a commitment to a destination. A board that wires an empty
|
|
87
|
+
* handler here puts one dead 12px-tall tab stop on the page per segment per
|
|
88
|
+
* card — six of the Tasks board's twenty tab stops did exactly that — which
|
|
89
|
+
* is worse than the control being absent. When it IS supplied, the segment
|
|
90
|
+
* is forced to a 24px minimum hit area (interface-standards §6), so it
|
|
91
|
+
* cannot come back as a 12px target.
|
|
92
|
+
*/
|
|
93
|
+
onSelectSegment?: (segmentId: string) => void
|
|
94
|
+
/**
|
|
95
|
+
* Draws a `<StatusRing>` at the head of the label row, showing the SAME
|
|
96
|
+
* percentage this ledger prints. It is decorative by construction — the
|
|
97
|
+
* number is already text two words away, and announcing it twice is the
|
|
98
|
+
* defect, not the feature.
|
|
99
|
+
*/
|
|
100
|
+
showRing?: boolean
|
|
101
|
+
/** Names the ledger for assistive tech, e.g. the item title. */
|
|
102
|
+
ariaLabel?: string
|
|
103
|
+
className?: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const TRACK_RADIUS = '5px'
|
|
107
|
+
const CELL_RADIUS = '3px'
|
|
108
|
+
|
|
109
|
+
export function ProgressLedger({
|
|
110
|
+
segments,
|
|
111
|
+
label = 'Progress',
|
|
112
|
+
phase,
|
|
113
|
+
size = 'sm',
|
|
114
|
+
onSelectSegment,
|
|
115
|
+
showRing = false,
|
|
116
|
+
ariaLabel,
|
|
117
|
+
className,
|
|
118
|
+
}: ProgressLedgerProps) {
|
|
119
|
+
const { done, total, percent } = ledgerProgress(segments)
|
|
120
|
+
const cellHeight = size === 'lg' ? 22 : 12
|
|
121
|
+
const cellMinWidth = size === 'lg' ? 12 : 5
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div
|
|
125
|
+
className={cn('flex flex-col', className)}
|
|
126
|
+
style={{ gap: 'var(--space-1)' }}
|
|
127
|
+
role="group"
|
|
128
|
+
aria-label={
|
|
129
|
+
ariaLabel
|
|
130
|
+
? `${label} for ${ariaLabel}: ${done} of ${total} done, ${percent} percent`
|
|
131
|
+
: `${label}: ${done} of ${total} done, ${percent} percent`
|
|
132
|
+
}
|
|
133
|
+
>
|
|
134
|
+
{/* Label row — `Progress · 7/11 · 64%`. The count is the reason the
|
|
135
|
+
ledger beats a bar, so it is never dropped for space. */}
|
|
136
|
+
<div
|
|
137
|
+
className="flex items-baseline text-[11px] leading-none"
|
|
138
|
+
style={{ gap: 'var(--space-2)', color: 'rgb(var(--text-muted))' }}
|
|
139
|
+
>
|
|
140
|
+
{showRing ? (
|
|
141
|
+
<StatusRing
|
|
142
|
+
percent={percent}
|
|
143
|
+
size={size === 'lg' ? 16 : 13}
|
|
144
|
+
strokeWidth={2}
|
|
145
|
+
decorative
|
|
146
|
+
// The 11px label row sits on its baseline; a ring has none, so it
|
|
147
|
+
// is nudged optically rather than mathematically.
|
|
148
|
+
style={{ alignSelf: 'center', marginBlockStart: '-1px' }}
|
|
149
|
+
/>
|
|
150
|
+
) : null}
|
|
151
|
+
<span>{label}</span>
|
|
152
|
+
{/* min-width slots: the card must not resize when a task is checked
|
|
153
|
+
and `9/11` becomes `10/11` (layout-stability rule). */}
|
|
154
|
+
<LedgerFigure style={{ minInlineSize: '3.5ch' }}>
|
|
155
|
+
{done}/{total}
|
|
156
|
+
</LedgerFigure>
|
|
157
|
+
{phase ? <span className="truncate">{phase}</span> : null}
|
|
158
|
+
<LedgerFigure
|
|
159
|
+
className="ms-auto font-semibold"
|
|
160
|
+
style={{
|
|
161
|
+
color: 'rgb(var(--muted-foreground))',
|
|
162
|
+
minInlineSize: '4ch',
|
|
163
|
+
textAlign: 'end',
|
|
164
|
+
}}
|
|
165
|
+
>
|
|
166
|
+
{percent}%
|
|
167
|
+
</LedgerFigure>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<div
|
|
171
|
+
className="flex items-stretch"
|
|
172
|
+
style={{
|
|
173
|
+
gap: '2px',
|
|
174
|
+
padding: '2px',
|
|
175
|
+
background: 'rgb(var(--surface-inset))',
|
|
176
|
+
border: '1px solid rgb(var(--border))',
|
|
177
|
+
borderRadius: TRACK_RADIUS,
|
|
178
|
+
}}
|
|
179
|
+
>
|
|
180
|
+
{total === 0 ? (
|
|
181
|
+
<span
|
|
182
|
+
className="block w-full"
|
|
183
|
+
style={{ height: cellHeight, borderRadius: CELL_RADIUS }}
|
|
184
|
+
aria-hidden="true"
|
|
185
|
+
/>
|
|
186
|
+
) : null}
|
|
187
|
+
{segments.map((segment, index) => (
|
|
188
|
+
<SegmentRun
|
|
189
|
+
key={segment.id}
|
|
190
|
+
segment={segment}
|
|
191
|
+
showDivider={index > 0}
|
|
192
|
+
cellHeight={cellHeight}
|
|
193
|
+
cellMinWidth={cellMinWidth}
|
|
194
|
+
onSelect={onSelectSegment}
|
|
195
|
+
/>
|
|
196
|
+
))}
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function SegmentRun({
|
|
203
|
+
segment,
|
|
204
|
+
showDivider,
|
|
205
|
+
cellHeight,
|
|
206
|
+
cellMinWidth,
|
|
207
|
+
onSelect,
|
|
208
|
+
}: {
|
|
209
|
+
segment: LedgerSegment
|
|
210
|
+
showDivider: boolean
|
|
211
|
+
cellHeight: number
|
|
212
|
+
cellMinWidth: number
|
|
213
|
+
onSelect?: ((segmentId: string) => void) | undefined
|
|
214
|
+
}) {
|
|
215
|
+
const doneCount = segment.cells.filter((cell) => cell.done).length
|
|
216
|
+
const cells = (
|
|
217
|
+
<>
|
|
218
|
+
{segment.cells.map((cell) => (
|
|
219
|
+
<span
|
|
220
|
+
key={cell.id}
|
|
221
|
+
className="ds-ledger-cell block"
|
|
222
|
+
title={cell.label ?? undefined}
|
|
223
|
+
style={{
|
|
224
|
+
flex: '1 1 0',
|
|
225
|
+
minWidth: cellMinWidth,
|
|
226
|
+
height: cellHeight,
|
|
227
|
+
borderRadius: CELL_RADIUS,
|
|
228
|
+
background: cell.done
|
|
229
|
+
? 'var(--tint-fill)'
|
|
230
|
+
: cell.current
|
|
231
|
+
? 'var(--tint-task)'
|
|
232
|
+
: 'rgb(var(--surface-card))',
|
|
233
|
+
boxShadow: cell.current && !cell.done ? 'inset 0 0 0 1px var(--h)' : undefined,
|
|
234
|
+
}}
|
|
235
|
+
/>
|
|
236
|
+
))}
|
|
237
|
+
</>
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
const runStyle = {
|
|
241
|
+
flex: `${Math.max(segment.cells.length, 1)} 1 0`,
|
|
242
|
+
display: 'flex',
|
|
243
|
+
gap: '2px',
|
|
244
|
+
minWidth: 0,
|
|
245
|
+
} as const
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<>
|
|
249
|
+
{/* Phase boundary. A 1px rule, not a gap — a gap at this scale reads as
|
|
250
|
+
a missing cell and would make the count wrong at a glance. */}
|
|
251
|
+
{showDivider ? (
|
|
252
|
+
<span
|
|
253
|
+
aria-hidden="true"
|
|
254
|
+
style={{
|
|
255
|
+
flex: 'none',
|
|
256
|
+
width: '1px',
|
|
257
|
+
background: 'rgb(var(--border-strong))',
|
|
258
|
+
marginBlock: '-2px',
|
|
259
|
+
marginInline: '2px',
|
|
260
|
+
}}
|
|
261
|
+
/>
|
|
262
|
+
) : null}
|
|
263
|
+
{onSelect ? (
|
|
264
|
+
<button
|
|
265
|
+
type="button"
|
|
266
|
+
onClick={() => onSelect(segment.id)}
|
|
267
|
+
aria-label={`${segment.label}: ${doneCount} of ${segment.cells.length} done`}
|
|
268
|
+
className="rounded-[3px] focus-visible:[box-shadow:var(--ring-focus)] focus-visible:outline-none"
|
|
269
|
+
style={{
|
|
270
|
+
...runStyle,
|
|
271
|
+
// The accessibility floor, enforced in the primitive rather than
|
|
272
|
+
// asked of every caller: a 12px cell row is not a 24px target, so
|
|
273
|
+
// the run grows to 24px and centres its cells inside it.
|
|
274
|
+
alignItems: 'center',
|
|
275
|
+
minBlockSize: '24px',
|
|
276
|
+
background: 'transparent',
|
|
277
|
+
border: 'none',
|
|
278
|
+
padding: 0,
|
|
279
|
+
}}
|
|
280
|
+
>
|
|
281
|
+
{cells}
|
|
282
|
+
</button>
|
|
283
|
+
) : (
|
|
284
|
+
<span style={runStyle}>{cells}</span>
|
|
285
|
+
)}
|
|
286
|
+
</>
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function LedgerFigure({
|
|
291
|
+
children,
|
|
292
|
+
className,
|
|
293
|
+
style,
|
|
294
|
+
}: {
|
|
295
|
+
children: ReactNode
|
|
296
|
+
className?: string
|
|
297
|
+
style?: CSSProperties
|
|
298
|
+
}) {
|
|
299
|
+
return (
|
|
300
|
+
<span className={cn('tabular-nums', className)} style={style}>
|
|
301
|
+
{children}
|
|
302
|
+
</span>
|
|
303
|
+
)
|
|
304
|
+
}
|