@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/lib/focus.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tabbable-element query shared by the focus-managing surfaces (Modal's
|
|
3
|
+
* trap, Popover's focus-on-open). Extracted from modal.tsx.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately attribute-only — no geometry check. `offsetWidth` /
|
|
6
|
+
* `getClientRects()` are always zero in jsdom, so a visibility filter would
|
|
7
|
+
* empty this list under test and silently disable the trap in exactly the
|
|
8
|
+
* environment that verifies it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const FOCUSABLE_SELECTOR = [
|
|
12
|
+
'a[href]',
|
|
13
|
+
'area[href]',
|
|
14
|
+
'button:not([disabled])',
|
|
15
|
+
'input:not([disabled]):not([type="hidden"])',
|
|
16
|
+
'select:not([disabled])',
|
|
17
|
+
'textarea:not([disabled])',
|
|
18
|
+
'iframe',
|
|
19
|
+
'summary',
|
|
20
|
+
'[contenteditable="true"]',
|
|
21
|
+
'[tabindex]',
|
|
22
|
+
].join(',')
|
|
23
|
+
|
|
24
|
+
export function tabbablesWithin(root: HTMLElement): HTMLElement[] {
|
|
25
|
+
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
|
26
|
+
(el) =>
|
|
27
|
+
el.tabIndex >= 0 &&
|
|
28
|
+
!el.hasAttribute('inert') &&
|
|
29
|
+
el.getAttribute('aria-hidden') !== 'true' &&
|
|
30
|
+
!el.closest('[hidden]') &&
|
|
31
|
+
// `inert` is INHERITED by the subtree in a browser, so a control inside
|
|
32
|
+
// an inert ancestor cannot take focus — a trap that hands it focus
|
|
33
|
+
// anyway simply stops moving. The detail surface collapses a pane by
|
|
34
|
+
// making it inert rather than unmounting it (its scroll and its drafts
|
|
35
|
+
// have to survive), which is the case that made this ancestor check
|
|
36
|
+
// load-bearing rather than tidy.
|
|
37
|
+
!el.closest('[inert]'),
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Keep Tab inside `container`.
|
|
43
|
+
*
|
|
44
|
+
* `Popover` is non-modal by design: it moves focus in and hands it back, but
|
|
45
|
+
* Tab walks straight out of it into the page behind. For a surface that is a
|
|
46
|
+
* self-contained panel of controls — the Display popover, the filter picker —
|
|
47
|
+
* that reads as the panel losing focus mid-use, so those surfaces wrap the
|
|
48
|
+
* cycle themselves. Call from a `keydown` handler on the container.
|
|
49
|
+
*
|
|
50
|
+
* Does nothing unless the key is Tab, and nothing when the container holds
|
|
51
|
+
* fewer than two tabbables (a single control has nowhere to cycle to and
|
|
52
|
+
* preventing default there would trap the user on it).
|
|
53
|
+
*/
|
|
54
|
+
export function handleTabTrap(
|
|
55
|
+
event: { key: string; shiftKey: boolean; preventDefault: () => void },
|
|
56
|
+
container: HTMLElement | null,
|
|
57
|
+
): void {
|
|
58
|
+
if (event.key !== 'Tab' || !container) return
|
|
59
|
+
const tabbables = tabbablesWithin(container)
|
|
60
|
+
if (tabbables.length < 2) return
|
|
61
|
+
const first = tabbables[0]
|
|
62
|
+
const last = tabbables[tabbables.length - 1]
|
|
63
|
+
if (!first || !last) return
|
|
64
|
+
const active = document.activeElement
|
|
65
|
+
if (event.shiftKey && (active === first || !container.contains(active))) {
|
|
66
|
+
event.preventDefault()
|
|
67
|
+
last.focus()
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
if (!event.shiftKey && active === last) {
|
|
71
|
+
event.preventDefault()
|
|
72
|
+
first.focus()
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
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
|
+
/**
|
|
62
|
+
* MUTABLE, and the one field that is.
|
|
63
|
+
*
|
|
64
|
+
* A surface whose kind CHANGES while it is open must not lose its place in
|
|
65
|
+
* the stack over it. The item detail is the case that found this: its three
|
|
66
|
+
* modes are one element tree, and `panel` is non-modal while `full` and
|
|
67
|
+
* `modal` are, so switching mode flips this from 'modal' to 'popover'. When
|
|
68
|
+
* `kind` was an effect dependency, that flip popped and re-pushed the
|
|
69
|
+
* layer — landing the detail ABOVE a dropdown menu that was already open,
|
|
70
|
+
* so the next Escape closed the whole detail instead of the menu inside it.
|
|
71
|
+
* (Switching mode is done FROM that menu, so it was the common path, not an
|
|
72
|
+
* edge case.)
|
|
73
|
+
*
|
|
74
|
+
* Updating in place keeps push order meaning what it says: the order things
|
|
75
|
+
* OPENED, not the order they last changed shape.
|
|
76
|
+
*/
|
|
77
|
+
kind: LayerKind
|
|
78
|
+
readonly element: () => HTMLElement | null
|
|
79
|
+
readonly onEscape: (event: KeyboardEvent) => void
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const stack: LayerRecord[] = []
|
|
83
|
+
let listening = false
|
|
84
|
+
|
|
85
|
+
function onDocumentKeyDown(event: KeyboardEvent): void {
|
|
86
|
+
if (event.key !== 'Escape') return
|
|
87
|
+
const top = stack[stack.length - 1]
|
|
88
|
+
if (!top) return
|
|
89
|
+
// Consumed here. Window-level listeners (hotkey maps, drawers) must not
|
|
90
|
+
// see an Escape that closed a layer — same contract DropdownMenu's own
|
|
91
|
+
// listener had before the stack existed.
|
|
92
|
+
event.stopPropagation()
|
|
93
|
+
top.onEscape(event)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function pushLayer(record: LayerRecord): () => void {
|
|
97
|
+
stack.push(record)
|
|
98
|
+
if (!listening) {
|
|
99
|
+
document.addEventListener('keydown', onDocumentKeyDown)
|
|
100
|
+
listening = true
|
|
101
|
+
}
|
|
102
|
+
return () => {
|
|
103
|
+
const at = stack.lastIndexOf(record)
|
|
104
|
+
if (at !== -1) stack.splice(at, 1)
|
|
105
|
+
if (stack.length === 0 && listening) {
|
|
106
|
+
document.removeEventListener('keydown', onDocumentKeyDown)
|
|
107
|
+
listening = false
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface UseLayerOptions {
|
|
113
|
+
/** Register while true; pop when it turns false or the owner unmounts. */
|
|
114
|
+
enabled: boolean
|
|
115
|
+
kind: LayerKind
|
|
116
|
+
/**
|
|
117
|
+
* The surface's root element. Read lazily, so a ref that is filled in
|
|
118
|
+
* after the layer registers (portal content) still resolves.
|
|
119
|
+
*/
|
|
120
|
+
elementRef?: RefObject<HTMLElement | null>
|
|
121
|
+
/** Called when this layer is topmost and Escape is pressed. */
|
|
122
|
+
onEscape: (event: KeyboardEvent) => void
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Register a dismiss layer for as long as `enabled` holds. `onEscape` is
|
|
127
|
+
* read through a ref, so an inline arrow does not re-register the layer
|
|
128
|
+
* (and does not reorder it) on every render.
|
|
129
|
+
*/
|
|
130
|
+
export function useLayer(options: UseLayerOptions): LayerHandle {
|
|
131
|
+
const { enabled, kind, elementRef, onEscape } = options
|
|
132
|
+
|
|
133
|
+
const onEscapeRef = useRef(onEscape)
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
onEscapeRef.current = onEscape
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
// Read through a ref for the same reason `onEscape` is: so that changing it
|
|
139
|
+
// updates the layer rather than reordering the stack. See `LayerRecord.kind`.
|
|
140
|
+
const kindRef = useRef(kind)
|
|
141
|
+
kindRef.current = kind
|
|
142
|
+
|
|
143
|
+
const recordRef = useRef<LayerRecord | null>(null)
|
|
144
|
+
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
if (!enabled) return
|
|
147
|
+
const record: LayerRecord = {
|
|
148
|
+
kind: kindRef.current,
|
|
149
|
+
element: () => elementRef?.current ?? null,
|
|
150
|
+
onEscape: (event) => onEscapeRef.current(event),
|
|
151
|
+
}
|
|
152
|
+
recordRef.current = record
|
|
153
|
+
const pop = pushLayer(record)
|
|
154
|
+
return () => {
|
|
155
|
+
pop()
|
|
156
|
+
if (recordRef.current === record) recordRef.current = null
|
|
157
|
+
}
|
|
158
|
+
}, [enabled, elementRef])
|
|
159
|
+
|
|
160
|
+
// A kind change updates the record IN PLACE, keeping its position.
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
const record = recordRef.current
|
|
163
|
+
if (record !== null) record.kind = kind
|
|
164
|
+
}, [kind])
|
|
165
|
+
|
|
166
|
+
return useMemo<LayerHandle>(
|
|
167
|
+
() => ({
|
|
168
|
+
isTop: () => {
|
|
169
|
+
const record = recordRef.current
|
|
170
|
+
return record !== null && stack[stack.length - 1] === record
|
|
171
|
+
},
|
|
172
|
+
isTopOfKind: () => {
|
|
173
|
+
const record = recordRef.current
|
|
174
|
+
if (!record) return false
|
|
175
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
176
|
+
const layer = stack[i]
|
|
177
|
+
if (layer === record) return true
|
|
178
|
+
if (layer?.kind === record.kind) return false
|
|
179
|
+
}
|
|
180
|
+
return false
|
|
181
|
+
},
|
|
182
|
+
containsInLayerAbove: (node) => {
|
|
183
|
+
const record = recordRef.current
|
|
184
|
+
if (!record) return false
|
|
185
|
+
const at = stack.indexOf(record)
|
|
186
|
+
if (at === -1) return false
|
|
187
|
+
for (let i = at + 1; i < stack.length; i++) {
|
|
188
|
+
if (stack[i]?.element()?.contains(node)) return true
|
|
189
|
+
}
|
|
190
|
+
return false
|
|
191
|
+
},
|
|
192
|
+
}),
|
|
193
|
+
[],
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface UseEscapeKeyOptions {
|
|
198
|
+
/** Default true. */
|
|
199
|
+
enabled?: boolean
|
|
200
|
+
/** Default `popover`. */
|
|
201
|
+
kind?: LayerKind
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Escape-only participant in the layer stack. Fires `onEscape` only while
|
|
206
|
+
* this is the topmost layer; a layer that opens later takes over until it
|
|
207
|
+
* closes.
|
|
208
|
+
*/
|
|
209
|
+
export function useEscapeKey(
|
|
210
|
+
onEscape: (event: KeyboardEvent) => void,
|
|
211
|
+
options: UseEscapeKeyOptions = {},
|
|
212
|
+
): LayerHandle {
|
|
213
|
+
const { enabled = true, kind = 'popover' } = options
|
|
214
|
+
return useLayer({ enabled, kind, onEscape })
|
|
215
|
+
}
|
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
|
+
}
|