@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
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dismiss-layer stack — the ONE "topmost layer wins" registry shared by
|
|
3
|
+
* Modal, DropdownMenu and Popover (and, next, ContextMenu / Select /
|
|
4
|
+
* Combobox / Tooltip / CommandPalette).
|
|
5
|
+
*
|
|
6
|
+
* Extracted from modal.tsx's module-level `openPanels` array so every
|
|
7
|
+
* dismissable surface participates in the same order. Before this, a
|
|
8
|
+
* DropdownMenu opened from inside a Modal answered Escape at the same time
|
|
9
|
+
* the Modal did — two document listeners, one keypress, both closed. Now a
|
|
10
|
+
* single document `keydown` listener dispatches Escape to the top of the
|
|
11
|
+
* stack ONLY, then stops propagation so window-level handlers (the chat
|
|
12
|
+
* conversation drawer's Escape, lens hotkey maps) do not treat a consumed
|
|
13
|
+
* Escape as theirs — which is what DropdownMenu's own listener already did.
|
|
14
|
+
*
|
|
15
|
+
* Promoted per ADR-0030 Decision G (meta-ads-audit-dashboard task manager:
|
|
16
|
+
* every picker, sheet and palette needs one dismiss order). First consumer
|
|
17
|
+
* is the workspace app: Modal (51 files), DropdownMenu (137 call sites) and
|
|
18
|
+
* Popover all register here.
|
|
19
|
+
*
|
|
20
|
+
* Two kinds. A `modal` layer also owns a focus trap, and that trap must not
|
|
21
|
+
* switch off because a `popover` opened above it — so Modal asks
|
|
22
|
+
* `isTopOfKind()` for Tab handling while Escape always goes to `isTop()`.
|
|
23
|
+
* Order is push order, which under React is effect order: a surface that
|
|
24
|
+
* mounts or opens later sits above one that opened earlier.
|
|
25
|
+
*
|
|
26
|
+
* Usage (a dismissable surface):
|
|
27
|
+
*
|
|
28
|
+
* const layer = useLayer({
|
|
29
|
+
* enabled: open,
|
|
30
|
+
* kind: 'popover',
|
|
31
|
+
* elementRef: contentRef,
|
|
32
|
+
* onEscape: () => setOpen(false),
|
|
33
|
+
* })
|
|
34
|
+
* useOutsideClick([triggerRef, contentRef], () => setOpen(false), {
|
|
35
|
+
* enabled: open,
|
|
36
|
+
* layer, // clicks inside a layer stacked ABOVE this one are not "outside"
|
|
37
|
+
* })
|
|
38
|
+
*
|
|
39
|
+
* `useEscapeKey(handler, { enabled })` is the one-liner for anything that
|
|
40
|
+
* only needs Escape.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { useEffect, useMemo, useRef, type RefObject } from 'react'
|
|
44
|
+
|
|
45
|
+
export type LayerKind = 'modal' | 'popover'
|
|
46
|
+
|
|
47
|
+
export interface LayerHandle {
|
|
48
|
+
/** True while no layer of ANY kind sits above this one. */
|
|
49
|
+
isTop(): boolean
|
|
50
|
+
/** True while no layer of the SAME kind sits above this one. */
|
|
51
|
+
isTopOfKind(): boolean
|
|
52
|
+
/**
|
|
53
|
+
* True when `node` is inside the element of a layer stacked above this
|
|
54
|
+
* one — a nested Select's listbox, a Combobox inside a Popover. Outside-
|
|
55
|
+
* click handlers treat those as inside.
|
|
56
|
+
*/
|
|
57
|
+
containsInLayerAbove(node: Node): boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface LayerRecord {
|
|
61
|
+
readonly kind: LayerKind
|
|
62
|
+
readonly element: () => HTMLElement | null
|
|
63
|
+
readonly onEscape: (event: KeyboardEvent) => void
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const stack: LayerRecord[] = []
|
|
67
|
+
let listening = false
|
|
68
|
+
|
|
69
|
+
function onDocumentKeyDown(event: KeyboardEvent): void {
|
|
70
|
+
if (event.key !== 'Escape') return
|
|
71
|
+
const top = stack[stack.length - 1]
|
|
72
|
+
if (!top) return
|
|
73
|
+
// Consumed here. Window-level listeners (hotkey maps, drawers) must not
|
|
74
|
+
// see an Escape that closed a layer — same contract DropdownMenu's own
|
|
75
|
+
// listener had before the stack existed.
|
|
76
|
+
event.stopPropagation()
|
|
77
|
+
top.onEscape(event)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function pushLayer(record: LayerRecord): () => void {
|
|
81
|
+
stack.push(record)
|
|
82
|
+
if (!listening) {
|
|
83
|
+
document.addEventListener('keydown', onDocumentKeyDown)
|
|
84
|
+
listening = true
|
|
85
|
+
}
|
|
86
|
+
return () => {
|
|
87
|
+
const at = stack.lastIndexOf(record)
|
|
88
|
+
if (at !== -1) stack.splice(at, 1)
|
|
89
|
+
if (stack.length === 0 && listening) {
|
|
90
|
+
document.removeEventListener('keydown', onDocumentKeyDown)
|
|
91
|
+
listening = false
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface UseLayerOptions {
|
|
97
|
+
/** Register while true; pop when it turns false or the owner unmounts. */
|
|
98
|
+
enabled: boolean
|
|
99
|
+
kind: LayerKind
|
|
100
|
+
/**
|
|
101
|
+
* The surface's root element. Read lazily, so a ref that is filled in
|
|
102
|
+
* after the layer registers (portal content) still resolves.
|
|
103
|
+
*/
|
|
104
|
+
elementRef?: RefObject<HTMLElement | null>
|
|
105
|
+
/** Called when this layer is topmost and Escape is pressed. */
|
|
106
|
+
onEscape: (event: KeyboardEvent) => void
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Register a dismiss layer for as long as `enabled` holds. `onEscape` is
|
|
111
|
+
* read through a ref, so an inline arrow does not re-register the layer
|
|
112
|
+
* (and does not reorder it) on every render.
|
|
113
|
+
*/
|
|
114
|
+
export function useLayer(options: UseLayerOptions): LayerHandle {
|
|
115
|
+
const { enabled, kind, elementRef, onEscape } = options
|
|
116
|
+
|
|
117
|
+
const onEscapeRef = useRef(onEscape)
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
onEscapeRef.current = onEscape
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const recordRef = useRef<LayerRecord | null>(null)
|
|
123
|
+
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (!enabled) return
|
|
126
|
+
const record: LayerRecord = {
|
|
127
|
+
kind,
|
|
128
|
+
element: () => elementRef?.current ?? null,
|
|
129
|
+
onEscape: (event) => onEscapeRef.current(event),
|
|
130
|
+
}
|
|
131
|
+
recordRef.current = record
|
|
132
|
+
const pop = pushLayer(record)
|
|
133
|
+
return () => {
|
|
134
|
+
pop()
|
|
135
|
+
if (recordRef.current === record) recordRef.current = null
|
|
136
|
+
}
|
|
137
|
+
}, [enabled, kind, elementRef])
|
|
138
|
+
|
|
139
|
+
return useMemo<LayerHandle>(
|
|
140
|
+
() => ({
|
|
141
|
+
isTop: () => {
|
|
142
|
+
const record = recordRef.current
|
|
143
|
+
return record !== null && stack[stack.length - 1] === record
|
|
144
|
+
},
|
|
145
|
+
isTopOfKind: () => {
|
|
146
|
+
const record = recordRef.current
|
|
147
|
+
if (!record) return false
|
|
148
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
149
|
+
const layer = stack[i]
|
|
150
|
+
if (layer === record) return true
|
|
151
|
+
if (layer?.kind === record.kind) return false
|
|
152
|
+
}
|
|
153
|
+
return false
|
|
154
|
+
},
|
|
155
|
+
containsInLayerAbove: (node) => {
|
|
156
|
+
const record = recordRef.current
|
|
157
|
+
if (!record) return false
|
|
158
|
+
const at = stack.indexOf(record)
|
|
159
|
+
if (at === -1) return false
|
|
160
|
+
for (let i = at + 1; i < stack.length; i++) {
|
|
161
|
+
if (stack[i]?.element()?.contains(node)) return true
|
|
162
|
+
}
|
|
163
|
+
return false
|
|
164
|
+
},
|
|
165
|
+
}),
|
|
166
|
+
[],
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface UseEscapeKeyOptions {
|
|
171
|
+
/** Default true. */
|
|
172
|
+
enabled?: boolean
|
|
173
|
+
/** Default `popover`. */
|
|
174
|
+
kind?: LayerKind
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Escape-only participant in the layer stack. Fires `onEscape` only while
|
|
179
|
+
* this is the topmost layer; a layer that opens later takes over until it
|
|
180
|
+
* closes.
|
|
181
|
+
*/
|
|
182
|
+
export function useEscapeKey(
|
|
183
|
+
onEscape: (event: KeyboardEvent) => void,
|
|
184
|
+
options: UseEscapeKeyOptions = {},
|
|
185
|
+
): LayerHandle {
|
|
186
|
+
const { enabled = true, kind = 'popover' } = options
|
|
187
|
+
return useLayer({ enabled, kind, onEscape })
|
|
188
|
+
}
|
package/src/lib/refs.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ref plumbing for primitives that must hand one DOM node to several
|
|
3
|
+
* owners — the caller's forwarded ref, the component's own ref, and a
|
|
4
|
+
* positioning hook's callback ref.
|
|
5
|
+
*
|
|
6
|
+
* `assignRef` writes a node into any React ref shape. `composeRefs` returns
|
|
7
|
+
* ONE callback ref that fans out to all of them; memoize the result
|
|
8
|
+
* (`useMemo` on the inputs) so React does not detach/re-attach on every
|
|
9
|
+
* render.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Ref, RefCallback } from 'react'
|
|
13
|
+
|
|
14
|
+
export function assignRef<T>(ref: Ref<T> | undefined, node: T | null): void {
|
|
15
|
+
if (!ref) return
|
|
16
|
+
if (typeof ref === 'function') {
|
|
17
|
+
// React 19 callback refs may return a cleanup; we call with `null` on
|
|
18
|
+
// detach instead, which every consumer of this package already handles.
|
|
19
|
+
ref(node)
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
ref.current = node
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function composeRefs<T>(
|
|
26
|
+
...refs: ReadonlyArray<Ref<T> | undefined>
|
|
27
|
+
): RefCallback<T> {
|
|
28
|
+
return (node) => {
|
|
29
|
+
for (const ref of refs) assignRef(ref, node)
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/metric-card.tsx
CHANGED
|
@@ -11,9 +11,17 @@
|
|
|
11
11
|
* Token discipline: all colors via public tokens. Tone variant gives
|
|
12
12
|
* the value text an accent/success/warning/destructive/info color;
|
|
13
13
|
* background is always neutral surface-overlay.
|
|
14
|
+
*
|
|
15
|
+
* `copyable` (ADR-145 amendment, 2026-09-04): the card becomes a button
|
|
16
|
+
* that copies `copyValue` (or the string `value`) and toasts
|
|
17
|
+
* "Copied <label>". Same hover/focus affordance as ValueChip and the
|
|
18
|
+
* copyable StatRow. Inert by default so a read-only KPI never advertises
|
|
19
|
+
* an interaction it doesn't have.
|
|
14
20
|
*/
|
|
15
21
|
|
|
16
22
|
import type { ReactNode } from 'react'
|
|
23
|
+
import { cn } from './lib/utils'
|
|
24
|
+
import { copyText } from './lib/clipboard'
|
|
17
25
|
|
|
18
26
|
export type MetricCardTone = 'neutral' | 'accent' | 'success' | 'warning' | 'destructive' | 'info'
|
|
19
27
|
|
|
@@ -46,6 +54,11 @@ export interface MetricCardProps {
|
|
|
46
54
|
labelPosition?: 'top' | 'bottom'
|
|
47
55
|
/** Optional className override for the outer container. */
|
|
48
56
|
className?: string
|
|
57
|
+
/** Make the whole card a copy-to-clipboard button. */
|
|
58
|
+
copyable?: boolean
|
|
59
|
+
/** What a copyable card writes. Defaults to `value` when it is a string;
|
|
60
|
+
* REQUIRED when `value` is a node (there is nothing else to copy). */
|
|
61
|
+
copyValue?: string
|
|
49
62
|
}
|
|
50
63
|
|
|
51
64
|
export function MetricCard({
|
|
@@ -55,6 +68,8 @@ export function MetricCard({
|
|
|
55
68
|
icon,
|
|
56
69
|
labelPosition = 'bottom',
|
|
57
70
|
className,
|
|
71
|
+
copyable = false,
|
|
72
|
+
copyValue,
|
|
58
73
|
}: MetricCardProps) {
|
|
59
74
|
const labelEl =
|
|
60
75
|
labelPosition === 'top' ? (
|
|
@@ -89,29 +104,49 @@ export function MetricCard({
|
|
|
89
104
|
</div>
|
|
90
105
|
)
|
|
91
106
|
|
|
107
|
+
const body =
|
|
108
|
+
labelPosition === 'top' ? (
|
|
109
|
+
<>
|
|
110
|
+
{labelEl}
|
|
111
|
+
{valueEl}
|
|
112
|
+
</>
|
|
113
|
+
) : (
|
|
114
|
+
<>
|
|
115
|
+
{valueEl}
|
|
116
|
+
{labelEl}
|
|
117
|
+
</>
|
|
118
|
+
)
|
|
119
|
+
const surfaceClass = className ?? 'flex flex-col gap-1 px-4 py-3 border'
|
|
120
|
+
const surfaceStyle = {
|
|
121
|
+
background: 'rgb(var(--surface-overlay-soft))',
|
|
122
|
+
borderColor: 'rgb(var(--border))',
|
|
123
|
+
borderRadius: 'var(--radius-lg)',
|
|
124
|
+
} as const
|
|
125
|
+
|
|
126
|
+
const text = copyValue ?? (typeof value === 'string' ? value : undefined)
|
|
127
|
+
if (copyable && text !== undefined) {
|
|
128
|
+
return (
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
onClick={() => void copyText(text, label)}
|
|
132
|
+
title={`Copy ${label}`}
|
|
133
|
+
aria-label={`Copy ${label}: ${text}`}
|
|
134
|
+
className={cn(
|
|
135
|
+
surfaceClass,
|
|
136
|
+
'text-left cursor-pointer transition-[border-color,box-shadow]',
|
|
137
|
+
'hover:border-[rgb(var(--border-strong))] hover:[box-shadow:var(--shadow-sm)]',
|
|
138
|
+
'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
|
|
139
|
+
)}
|
|
140
|
+
style={surfaceStyle}
|
|
141
|
+
>
|
|
142
|
+
{body}
|
|
143
|
+
</button>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
92
147
|
return (
|
|
93
|
-
<div
|
|
94
|
-
|
|
95
|
-
className ??
|
|
96
|
-
'flex flex-col gap-1 px-4 py-3 border'
|
|
97
|
-
}
|
|
98
|
-
style={{
|
|
99
|
-
background: 'rgb(var(--surface-overlay-soft))',
|
|
100
|
-
borderColor: 'rgb(var(--border))',
|
|
101
|
-
borderRadius: 'var(--radius-lg)',
|
|
102
|
-
}}
|
|
103
|
-
>
|
|
104
|
-
{labelPosition === 'top' ? (
|
|
105
|
-
<>
|
|
106
|
-
{labelEl}
|
|
107
|
-
{valueEl}
|
|
108
|
-
</>
|
|
109
|
-
) : (
|
|
110
|
-
<>
|
|
111
|
-
{valueEl}
|
|
112
|
-
{labelEl}
|
|
113
|
-
</>
|
|
114
|
-
)}
|
|
148
|
+
<div className={surfaceClass} style={surfaceStyle}>
|
|
149
|
+
{body}
|
|
115
150
|
</div>
|
|
116
151
|
)
|
|
117
152
|
}
|
package/src/modal.tsx
CHANGED
|
@@ -2,12 +2,16 @@ import {
|
|
|
2
2
|
Children,
|
|
3
3
|
isValidElement,
|
|
4
4
|
useEffect,
|
|
5
|
+
useId,
|
|
6
|
+
useRef,
|
|
5
7
|
type HTMLAttributes,
|
|
6
8
|
type ReactElement,
|
|
7
9
|
type ReactNode,
|
|
8
10
|
} from 'react'
|
|
9
11
|
import { X } from 'lucide-react'
|
|
10
12
|
import { cn } from './lib/utils'
|
|
13
|
+
import { tabbablesWithin } from './lib/focus'
|
|
14
|
+
import { useLayer } from './lib/layer-stack'
|
|
11
15
|
|
|
12
16
|
/**
|
|
13
17
|
* Modal — a centered floating panel on top of a backdrop. Built on
|
|
@@ -36,6 +40,42 @@ import { cn } from './lib/utils'
|
|
|
36
40
|
* <Button>Confirm</Button>
|
|
37
41
|
* </Modal.Footer>
|
|
38
42
|
* </Modal>
|
|
43
|
+
*
|
|
44
|
+
* Accessibility. The panel is a `role="dialog" aria-modal="true"` region
|
|
45
|
+
* named by its own `<h2>` (`aria-labelledby`), or by the `ariaLabel` prop
|
|
46
|
+
* when a caller renders a titleless modal. Opening moves focus to the
|
|
47
|
+
* panel — so a screen reader announces the dialog's name — and closing
|
|
48
|
+
* RESTORES focus to whatever was focused before it opened. Tab and
|
|
49
|
+
* Shift+Tab wrap inside the panel; Escape closes.
|
|
50
|
+
*
|
|
51
|
+
* Callers that want a specific control focused on open mark it
|
|
52
|
+
* `data-autofocus`; anything else and the panel itself takes focus.
|
|
53
|
+
*
|
|
54
|
+
* WHY A FOCUS TRAP AND NOT `inert` ON THE APP ROOT. Modal renders INLINE
|
|
55
|
+
* in the React tree — it is a descendant of `#root`, not a portal — so
|
|
56
|
+
* `inert` on the app root would make the modal itself inert. Portaling it
|
|
57
|
+
* to `document.body` to unlock that would move all 59 consuming files off
|
|
58
|
+
* the `.cs-frame`-scoped token layer and out of their current stacking /
|
|
59
|
+
* inheritance context, which is a much larger change than an a11y fix
|
|
60
|
+
* should make. Inerting `document.body`'s other children is also wrong:
|
|
61
|
+
* `DropdownMenu`, `Popover`, `TagChipInput` and `FolderTreePicker` portal
|
|
62
|
+
* their content THERE, so a select inside a modal would go dead. Focus trap
|
|
63
|
+
* it is — the keyboard hole is closed; pointer and AT virtual-cursor access
|
|
64
|
+
* to the background remains, which is the documented residual.
|
|
65
|
+
*
|
|
66
|
+
* Escape and dismiss order come from the shared layer stack
|
|
67
|
+
* (`lib/layer-stack.ts`): the topmost open surface — modal, menu or
|
|
68
|
+
* popover — answers Escape alone.
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Dismiss order lives in the shared layer stack (`lib/layer-stack.ts`),
|
|
73
|
+
* which this file used to own as a module-level `openPanels` array. Only
|
|
74
|
+
* the topmost layer answers Escape, so nested modals do not both close on
|
|
75
|
+
* one keypress — and now a DropdownMenu or Popover opened from inside a
|
|
76
|
+
* modal closes on its own first, leaving the modal open. The Tab trap asks
|
|
77
|
+
* for the topmost MODAL (`isTopOfKind`), so a popover above it does not
|
|
78
|
+
* switch the trap off.
|
|
39
79
|
*/
|
|
40
80
|
|
|
41
81
|
interface ModalProps {
|
|
@@ -43,18 +83,106 @@ interface ModalProps {
|
|
|
43
83
|
onClose: () => void
|
|
44
84
|
title: string
|
|
45
85
|
children: ReactNode
|
|
46
|
-
className?: string
|
|
86
|
+
className?: string | undefined
|
|
87
|
+
/**
|
|
88
|
+
* Accessible name for a modal rendered with an empty `title`. Ignored
|
|
89
|
+
* when `title` is non-empty — the `<h2>` names the dialog then.
|
|
90
|
+
*/
|
|
91
|
+
ariaLabel?: string | undefined
|
|
47
92
|
}
|
|
48
93
|
|
|
49
|
-
function Modal({ isOpen, onClose, title, children, className }: ModalProps) {
|
|
94
|
+
function Modal({ isOpen, onClose, title, children, className, ariaLabel }: ModalProps) {
|
|
95
|
+
const panelRef = useRef<HTMLDivElement | null>(null)
|
|
96
|
+
const titleId = useId()
|
|
97
|
+
|
|
98
|
+
// Focus: move in on open, restore on close. Keyed on `isOpen` ALONE —
|
|
99
|
+
// adding `onClose` here would re-run it on every render for the many
|
|
100
|
+
// callers that pass an inline arrow, snatching focus back to the panel
|
|
101
|
+
// mid-keystroke.
|
|
50
102
|
useEffect(() => {
|
|
51
103
|
if (!isOpen) return
|
|
52
|
-
const
|
|
53
|
-
|
|
104
|
+
const panel = panelRef.current
|
|
105
|
+
if (!panel) return
|
|
106
|
+
|
|
107
|
+
const previous =
|
|
108
|
+
document.activeElement instanceof HTMLElement &&
|
|
109
|
+
document.activeElement !== document.body
|
|
110
|
+
? document.activeElement
|
|
111
|
+
: null
|
|
112
|
+
|
|
113
|
+
const initial = panel.querySelector<HTMLElement>('[data-autofocus]')
|
|
114
|
+
;(initial ?? panel).focus()
|
|
115
|
+
|
|
116
|
+
return () => {
|
|
117
|
+
// Restoring is the half that gets forgotten: without it, closing a
|
|
118
|
+
// modal drops the caret to <body> and the next Tab restarts from
|
|
119
|
+
// the top of the page.
|
|
120
|
+
if (previous && previous.isConnected) previous.focus()
|
|
54
121
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
122
|
+
}, [isOpen])
|
|
123
|
+
|
|
124
|
+
// Escape: the shared stack dispatches it to the topmost layer only, and
|
|
125
|
+
// reads `onClose` through a ref so an inline arrow does not re-register
|
|
126
|
+
// (or reorder) the layer on every render.
|
|
127
|
+
const layer = useLayer({
|
|
128
|
+
enabled: isOpen,
|
|
129
|
+
kind: 'modal',
|
|
130
|
+
elementRef: panelRef,
|
|
131
|
+
onEscape: () => onClose(),
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// Tab trap. One document listener; the topmost-MODAL guard keeps nested
|
|
135
|
+
// modals from both reacting, and keeps this trap live while a popover
|
|
136
|
+
// sits above it.
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
if (!isOpen) return
|
|
139
|
+
const panel = panelRef.current
|
|
140
|
+
if (!panel) return
|
|
141
|
+
|
|
142
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
143
|
+
if (e.key !== 'Tab') return
|
|
144
|
+
if (!layer.isTopOfKind()) return
|
|
145
|
+
|
|
146
|
+
const active = document.activeElement
|
|
147
|
+
// Focus is outside the panel — almost always a body-level portal
|
|
148
|
+
// (DropdownMenu / TagChipInput / FolderTreePicker) opened FROM the
|
|
149
|
+
// modal. Wrapping it back in would break that menu's own keyboard
|
|
150
|
+
// handling, so leave the event alone.
|
|
151
|
+
if (!(active instanceof HTMLElement) || !panel.contains(active)) return
|
|
152
|
+
|
|
153
|
+
const tabbables = tabbablesWithin(panel)
|
|
154
|
+
const first = tabbables[0]
|
|
155
|
+
const last = tabbables[tabbables.length - 1]
|
|
156
|
+
if (!first || !last) {
|
|
157
|
+
// Nothing to land on — keep focus on the panel rather than let
|
|
158
|
+
// Tab walk out into the page behind the backdrop.
|
|
159
|
+
e.preventDefault()
|
|
160
|
+
panel.focus()
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The panel holds focus itself right after open. Forward Tab can
|
|
165
|
+
// fall through to `first` naturally; Shift+Tab would leave the
|
|
166
|
+
// dialog, so it wraps to `last`.
|
|
167
|
+
if (active === panel) {
|
|
168
|
+
if (e.shiftKey) {
|
|
169
|
+
e.preventDefault()
|
|
170
|
+
last.focus()
|
|
171
|
+
}
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
if (e.shiftKey && active === first) {
|
|
175
|
+
e.preventDefault()
|
|
176
|
+
last.focus()
|
|
177
|
+
} else if (!e.shiftKey && active === last) {
|
|
178
|
+
e.preventDefault()
|
|
179
|
+
first.focus()
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
184
|
+
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
185
|
+
}, [isOpen, layer])
|
|
58
186
|
|
|
59
187
|
if (!isOpen) return null
|
|
60
188
|
|
|
@@ -69,8 +197,17 @@ function Modal({ isOpen, onClose, title, children, className }: ModalProps) {
|
|
|
69
197
|
{/* Outer card — uses .ds-card-surface so the modal gets the
|
|
70
198
|
same chrome as cards (border + multi-layer shadow + inner
|
|
71
199
|
highlight in light mode). overflow-hidden clips children to
|
|
72
|
-
the rounded corners; the tray inside handles its own scroll.
|
|
200
|
+
the rounded corners; the tray inside handles its own scroll.
|
|
201
|
+
|
|
202
|
+
tabIndex={-1} makes the panel programmatically focusable so it
|
|
203
|
+
can receive focus on open without joining the tab ring. */}
|
|
73
204
|
<div
|
|
205
|
+
ref={panelRef}
|
|
206
|
+
role="dialog"
|
|
207
|
+
aria-modal="true"
|
|
208
|
+
aria-labelledby={title ? titleId : undefined}
|
|
209
|
+
aria-label={title ? undefined : ariaLabel}
|
|
210
|
+
tabIndex={-1}
|
|
74
211
|
data-framed="true"
|
|
75
212
|
className={cn(
|
|
76
213
|
'ds-card-surface relative w-full max-w-2xl flex flex-col max-h-[90vh] overflow-hidden',
|
|
@@ -89,7 +226,10 @@ function Modal({ isOpen, onClose, title, children, className }: ModalProps) {
|
|
|
89
226
|
than the text, the BUTTON set the row height and the title
|
|
90
227
|
floated in slack it never asked for. */}
|
|
91
228
|
<div className="flex items-center justify-between gap-3 shrink-0 px-4 py-3">
|
|
92
|
-
<h2
|
|
229
|
+
<h2
|
|
230
|
+
id={titleId}
|
|
231
|
+
className="text-[15px] font-bold tracking-[-0.01em] text-[rgb(var(--foreground))]"
|
|
232
|
+
>
|
|
93
233
|
{title}
|
|
94
234
|
</h2>
|
|
95
235
|
<button
|
package/src/page-shell.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from 'react'
|
|
11
11
|
import { createPortal } from 'react-dom'
|
|
12
12
|
import { Link } from 'react-router-dom'
|
|
13
|
+
import { House } from 'lucide-react'
|
|
13
14
|
import { cn } from './lib/utils'
|
|
14
15
|
|
|
15
16
|
export type Crumb = {
|
|
@@ -60,7 +61,24 @@ interface PageShellProps {
|
|
|
60
61
|
// per mount, never per render).
|
|
61
62
|
// ---------------------------------------------------------------------------
|
|
62
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The app's home destination, rendered as the first breadcrumb.
|
|
66
|
+
*
|
|
67
|
+
* PER-APP AND OPT-IN, which is the whole reason it lives on the provider
|
|
68
|
+
* instead of being hardcoded in `Breadcrumbs`. `PageShell` is shared: the
|
|
69
|
+
* workspace and `apps/approvals` both render it, and approvals has no
|
|
70
|
+
* `/clients` route — a hardcoded home would have shipped it a dead link on
|
|
71
|
+
* every page. An app that wants a home crumb says where home is; an app that
|
|
72
|
+
* does not gets no icon and nothing breaks.
|
|
73
|
+
*/
|
|
74
|
+
export interface HomeCrumb {
|
|
75
|
+
to: string
|
|
76
|
+
/** Accessible name for the icon-only link, e.g. "Clients". */
|
|
77
|
+
label: string
|
|
78
|
+
}
|
|
79
|
+
|
|
63
80
|
interface PageHeaderSlotValue {
|
|
81
|
+
home: HomeCrumb | null
|
|
64
82
|
hostEl: HTMLElement | null
|
|
65
83
|
centerEl: HTMLElement | null
|
|
66
84
|
scrollEl: HTMLElement | null
|
|
@@ -71,7 +89,14 @@ interface PageHeaderSlotValue {
|
|
|
71
89
|
|
|
72
90
|
const PageHeaderSlotContext = createContext<PageHeaderSlotValue | null>(null)
|
|
73
91
|
|
|
74
|
-
export function PageHeaderSlotProvider({
|
|
92
|
+
export function PageHeaderSlotProvider({
|
|
93
|
+
children,
|
|
94
|
+
home = null,
|
|
95
|
+
}: {
|
|
96
|
+
children: ReactNode
|
|
97
|
+
/** Where the leading home crumb points. Omit to render no home crumb. */
|
|
98
|
+
home?: HomeCrumb | null
|
|
99
|
+
}) {
|
|
75
100
|
const [hostEl, setHostEl] = useState<HTMLElement | null>(null)
|
|
76
101
|
const [centerEl, setCenterEl] = useState<HTMLElement | null>(null)
|
|
77
102
|
const [scrollEl, setScrollEl] = useState<HTMLElement | null>(null)
|
|
@@ -84,6 +109,7 @@ export function PageHeaderSlotProvider({ children }: { children: ReactNode }) {
|
|
|
84
109
|
return (
|
|
85
110
|
<PageHeaderSlotContext.Provider
|
|
86
111
|
value={{
|
|
112
|
+
home,
|
|
87
113
|
hostEl,
|
|
88
114
|
centerEl,
|
|
89
115
|
scrollEl,
|
|
@@ -375,6 +401,50 @@ export function PageHeaderHost({
|
|
|
375
401
|
)
|
|
376
402
|
}
|
|
377
403
|
|
|
404
|
+
/**
|
|
405
|
+
* The app's home destination, or null when the app has not declared one.
|
|
406
|
+
*
|
|
407
|
+
* Exported because not every breadcrumb in the app goes through `PageShell` —
|
|
408
|
+
* the Generate lens renders its own trail beside an editable project title —
|
|
409
|
+
* and those forks should read the same destination rather than each hardcoding
|
|
410
|
+
* a path that then drifts.
|
|
411
|
+
*/
|
|
412
|
+
export function useBreadcrumbHome(): HomeCrumb | null {
|
|
413
|
+
return useContext(PageHeaderSlotContext)?.home ?? null
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* The leading home crumb: an icon, not a word.
|
|
418
|
+
*
|
|
419
|
+
* WHY AN ICON REPLACES THE FIRST CRUMB RATHER THAN SITTING BESIDE IT. Thirty-one
|
|
420
|
+
* lens trails already began `Clients / …` pointing at `/clients`, which is
|
|
421
|
+
* exactly where home goes — so rendering both would put the same destination on
|
|
422
|
+
* screen twice in a row, once as a picture and once as a word. The icon takes
|
|
423
|
+
* that crumb's place when they agree and prepends when they do not (the tools
|
|
424
|
+
* trails start at `/tools`), so "first position" holds either way.
|
|
425
|
+
*
|
|
426
|
+
* It carries an `aria-label` and a `title`: an icon-only link has no accessible
|
|
427
|
+
* name otherwise, and the tooltip is what tells a first-time user that the house
|
|
428
|
+
* means the client list rather than some other notion of home.
|
|
429
|
+
*/
|
|
430
|
+
export function HomeCrumbLink({ home }: { home: HomeCrumb }) {
|
|
431
|
+
return (
|
|
432
|
+
<Link
|
|
433
|
+
to={home.to}
|
|
434
|
+
aria-label={home.label}
|
|
435
|
+
title={home.label}
|
|
436
|
+
className="shrink-0 flex items-center transition-colors hover:text-[rgb(var(--foreground))]"
|
|
437
|
+
style={{ color: 'inherit' }}
|
|
438
|
+
>
|
|
439
|
+
{/* 15px beside 13px crumb text, at the 1.9 stroke the theme toggle in
|
|
440
|
+
this same header band uses — the app's chrome runs heavier than
|
|
441
|
+
Lucide's default, and matching the neighbour matters more than the
|
|
442
|
+
generic rule. */}
|
|
443
|
+
<House className="h-[15px] w-[15px]" strokeWidth={1.9} aria-hidden="true" />
|
|
444
|
+
</Link>
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
|
|
378
448
|
function Breadcrumbs({
|
|
379
449
|
parents,
|
|
380
450
|
current,
|
|
@@ -382,12 +452,31 @@ function Breadcrumbs({
|
|
|
382
452
|
parents: Crumb[]
|
|
383
453
|
current: Crumb | undefined
|
|
384
454
|
}) {
|
|
455
|
+
const home = useBreadcrumbHome()
|
|
456
|
+
// When the first crumb already IS home, the icon stands in for it rather
|
|
457
|
+
// than duplicating it. Compared by destination, not by label, because the
|
|
458
|
+
// label varies ("Clients", a brand name) while the route does not.
|
|
459
|
+
const visibleParents =
|
|
460
|
+
home && parents[0]?.to === home.to ? parents.slice(1) : parents
|
|
461
|
+
|
|
385
462
|
return (
|
|
386
463
|
<nav
|
|
387
464
|
className="flex items-center gap-1.5 text-[13px] min-w-0"
|
|
388
465
|
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
389
466
|
>
|
|
390
|
-
{
|
|
467
|
+
{home && (
|
|
468
|
+
<>
|
|
469
|
+
<HomeCrumbLink home={home} />
|
|
470
|
+
{/* Separator only when something follows it. A page with no crumbs
|
|
471
|
+
at all would otherwise render a dangling "home /". */}
|
|
472
|
+
{(visibleParents.length > 0 || current) && (
|
|
473
|
+
<span className="shrink-0" style={{ color: 'rgb(var(--text-muted))' }}>
|
|
474
|
+
/
|
|
475
|
+
</span>
|
|
476
|
+
)}
|
|
477
|
+
</>
|
|
478
|
+
)}
|
|
479
|
+
{visibleParents.map((c, i) => (
|
|
391
480
|
<span key={i} className="flex items-center gap-1.5 min-w-0">
|
|
392
481
|
{c.to ? (
|
|
393
482
|
<Link
|