@lovett/ui 0.0.11 → 0.2.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 +5574 -57
- package/dist/index.js +21650 -11206
- 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 +1570 -0
- package/dist/tokens.css +112 -0
- package/package.json +8 -1
- 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__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +307 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +156 -2
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/sortable.test.tsx +394 -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/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- 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/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +369 -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 +611 -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 +301 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +290 -19
- 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 +303 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +43 -1
- package/src/lib/layer-stack.ts +30 -3
- 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/modal.tsx +42 -18
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/sortable.tsx +520 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1570 -0
- 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 +252 -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 +742 -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 +967 -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 +232 -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 +112 -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
|
+
}
|