@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,195 @@
1
+ /**
2
+ * RichThreadComposer — the shell around the TipTap composer.
3
+ *
4
+ * ADR-148 D5 / D6. This file satisfies `ThreadComposerProps` exactly, so
5
+ * `<Thread composer="rich">` cannot tell it from `ThreadComposer`, and it is
6
+ * the ONLY thing the package barrel exports for the rich editor. It imports
7
+ * no TipTap: the editor lives in `rich-composer-impl.tsx` behind the single
8
+ * `lazy()` boundary below, which is what keeps ProseMirror out of every
9
+ * consumer that renders a thread LIST (AC#10). The chunk is requested when a
10
+ * composer MOUNTS — not on focus, which would need a placeholder control, a
11
+ * swap and a caret restore for the bytes it would save.
12
+ *
13
+ * Three states, and a dead composer is never one of them (D6):
14
+ *
15
+ * loading — the plain composer, disabled, holding the draft. Same `rows`,
16
+ * same padding, and the editor reserves the same height, so the
17
+ * swap moves nothing.
18
+ * ready — the editor (the impl decides, before it mounts anything, whether
19
+ * the draft is even rich-editable — D3 rule 2 — and if not,
20
+ * renders the plain composer with the source untouched).
21
+ * failed — the import rejected (offline, a chunk that 404s after a deploy,
22
+ * a CSP block): the plain composer with the same draft, and a
23
+ * one-line notice. Nothing is lost, because the draft is markdown
24
+ * (D1). Reported once, politely, never with a modal.
25
+ *
26
+ * The live region here is the composer's own. `ThreadComposerProps` carries no
27
+ * `announce` seam to the thread's single region, and adding one means editing
28
+ * composer.tsx and comment.tsx, which this ADR's scope left alone — noted in
29
+ * the completion report as the two-line follow-up it is.
30
+ */
31
+
32
+ import { Component, Suspense, lazy, useCallback, useState } from 'react'
33
+ import type { ErrorInfo, ReactNode } from 'react'
34
+ import { Info } from 'lucide-react'
35
+ import { ThreadComposer, type ThreadComposerProps } from './composer'
36
+ import type { GifPickerResult } from './attachments'
37
+ import type { MentionSuggestionItem } from './types'
38
+ import type { UseAttachmentsOptions } from './use-attachments'
39
+
40
+ const RichThreadComposerImpl = lazy(() => import('./rich-composer-impl'))
41
+
42
+ /** D6, in the words the live region speaks. */
43
+ export const RICH_FALLBACK_NOTICE = 'Rich editor unavailable, plain editor loaded'
44
+
45
+ /** The one-line notice above a plain composer that stands in for the rich one. */
46
+ export function ComposerNotice({ children }: { readonly children: ReactNode }) {
47
+ return (
48
+ <p
49
+ className="flex items-center gap-[var(--space-1)] text-[12px]"
50
+ style={{ color: 'rgb(var(--text-tertiary))' }}
51
+ >
52
+ <Info size={12} strokeWidth={1.75} aria-hidden="true" />
53
+ <span>{children}</span>
54
+ </p>
55
+ )
56
+ }
57
+
58
+ interface ChunkBoundaryProps {
59
+ readonly fallback: ReactNode
60
+ readonly onError: (error: unknown) => void
61
+ readonly children: ReactNode
62
+ }
63
+
64
+ /**
65
+ * `lazy()` reports a rejected import by throwing during render; a boundary is
66
+ * the only place React lets that be caught. It renders the plain composer in
67
+ * place and tells the shell, which then owns the failed state for good — a
68
+ * rejected `lazy` stays rejected for the life of the page anyway.
69
+ */
70
+ class ChunkBoundary extends Component<ChunkBoundaryProps, { readonly failed: boolean }> {
71
+ override state = { failed: false }
72
+
73
+ static getDerivedStateFromError(): { failed: boolean } {
74
+ return { failed: true }
75
+ }
76
+
77
+ override componentDidCatch(error: unknown, _info: ErrorInfo): void {
78
+ this.props.onError(error)
79
+ }
80
+
81
+ override render(): ReactNode {
82
+ return this.state.failed ? this.props.fallback : this.props.children
83
+ }
84
+ }
85
+
86
+ /**
87
+ * `ThreadComposerProps` plus the optional host capabilities added by ADR-149
88
+ * and ADR-151: mention lookup, upload + ticket verification, GIF search, and
89
+ * the worker's attachment limits. `<Thread composer="rich">` can still hand
90
+ * this component a bare `ThreadComposerProps`; absent capabilities simply do
91
+ * not render controls that cannot work.
92
+ *
93
+ * They stop here rather than continuing down to `ThreadComposer`: the plain
94
+ * textarea has neither a suggestion layer nor attachment state, so fallback
95
+ * paths deliberately drop them without offering inert controls.
96
+ */
97
+ export interface RichComposerAttachmentProps {
98
+ /** Upload bytes and return the worker-verified `tup_...` ticket. */
99
+ readonly uploadAttachment?: NonNullable<UseAttachmentsOptions['upload']> | undefined
100
+ /** Turn a Giphy id into the same verified ticket shape. */
101
+ readonly attachGif?: NonNullable<UseAttachmentsOptions['attachGif']> | undefined
102
+ /** Search results stay host-owned so no API URL or key enters this package. */
103
+ readonly searchGifs?: ((query: string) => Promise<readonly GifPickerResult[]>) | undefined
104
+ /** Mirrors the worker's extension allowlist for the native file chooser. */
105
+ readonly attachmentAccept?: string | undefined
106
+ readonly attachmentMaxPerComment?: number | undefined
107
+ readonly attachmentMaxFileBytes?: number | undefined
108
+ readonly attachmentMaxTotalBytes?: number | undefined
109
+ }
110
+
111
+ export interface RichThreadComposerProps
112
+ extends ThreadComposerProps,
113
+ RichComposerAttachmentProps {
114
+ readonly mentionItems?: ((query: string) => readonly MentionSuggestionItem[]) | undefined
115
+ }
116
+
117
+ export function RichThreadComposer({
118
+ mentionItems,
119
+ uploadAttachment,
120
+ attachGif,
121
+ searchGifs,
122
+ attachmentAccept,
123
+ attachmentMaxPerComment,
124
+ attachmentMaxFileBytes,
125
+ attachmentMaxTotalBytes,
126
+ ...props
127
+ }: RichThreadComposerProps) {
128
+ const [failed, setFailed] = useState(false)
129
+
130
+ // Same shape as the thread's region: a live region speaks when its text
131
+ // CHANGES, so the sequence alternates a trailing NBSP that screen readers
132
+ // trim and the DOM counts as a change.
133
+ const [announcement, setAnnouncement] = useState<{
134
+ readonly text: string
135
+ readonly seq: number
136
+ }>({ text: '', seq: 0 })
137
+
138
+ const announce = useCallback((text: string) => {
139
+ setAnnouncement((prev) => ({ text, seq: prev.seq + 1 }))
140
+ }, [])
141
+
142
+ const onError = useCallback(
143
+ (error: unknown) => {
144
+ console.warn(
145
+ 'rich composer: the editor chunk could not load; the plain composer is rendered instead',
146
+ error,
147
+ )
148
+ setFailed(true)
149
+ announce(RICH_FALLBACK_NOTICE)
150
+ },
151
+ [announce],
152
+ )
153
+
154
+ const spoken =
155
+ announcement.text === ''
156
+ ? ''
157
+ : announcement.seq % 2 === 0
158
+ ? `${announcement.text}\u{00A0}`
159
+ : announcement.text
160
+
161
+ return (
162
+ <div className="flex flex-col gap-[var(--space-1)]">
163
+ {/* Rendered empty on mount and kept for the life of the composer: an AT
164
+ has to be observing the node before the text lands. */}
165
+ <div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
166
+ {spoken}
167
+ </div>
168
+ {failed ? (
169
+ <>
170
+ <ComposerNotice>{RICH_FALLBACK_NOTICE}</ComposerNotice>
171
+ <ThreadComposer {...props} />
172
+ </>
173
+ ) : (
174
+ <ChunkBoundary onError={onError} fallback={<ThreadComposer {...props} />}>
175
+ <Suspense fallback={<ThreadComposer {...props} disabled />}>
176
+ <RichThreadComposerImpl
177
+ {...props}
178
+ announce={announce}
179
+ {...(mentionItems === undefined ? {} : { mentionItems })}
180
+ {...(uploadAttachment === undefined ? {} : { uploadAttachment })}
181
+ {...(attachGif === undefined ? {} : { attachGif })}
182
+ {...(searchGifs === undefined ? {} : { searchGifs })}
183
+ {...(attachmentAccept === undefined ? {} : { attachmentAccept })}
184
+ {...(attachmentMaxPerComment === undefined
185
+ ? {}
186
+ : { attachmentMaxPerComment })}
187
+ {...(attachmentMaxFileBytes === undefined ? {} : { attachmentMaxFileBytes })}
188
+ {...(attachmentMaxTotalBytes === undefined ? {} : { attachmentMaxTotalBytes })}
189
+ />
190
+ </Suspense>
191
+ </ChunkBoundary>
192
+ )}
193
+ </div>
194
+ )
195
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The scroll math behind `handleScrollToSelection` (ADR-148, owner report
3
+ * 2026-09-07: "when I did returns or new lines in the rich editor, the same
4
+ * issue happened again").
5
+ *
6
+ * It lives in its own module for one reason: it is the part that can be
7
+ * tested. jsdom performs no layout, so `coordsAtPos` and
8
+ * `getBoundingClientRect` are meaningless there — but the DECISION made from
9
+ * those numbers is pure arithmetic, and that is where a sign error or an
10
+ * off-by-a-line would hide.
11
+ *
12
+ * The rule: move the editor's own `scrollTop` by the smallest amount that puts
13
+ * the caret inside the box with one line of slack, and never move anything
14
+ * else. A single element's `scrollTop` cannot chain to an ancestor, which is
15
+ * the entire point — ProseMirror's default walks up and scrolls every
16
+ * scrollable ancestor it can find.
17
+ */
18
+
19
+ export interface CaretBox {
20
+ readonly top: number
21
+ readonly bottom: number
22
+ }
23
+
24
+ /**
25
+ * How far to move `scrollTop`. Positive scrolls down, negative up, 0 means the
26
+ * caret is already comfortably inside.
27
+ *
28
+ * `pad` is one line height: a caret flush against the edge reads as clipped
29
+ * even though it is technically visible.
30
+ */
31
+ export function caretScrollDelta(box: CaretBox, caret: CaretBox, pad: number): number {
32
+ // Above the top edge (plus slack) — pull up.
33
+ if (caret.top < box.top + pad) return caret.top - (box.top + pad)
34
+ // Below the bottom edge (minus slack) — push down.
35
+ if (caret.bottom > box.bottom - pad) return caret.bottom - (box.bottom - pad)
36
+ return 0
37
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * SuggestionList — the ONE popup behind both composer triggers (ADR-149 D1).
3
+ *
4
+ * `@` and `:` are the same interaction: type a sigil, filter a list, choose
5
+ * with the keyboard, insert something. The differences are DATA and RENDERING
6
+ * — who supplies the options and what each row shows — never behaviour, so
7
+ * there is one listbox here and two configurations in `markdown-spec.ts`.
8
+ * Two popups would mean two focus models and two sets of accessibility bugs.
9
+ *
10
+ * D5, the contract this file exists to hold:
11
+ *
12
+ * - The EDITOR keeps DOM focus throughout. Nothing in here is ever focused:
13
+ * the options are buttons for their hit area and click semantics, held out
14
+ * of the tab order and guarded by a `mousedown` preventDefault so a click
15
+ * cannot move the caret. The editor points at the active option with
16
+ * `aria-activedescendant` (set by the composer, which owns the editor's
17
+ * DOM node) and at this list with `aria-controls`.
18
+ * - `role="listbox"` / `role="option"` / `aria-selected`, so the pairing is
19
+ * the one an AT expects from a combobox-shaped completion.
20
+ * - The active option is scrolled into view INSIDE THE POPUP ONLY. This
21
+ * codebase has fixed "the page scrolls while I type" twice (the composer's
22
+ * `handleScrollToSelection`, then its height ceiling), and
23
+ * `Element.scrollIntoView` is exactly the API that reintroduces it — it
24
+ * walks up and scrolls every scrollable ancestor. So: arithmetic on this
25
+ * one element's `scrollTop`, plus `overscroll-behavior: contain` in CSS
26
+ * for the wheel and touch paths. A single element's `scrollTop` cannot
27
+ * chain.
28
+ *
29
+ * The two pure helpers below are exported because they are the part jsdom can
30
+ * actually verify: wrapping and scroll arithmetic. Placement is floating-ui's
31
+ * job and is checked in the browser pass, not here.
32
+ */
33
+
34
+ import { useLayoutEffect, useRef } from 'react'
35
+
36
+ export interface SuggestionOption {
37
+ /** Stable identity for React and for the option's DOM id. */
38
+ readonly key: string
39
+ /** The row's primary text — a person's name, or `:shortcode:`. */
40
+ readonly label: string
41
+ /** Secondary text, right-aligned: the mention's kind, or an alias hit. */
42
+ readonly hint?: string | undefined
43
+ /** The leading glyph slot: an emoji character, or a person's initial. */
44
+ readonly glyph?: string | undefined
45
+ /**
46
+ * Whether the glyph merely repeats what the label already says.
47
+ *
48
+ * A mention's leading initial is the first letter of the name beside it, so
49
+ * it is decoration and is hidden from assistive technology. An EMOJI is the
50
+ * opposite: it is the thing being chosen, and hiding it would leave a
51
+ * screen-reader user choosing between shortcodes with no idea what any of
52
+ * them looks like. Unhidden, the option announces the emoji's own Unicode
53
+ * name — "party popper" — which is the whole point of the row.
54
+ */
55
+ readonly glyphDecorative?: boolean | undefined
56
+ }
57
+
58
+ /**
59
+ * Move `current` by `delta` through a list of `length`, wrapping at both ends
60
+ * (D5: "ArrowUp / ArrowDown move the active option and wrap"). An empty list
61
+ * has no active option, which is `0` — the popup is closed in that case
62
+ * anyway, and a negative index would be a worse lie.
63
+ */
64
+ export function wrapIndex(current: number, length: number, delta: number): number {
65
+ if (length <= 0) return 0
66
+ return (((current + delta) % length) + length) % length
67
+ }
68
+
69
+ export interface ScrollViewport {
70
+ readonly scrollTop: number
71
+ readonly clientHeight: number
72
+ }
73
+
74
+ export interface ScrollTarget {
75
+ readonly offsetTop: number
76
+ readonly offsetHeight: number
77
+ }
78
+
79
+ /**
80
+ * The popup's new `scrollTop` so the active option is fully visible, moved by
81
+ * the smallest amount that does it — and never anything else's scrollTop.
82
+ */
83
+ export function listScrollTop(view: ScrollViewport, target: ScrollTarget): number {
84
+ if (target.offsetTop < view.scrollTop) return target.offsetTop
85
+ const bottom = target.offsetTop + target.offsetHeight
86
+ if (bottom > view.scrollTop + view.clientHeight) return bottom - view.clientHeight
87
+ return view.scrollTop
88
+ }
89
+
90
+ export interface SuggestionListProps {
91
+ /** The listbox's DOM id — the editor's `aria-controls` points at it. */
92
+ readonly id: string
93
+ /** The listbox's accessible name ("Emoji", "Mention someone"). */
94
+ readonly label: string
95
+ readonly options: readonly SuggestionOption[]
96
+ readonly activeIndex: number
97
+ /** Option DOM ids, so the editor's `aria-activedescendant` can name one. */
98
+ readonly optionId: (index: number) => string
99
+ /** Insert the option at `index` (a click, or Enter / Tab from the editor). */
100
+ readonly onSelect: (index: number) => void
101
+ /** A pointer hover makes an option active without inserting it. */
102
+ readonly onActivate: (index: number) => void
103
+ }
104
+
105
+ export function SuggestionList({
106
+ id,
107
+ label,
108
+ options,
109
+ activeIndex,
110
+ optionId,
111
+ onSelect,
112
+ onActivate,
113
+ }: SuggestionListProps) {
114
+ const listRef = useRef<HTMLDivElement>(null)
115
+
116
+ useLayoutEffect(() => {
117
+ const list = listRef.current
118
+ if (list === null) return
119
+ const option = list.children.item(activeIndex)
120
+ if (!(option instanceof HTMLElement)) return
121
+ list.scrollTop = listScrollTop(list, option)
122
+ }, [activeIndex, options])
123
+
124
+ return (
125
+ <div
126
+ ref={listRef}
127
+ id={id}
128
+ role="listbox"
129
+ aria-label={label}
130
+ className="ds-thread-suggestions"
131
+ // The options refuse focus one by one; this refuses it for the padding
132
+ // ring and the scrollbar between them. Without it a click on either
133
+ // blurs the contenteditable, which closes the popup and moves focus —
134
+ // the exact hole D5 says must not exist.
135
+ onMouseDown={(event) => {
136
+ event.preventDefault()
137
+ }}
138
+ >
139
+ {options.map((option, index) => {
140
+ const active = index === activeIndex
141
+ return (
142
+ <button
143
+ key={option.key}
144
+ type="button"
145
+ id={optionId(index)}
146
+ role="option"
147
+ aria-selected={active}
148
+ // D5: the editor keeps focus. These are buttons for their hit area
149
+ // and click semantics only — never for focus.
150
+ tabIndex={-1}
151
+ data-active={active}
152
+ className="ds-thread-suggestions__option"
153
+ onMouseDown={(event) => {
154
+ event.preventDefault()
155
+ }}
156
+ onMouseEnter={() => {
157
+ onActivate(index)
158
+ }}
159
+ onClick={() => {
160
+ onSelect(index)
161
+ }}
162
+ >
163
+ <span
164
+ // Reserved slot either way, so a hidden glyph still holds the
165
+ // column and the labels stay on one alignment grid.
166
+ {...(option.glyphDecorative === true || option.glyph === undefined
167
+ ? { 'aria-hidden': true as const }
168
+ : {})}
169
+ className="ds-thread-suggestions__glyph"
170
+ >
171
+ {option.glyph}
172
+ </span>
173
+ <span className="ds-thread-suggestions__label">{option.label}</span>
174
+ {option.hint === undefined ? null : (
175
+ <span className="ds-thread-suggestions__hint">{option.hint}</span>
176
+ )}
177
+ </button>
178
+ )
179
+ })}
180
+ </div>
181
+ )
182
+ }