@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.
Files changed (158) hide show
  1. package/dist/chunk-RBYWGBQ2.js +2752 -0
  2. package/dist/chunk-RBYWGBQ2.js.map +1 -0
  3. package/dist/index.d.ts +6064 -80
  4. package/dist/index.js +21561 -9696
  5. package/dist/index.js.map +1 -1
  6. package/dist/rich-composer-impl-5NO443A6.js +1859 -0
  7. package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
  8. package/dist/styles.css +1586 -2
  9. package/dist/theme-v2.css +228 -0
  10. package/dist/tokens.css +121 -0
  11. package/package.json +8 -1
  12. package/src/__tests__/anchor.test.tsx +422 -0
  13. package/src/__tests__/avatar.test.tsx +272 -0
  14. package/src/__tests__/bar-chart.test.tsx +809 -0
  15. package/src/__tests__/board.test.tsx +420 -0
  16. package/src/__tests__/chart-math.test.ts +922 -0
  17. package/src/__tests__/chart-series.test.ts +339 -0
  18. package/src/__tests__/code-block.test.tsx +134 -0
  19. package/src/__tests__/combobox.test.tsx +677 -0
  20. package/src/__tests__/display-popover.test.tsx +195 -0
  21. package/src/__tests__/display-store.test.tsx +208 -0
  22. package/src/__tests__/donut-chart.test.tsx +397 -0
  23. package/src/__tests__/dropdown-menu.test.tsx +572 -0
  24. package/src/__tests__/filter-menu.test.tsx +175 -0
  25. package/src/__tests__/gauge-ring.test.tsx +233 -0
  26. package/src/__tests__/helpers/geometry.ts +58 -0
  27. package/src/__tests__/layer-stack.test.tsx +228 -0
  28. package/src/__tests__/line-chart.test.tsx +612 -0
  29. package/src/__tests__/popover.test.tsx +460 -0
  30. package/src/__tests__/ranked-bars.test.tsx +343 -0
  31. package/src/__tests__/remark-underline.test.ts +194 -0
  32. package/src/__tests__/select.test.tsx +543 -0
  33. package/src/__tests__/sparkline.test.tsx +368 -0
  34. package/src/__tests__/stat-layer.test.tsx +271 -0
  35. package/src/__tests__/stat-strip.test.tsx +175 -0
  36. package/src/__tests__/status.test.tsx +293 -0
  37. package/src/__tests__/tabs.test.tsx +303 -0
  38. package/src/__tests__/token-shape.test.ts +132 -2
  39. package/src/__tests__/tooltip.test.tsx +355 -0
  40. package/src/avatar.tsx +352 -0
  41. package/src/bar-chart.tsx +1214 -0
  42. package/src/board.tsx +658 -0
  43. package/src/calculator-shell-v2.tsx +19 -39
  44. package/src/chart-frame.tsx +960 -0
  45. package/src/chart-legend.tsx +304 -0
  46. package/src/chart-tooltip.tsx +267 -0
  47. package/src/code-block.tsx +62 -8
  48. package/src/combobox.tsx +796 -0
  49. package/src/delta-chip.tsx +263 -0
  50. package/src/detail/__tests__/activity-pane.test.tsx +186 -0
  51. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  52. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  53. package/src/detail/__tests__/field-row.test.tsx +357 -0
  54. package/src/detail/activity-pane.tsx +507 -0
  55. package/src/detail/calendar.tsx +355 -0
  56. package/src/detail/detail-divider.tsx +261 -0
  57. package/src/detail/detail-header.tsx +287 -0
  58. package/src/detail/detail-menu.tsx +254 -0
  59. package/src/detail/detail-surface.tsx +1110 -0
  60. package/src/detail/field-list.tsx +196 -0
  61. package/src/detail/field-row.tsx +1131 -0
  62. package/src/detail/index.ts +58 -0
  63. package/src/detail/segmented-choice.tsx +94 -0
  64. package/src/detail/types.ts +129 -0
  65. package/src/display-popover.tsx +487 -0
  66. package/src/display-store.tsx +242 -0
  67. package/src/donut-chart.tsx +988 -0
  68. package/src/dropdown-menu.tsx +412 -151
  69. package/src/filter-core/EXPORTS.md +85 -0
  70. package/src/filter-core/__tests__/columns.test.ts +159 -0
  71. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  72. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  73. package/src/filter-core/__tests__/operators.test.ts +235 -0
  74. package/src/filter-core/__tests__/state.test.ts +268 -0
  75. package/src/filter-core/__tests__/url.test.ts +350 -0
  76. package/src/filter-core/columns.ts +134 -0
  77. package/src/filter-core/date-utils.ts +38 -0
  78. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  79. package/src/filter-core/faceting.ts +120 -0
  80. package/src/filter-core/filter-fns.ts +335 -0
  81. package/src/filter-core/index.ts +105 -0
  82. package/src/filter-core/operators.ts +433 -0
  83. package/src/filter-core/state.ts +280 -0
  84. package/src/filter-core/types.ts +247 -0
  85. package/src/filter-core/url.ts +261 -0
  86. package/src/filter-dropdown.tsx +12 -0
  87. package/src/filter-menu.tsx +649 -0
  88. package/src/floating-drawer.tsx +19 -1
  89. package/src/gauge-ring.tsx +435 -0
  90. package/src/hue.ts +52 -0
  91. package/src/index.ts +402 -0
  92. package/src/kbd.tsx +27 -4
  93. package/src/lib/anchor.ts +427 -0
  94. package/src/lib/chart.ts +866 -0
  95. package/src/lib/focus.ts +74 -0
  96. package/src/lib/layer-stack.ts +215 -0
  97. package/src/lib/refs.ts +31 -0
  98. package/src/lib/remark-underline.ts +443 -0
  99. package/src/lib/series.ts +169 -0
  100. package/src/line-chart.tsx +1176 -0
  101. package/src/markdown.tsx +26 -7
  102. package/src/metric-card.tsx +57 -22
  103. package/src/modal.tsx +73 -66
  104. package/src/popover.tsx +407 -0
  105. package/src/progress-ledger.tsx +304 -0
  106. package/src/ranked-bars.tsx +386 -0
  107. package/src/segmented-pill.tsx +32 -9
  108. package/src/select.tsx +646 -0
  109. package/src/sortable.tsx +315 -1
  110. package/src/sparkline.tsx +416 -0
  111. package/src/stat-card.tsx +376 -0
  112. package/src/stat-row.tsx +108 -70
  113. package/src/stat-strip.tsx +327 -0
  114. package/src/status.tsx +215 -0
  115. package/src/styles.css +1586 -2
  116. package/src/tabs.tsx +206 -25
  117. package/src/task-card.tsx +610 -0
  118. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  119. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  120. package/src/thread/__tests__/emoji.test.ts +187 -0
  121. package/src/thread/__tests__/fixtures/thread-fixture.ts +235 -0
  122. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  123. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  124. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  125. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  126. package/src/thread/__tests__/relative-time.test.ts +71 -0
  127. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  128. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  129. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  130. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  131. package/src/thread/__tests__/thread.test.tsx +662 -0
  132. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  133. package/src/thread/actions.tsx +196 -0
  134. package/src/thread/attachments.tsx +1071 -0
  135. package/src/thread/comment-body.tsx +148 -0
  136. package/src/thread/comment-tree.ts +182 -0
  137. package/src/thread/comment.tsx +915 -0
  138. package/src/thread/composer-footer.tsx +125 -0
  139. package/src/thread/composer.tsx +319 -0
  140. package/src/thread/emoji.ts +283 -0
  141. package/src/thread/index.ts +153 -0
  142. package/src/thread/link-preview.tsx +341 -0
  143. package/src/thread/markdown-format.ts +155 -0
  144. package/src/thread/markdown-spec.ts +754 -0
  145. package/src/thread/rail.tsx +372 -0
  146. package/src/thread/reactions.tsx +415 -0
  147. package/src/thread/relative-time.tsx +94 -0
  148. package/src/thread/rich-composer-impl.tsx +1601 -0
  149. package/src/thread/rich-composer.tsx +195 -0
  150. package/src/thread/scroll-caret.ts +37 -0
  151. package/src/thread/suggestion-list.tsx +182 -0
  152. package/src/thread/thread.tsx +718 -0
  153. package/src/thread/types.ts +218 -0
  154. package/src/thread/use-attachments.ts +598 -0
  155. package/src/thread/use-now.ts +73 -0
  156. package/src/thread/use-thread.ts +316 -0
  157. package/src/tokens.css +121 -0
  158. package/src/tooltip.tsx +297 -0
@@ -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
+ }
@@ -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
+ }