ajo-ui 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/LICENSE +15 -0
- package/README.md +126 -0
- package/dist/accordion.js +130 -0
- package/dist/avatar.js +54 -0
- package/dist/calendar.js +2 -0
- package/dist/carousel.js +239 -0
- package/dist/chart.js +790 -0
- package/dist/checkbox-group.js +70 -0
- package/dist/checkbox.js +77 -0
- package/dist/chunks/bar-CVafh6C1.js +99 -0
- package/dist/chunks/calendar-q8jZ8Cxr.js +1923 -0
- package/dist/chunks/collection-DtRB63U4.js +111 -0
- package/dist/chunks/data-table-DpkWv4y4.js +1039 -0
- package/dist/chunks/menu-B45IyHHC.js +704 -0
- package/dist/chunks/native-BJdhd9XJ.js +20 -0
- package/dist/chunks/popup-C8Bb3l_g.js +459 -0
- package/dist/chunks/popup-surface-BDCgtVU0.js +18 -0
- package/dist/chunks/position-D6_i_SRn.js +434 -0
- package/dist/chunks/virtual-list-B7hjGkjk.js +413 -0
- package/dist/collapsible.js +106 -0
- package/dist/command.js +263 -0
- package/dist/context-menu.js +112 -0
- package/dist/data-table.js +5 -0
- package/dist/dialog.js +207 -0
- package/dist/direction.js +22 -0
- package/dist/drawer.js +139 -0
- package/dist/field.js +26 -0
- package/dist/index.js +38 -0
- package/dist/input-date.js +994 -0
- package/dist/input-group.js +52 -0
- package/dist/input-otp.js +179 -0
- package/dist/menu.js +2 -0
- package/dist/menubar.js +236 -0
- package/dist/message-scroller.js +446 -0
- package/dist/navigation-menu.js +330 -0
- package/dist/popover.js +307 -0
- package/dist/progress.js +39 -0
- package/dist/radio-group.js +107 -0
- package/dist/resizable.js +172 -0
- package/dist/select.js +961 -0
- package/dist/sidebar.js +343 -0
- package/dist/slider.js +259 -0
- package/dist/switch.js +53 -0
- package/dist/tabs.js +182 -0
- package/dist/toast.js +492 -0
- package/dist/toggle-group.js +111 -0
- package/dist/toggle.js +52 -0
- package/dist/toolbar.js +127 -0
- package/dist/tooltip.js +196 -0
- package/dist/utils.js +104 -0
- package/dist/virtual-list.js +2 -0
- package/package.json +250 -0
- package/src/accordion.tsx +261 -0
- package/src/availability.ts +261 -0
- package/src/avatar.tsx +99 -0
- package/src/bar.ts +156 -0
- package/src/calendar.tsx +1441 -0
- package/src/carousel.tsx +424 -0
- package/src/chart.tsx +1194 -0
- package/src/checkbox-group.tsx +132 -0
- package/src/checkbox.tsx +130 -0
- package/src/collapsible.tsx +188 -0
- package/src/collection.ts +154 -0
- package/src/command.tsx +511 -0
- package/src/context-menu.tsx +233 -0
- package/src/data-table-contract.ts +143 -0
- package/src/data-table-model.ts +760 -0
- package/src/data-table.tsx +475 -0
- package/src/dialog.tsx +393 -0
- package/src/direction.tsx +45 -0
- package/src/drawer.tsx +251 -0
- package/src/field.tsx +61 -0
- package/src/index.ts +37 -0
- package/src/input-date.tsx +1539 -0
- package/src/input-group.tsx +142 -0
- package/src/input-otp.tsx +324 -0
- package/src/menu-cluster.ts +124 -0
- package/src/menu.tsx +1095 -0
- package/src/menubar.tsx +459 -0
- package/src/message-scroller.tsx +732 -0
- package/src/native.ts +26 -0
- package/src/navigation-menu.tsx +578 -0
- package/src/popover.tsx +519 -0
- package/src/popup-surface.tsx +31 -0
- package/src/popup.ts +569 -0
- package/src/position.ts +523 -0
- package/src/progress.tsx +70 -0
- package/src/radio-group.tsx +186 -0
- package/src/resizable.tsx +310 -0
- package/src/segments.ts +922 -0
- package/src/select.tsx +1501 -0
- package/src/sidebar.tsx +683 -0
- package/src/slider.tsx +424 -0
- package/src/switch.tsx +104 -0
- package/src/tabs.tsx +314 -0
- package/src/toast.tsx +923 -0
- package/src/toggle-group.tsx +249 -0
- package/src/toggle.tsx +91 -0
- package/src/toolbar.tsx +212 -0
- package/src/tooltip.tsx +359 -0
- package/src/utils.ts +204 -0
- package/src/virtual-list.tsx +205 -0
- package/src/virtual.ts +385 -0
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
import type { IntrinsicElements, Stateful, Stateless, WithChildren } from 'ajo'
|
|
2
|
+
import { browser, callHandler, callRef, frame, overflow, resize, scrolling, timer } from 'ajo-cloves'
|
|
3
|
+
import { context } from 'ajo/context'
|
|
4
|
+
|
|
5
|
+
/** Initial edge or anchor used when the message scroller first mounts. */
|
|
6
|
+
export type MessageScrollerDefaultPosition =
|
|
7
|
+
| 'end'
|
|
8
|
+
| 'last-anchor'
|
|
9
|
+
| 'start'
|
|
10
|
+
|
|
11
|
+
/** Direction accepted by message-scroller controls and edge state. */
|
|
12
|
+
export type MessageScrollerDirection =
|
|
13
|
+
| 'end'
|
|
14
|
+
| 'start'
|
|
15
|
+
|
|
16
|
+
/** Whether more content can be reached at either scroll edge. */
|
|
17
|
+
export type MessageScrollerScrollable = {
|
|
18
|
+
end: boolean
|
|
19
|
+
start: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Visible message ids and the current reading anchor. */
|
|
23
|
+
export type MessageScrollerVisibility = {
|
|
24
|
+
currentAnchorId?: string
|
|
25
|
+
visibleMessageIds: string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Options for an imperative message-scroller jump. */
|
|
29
|
+
export type MessageScrollerScrollOptions = {
|
|
30
|
+
/** Browser scroll behavior for this jump. */
|
|
31
|
+
behavior?: ScrollBehavior
|
|
32
|
+
/** Extra pixels kept before the target item when aligning to the start. */
|
|
33
|
+
peek?: number
|
|
34
|
+
/** Vertical alignment inside the viewport. */
|
|
35
|
+
position?: 'center' | 'end' | 'start'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Imperative scrolling commands and observed viewport state. */
|
|
39
|
+
export type MessageScrollerApi = {
|
|
40
|
+
scrollToEnd: (options?: Pick<MessageScrollerScrollOptions, 'behavior'>) => boolean
|
|
41
|
+
scrollToMessage: (messageId: string, options?: MessageScrollerScrollOptions) => boolean
|
|
42
|
+
scrollToStart: (options?: Pick<MessageScrollerScrollOptions, 'behavior'>) => boolean
|
|
43
|
+
scrollable: MessageScrollerScrollable
|
|
44
|
+
visibility: MessageScrollerVisibility
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Props for the state provider shared by message-scroller parts. */
|
|
48
|
+
export type MessageScrollerProviderArgs = WithChildren<{
|
|
49
|
+
/** Follow new content while the reader is already at the end. */
|
|
50
|
+
autoScroll?: boolean
|
|
51
|
+
/** Initial scroll target once the viewport and items are mounted. */
|
|
52
|
+
defaultScrollPosition?: MessageScrollerDefaultPosition
|
|
53
|
+
/** Preserve the visible row when older messages are prepended. */
|
|
54
|
+
preserveScrollOnPrepend?: boolean
|
|
55
|
+
/** Pixels to keep visible above a target item. */
|
|
56
|
+
scrollPreviousItemPeek?: number
|
|
57
|
+
/** Receives the imperative scroller controller. */
|
|
58
|
+
setApi?: (api: MessageScrollerApi) => void
|
|
59
|
+
}>
|
|
60
|
+
|
|
61
|
+
/** Props for the message-scroller root element. */
|
|
62
|
+
export type MessageScrollerArgs = WithChildren<IntrinsicElements['div']>
|
|
63
|
+
|
|
64
|
+
/** Props for the scrollable message viewport. */
|
|
65
|
+
export type MessageScrollerViewportArgs = WithChildren<IntrinsicElements['div']>
|
|
66
|
+
|
|
67
|
+
/** Props for the element containing registered message items. */
|
|
68
|
+
export type MessageScrollerContentArgs = WithChildren<IntrinsicElements['div']>
|
|
69
|
+
|
|
70
|
+
/** Props for a tracked message item. */
|
|
71
|
+
export type MessageScrollerItemArgs = WithChildren<IntrinsicElements['div'] & {
|
|
72
|
+
/** Stable id used by `scrollToMessage` and visibility tracking. */
|
|
73
|
+
messageId?: string
|
|
74
|
+
/** Marks this item as an anchor for `defaultScrollPosition="last-anchor"`. */
|
|
75
|
+
scrollAnchor?: boolean
|
|
76
|
+
}>
|
|
77
|
+
|
|
78
|
+
/** Props for a control that scrolls toward one viewport edge. */
|
|
79
|
+
export type MessageScrollerButtonArgs = WithChildren<IntrinsicElements['button'] & {
|
|
80
|
+
/** Scroll direction controlled by this button. */
|
|
81
|
+
direction?: MessageScrollerDirection
|
|
82
|
+
}>
|
|
83
|
+
|
|
84
|
+
type MessageScrollerPartsContextValue = {
|
|
85
|
+
setButton: (direction: MessageScrollerDirection, element: HTMLButtonElement | null) => void
|
|
86
|
+
setContent: (element: HTMLElement | null) => void
|
|
87
|
+
setItem: (messageId: string | undefined, element: HTMLElement | null) => void
|
|
88
|
+
setRoot: (element: HTMLElement | null) => void
|
|
89
|
+
setViewport: (element: HTMLElement | null) => void
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type PendingScroll = {
|
|
93
|
+
id: string
|
|
94
|
+
options?: MessageScrollerScrollOptions
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type PreserveAnchor = {
|
|
98
|
+
id: string
|
|
99
|
+
top: number
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type ItemReading = MessageScrollerVisibility & {
|
|
103
|
+
preserveAnchor: PreserveAnchor | null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Read the public controller inherited from the nearest MessageScrollerProvider. */
|
|
107
|
+
export const MessageScrollerContext = context<MessageScrollerApi | null>(null)
|
|
108
|
+
const MessageScrollerPartsContext = context<MessageScrollerPartsContextValue | null>(null)
|
|
109
|
+
|
|
110
|
+
const edge = 2
|
|
111
|
+
|
|
112
|
+
const messageScroller = () => {
|
|
113
|
+
const api = MessageScrollerContext()
|
|
114
|
+
if (!api) throw new Error('MessageScroller controls must be used within a <MessageScrollerProvider />')
|
|
115
|
+
return api
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const messageScrollerParts = () => {
|
|
119
|
+
const parts = MessageScrollerPartsContext()
|
|
120
|
+
if (!parts) throw new Error('MessageScroller parts must be used within a <MessageScrollerProvider />')
|
|
121
|
+
return parts
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const getItems = (content: HTMLElement | null) =>
|
|
125
|
+
content
|
|
126
|
+
? Array.from(content.querySelectorAll<HTMLElement>('[data-slot="message-scroller-item"]'))
|
|
127
|
+
: []
|
|
128
|
+
|
|
129
|
+
const rectTop = (viewport: HTMLElement, element: HTMLElement) =>
|
|
130
|
+
element.getBoundingClientRect().top - viewport.getBoundingClientRect().top
|
|
131
|
+
|
|
132
|
+
const stamp = (element: HTMLElement, name: string, value: string) => {
|
|
133
|
+
if (element.getAttribute(name) !== value) element.setAttribute(name, value)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Unstyled provider for imperative scroll behavior and visibility state. */
|
|
137
|
+
const MessageScrollerProvider: Stateful<MessageScrollerProviderArgs> = function* ({
|
|
138
|
+
autoScroll = true,
|
|
139
|
+
defaultScrollPosition = 'end',
|
|
140
|
+
preserveScrollOnPrepend = true,
|
|
141
|
+
scrollPreviousItemPeek = 0,
|
|
142
|
+
}) {
|
|
143
|
+
let root: HTMLElement | null = null
|
|
144
|
+
let viewport: HTMLElement | null = null
|
|
145
|
+
let content: HTMLElement | null = null
|
|
146
|
+
let mutation: MutationObserver | undefined
|
|
147
|
+
let initialized = false
|
|
148
|
+
let didInitialScroll = false
|
|
149
|
+
let following = defaultScrollPosition !== 'start'
|
|
150
|
+
let currentAutoScroll = autoScroll
|
|
151
|
+
let currentDefaultPosition = defaultScrollPosition
|
|
152
|
+
let currentPreserve = preserveScrollOnPrepend
|
|
153
|
+
let currentPeek = scrollPreviousItemPeek
|
|
154
|
+
let currentSetApi: MessageScrollerProviderArgs['setApi']
|
|
155
|
+
let pending: PendingScroll | null = null
|
|
156
|
+
let preserveAnchor: PreserveAnchor | null = null
|
|
157
|
+
let settles = 0
|
|
158
|
+
const items = new Map<string, HTMLElement>()
|
|
159
|
+
const buttons: Record<MessageScrollerDirection, HTMLButtonElement | null> = {
|
|
160
|
+
end: null,
|
|
161
|
+
start: null,
|
|
162
|
+
}
|
|
163
|
+
const scrollable: MessageScrollerScrollable = { end: false, start: false }
|
|
164
|
+
const visibility: MessageScrollerVisibility = { visibleMessageIds: [] }
|
|
165
|
+
const autoscroll = timer(this)
|
|
166
|
+
|
|
167
|
+
const setAutoscrolling = (active: boolean) => {
|
|
168
|
+
if (!viewport || !root) return
|
|
169
|
+
|
|
170
|
+
root.toggleAttribute('data-autoscrolling', active)
|
|
171
|
+
viewport.toggleAttribute('data-autoscrolling', active)
|
|
172
|
+
|
|
173
|
+
if (!active) {
|
|
174
|
+
autoscroll.stop()
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Fallback for browsers without `scrollend`; long smooth scrolls must
|
|
179
|
+
// stay flagged so user-scroll detection does not hijack `following`.
|
|
180
|
+
autoscroll.start(1200, () => {
|
|
181
|
+
root?.toggleAttribute('data-autoscrolling', false)
|
|
182
|
+
viewport?.toggleAttribute('data-autoscrolling', false)
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const atStart = () => !viewport || viewport.scrollTop <= edge
|
|
187
|
+
|
|
188
|
+
const atEnd = () => !viewport ||
|
|
189
|
+
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <= edge
|
|
190
|
+
|
|
191
|
+
const buttonActive = (direction: MessageScrollerDirection) =>
|
|
192
|
+
direction === 'start' ? scrollable.start : scrollable.end
|
|
193
|
+
|
|
194
|
+
const updateButton = (direction: MessageScrollerDirection) => {
|
|
195
|
+
const button = buttons[direction]
|
|
196
|
+
if (!button) return
|
|
197
|
+
|
|
198
|
+
const active = buttonActive(direction)
|
|
199
|
+
const tabIndex = active ? 0 : -1
|
|
200
|
+
stamp(button, 'data-active', String(active))
|
|
201
|
+
if (button.tabIndex !== tabIndex) button.tabIndex = tabIndex
|
|
202
|
+
if (button.hasAttribute('inert') === active) button.toggleAttribute('inert', !active)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const readEdges = () => {
|
|
206
|
+
const canStart = !atStart()
|
|
207
|
+
const canEnd = !atEnd()
|
|
208
|
+
return { canEnd, canStart }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const commitEdges = ({ canEnd, canStart }: ReturnType<typeof readEdges>) => {
|
|
212
|
+
scrollable.start = canStart
|
|
213
|
+
scrollable.end = canEnd
|
|
214
|
+
|
|
215
|
+
updateButton('start')
|
|
216
|
+
updateButton('end')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const updateData = () => commitEdges(readEdges())
|
|
220
|
+
|
|
221
|
+
const findItem = (messageId: string) => {
|
|
222
|
+
const registered = items.get(messageId)
|
|
223
|
+
if (registered) return registered
|
|
224
|
+
|
|
225
|
+
return getItems(content).find(item => item.dataset.messageId === messageId)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const readItems = (): ItemReading => {
|
|
229
|
+
if (!viewport || !content) {
|
|
230
|
+
return {
|
|
231
|
+
currentAnchorId: undefined,
|
|
232
|
+
preserveAnchor: null,
|
|
233
|
+
visibleMessageIds: [],
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const viewportRect = viewport.getBoundingClientRect()
|
|
238
|
+
const visible: string[] = []
|
|
239
|
+
let anchor: string | undefined
|
|
240
|
+
let preserved: PreserveAnchor | null = null
|
|
241
|
+
|
|
242
|
+
for (const item of getItems(content)) {
|
|
243
|
+
const rect = item.getBoundingClientRect()
|
|
244
|
+
const id = item.dataset.messageId
|
|
245
|
+
const intersects = rect.bottom > viewportRect.top && rect.top < viewportRect.bottom
|
|
246
|
+
|
|
247
|
+
if (intersects && id) visible.push(id)
|
|
248
|
+
if (currentPreserve && !preserveAnchor && !preserved && intersects && id) {
|
|
249
|
+
preserved = { id, top: rect.top - viewportRect.top }
|
|
250
|
+
}
|
|
251
|
+
if (
|
|
252
|
+
id &&
|
|
253
|
+
item.dataset.scrollAnchor === 'true' &&
|
|
254
|
+
rect.top <= viewportRect.top + viewport.clientHeight * 0.65
|
|
255
|
+
) anchor = id
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
currentAnchorId: anchor,
|
|
260
|
+
preserveAnchor: preserved,
|
|
261
|
+
visibleMessageIds: visible,
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const commitItems = (reading: ReturnType<typeof readItems>) => {
|
|
266
|
+
visibility.currentAnchorId = reading.currentAnchorId
|
|
267
|
+
visibility.visibleMessageIds = reading.visibleMessageIds
|
|
268
|
+
if (!currentPreserve) preserveAnchor = null
|
|
269
|
+
else {
|
|
270
|
+
// Keep one row stable across the mutation/resize settle burst; a scroll
|
|
271
|
+
// clears it before the next geometry read chooses a new row.
|
|
272
|
+
preserveAnchor ??= reading.preserveAnchor
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const sync = () => {
|
|
277
|
+
const edges = readEdges()
|
|
278
|
+
const itemState = readItems()
|
|
279
|
+
commitItems(itemState)
|
|
280
|
+
commitEdges(edges)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const schedule = frame(sync)
|
|
284
|
+
|
|
285
|
+
const handleViewportScroll = (element: HTMLElement) => {
|
|
286
|
+
if (!element.hasAttribute('data-autoscrolling')) following = atEnd()
|
|
287
|
+
preserveAnchor = null
|
|
288
|
+
sync()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const handleViewportEnd = (element: HTMLElement) => {
|
|
292
|
+
// `content-visibility:auto` items grow scrollHeight as they render,
|
|
293
|
+
// so a jump to the end can land short: settle until it sticks.
|
|
294
|
+
if (following && !atEnd() && settles < 10) {
|
|
295
|
+
settles += 1
|
|
296
|
+
setAutoscrolling(true)
|
|
297
|
+
element.scrollTo({ behavior: 'auto', top: element.scrollHeight })
|
|
298
|
+
} else {
|
|
299
|
+
setAutoscrolling(false)
|
|
300
|
+
}
|
|
301
|
+
schedule()
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const viewportScroll = scrolling(this, {
|
|
305
|
+
target: () => viewport,
|
|
306
|
+
onScroll: handleViewportScroll,
|
|
307
|
+
onEnd: handleViewportEnd,
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
const viewportSize = resize(this, {
|
|
311
|
+
target: () => viewport,
|
|
312
|
+
onResize: sync,
|
|
313
|
+
})
|
|
314
|
+
const edges = overflow(this, { target: () => viewport })
|
|
315
|
+
|
|
316
|
+
const scrollTopFor = (
|
|
317
|
+
element: HTMLElement,
|
|
318
|
+
options: MessageScrollerScrollOptions = {},
|
|
319
|
+
) => {
|
|
320
|
+
if (!viewport) return 0
|
|
321
|
+
|
|
322
|
+
const peek = options.peek ?? currentPeek
|
|
323
|
+
const top = rectTop(viewport, element) + viewport.scrollTop
|
|
324
|
+
|
|
325
|
+
if (options.position === 'center') {
|
|
326
|
+
return top - (viewport.clientHeight - element.offsetHeight) / 2
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (options.position === 'end') {
|
|
330
|
+
return top - viewport.clientHeight + element.offsetHeight + peek
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return top - peek
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const scrollToTop = (top: number, behavior: ScrollBehavior = 'smooth') => {
|
|
337
|
+
if (!viewport) return false
|
|
338
|
+
|
|
339
|
+
preserveAnchor = null
|
|
340
|
+
settles = 0
|
|
341
|
+
setAutoscrolling(true)
|
|
342
|
+
viewport.scrollTo({ behavior, top: Math.max(0, top) })
|
|
343
|
+
schedule()
|
|
344
|
+
return true
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const scrollToStart = (options: Pick<MessageScrollerScrollOptions, 'behavior'> = {}) => {
|
|
348
|
+
following = false
|
|
349
|
+
return scrollToTop(0, options.behavior)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const scrollToEnd = (options: Pick<MessageScrollerScrollOptions, 'behavior'> = {}) => {
|
|
353
|
+
if (!viewport) return false
|
|
354
|
+
|
|
355
|
+
following = true
|
|
356
|
+
return scrollToTop(viewport.scrollHeight, options.behavior)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const scrollToMessage = (messageId: string, options: MessageScrollerScrollOptions = {}) => {
|
|
360
|
+
const item = findItem(messageId)
|
|
361
|
+
|
|
362
|
+
if (!item) {
|
|
363
|
+
if (!initialized) {
|
|
364
|
+
pending = { id: messageId, options }
|
|
365
|
+
return true
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return false
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
following = false
|
|
372
|
+
return scrollToTop(scrollTopFor(item, options), options.behavior)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const flushPending = () => {
|
|
376
|
+
if (!pending) return
|
|
377
|
+
|
|
378
|
+
const target = pending
|
|
379
|
+
pending = null
|
|
380
|
+
if (!scrollToMessage(target.id, { behavior: 'auto', ...target.options })) pending = target
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const restoreAnchor = () => {
|
|
384
|
+
if (!viewport || !preserveAnchor || following || !currentPreserve) return
|
|
385
|
+
|
|
386
|
+
const item = findItem(preserveAnchor.id)
|
|
387
|
+
if (!item) {
|
|
388
|
+
preserveAnchor = null
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const nextTop = rectTop(viewport, item)
|
|
393
|
+
viewport.scrollTop += nextTop - preserveAnchor.top
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const handleContentChange = () => {
|
|
397
|
+
queueMicrotask(() => {
|
|
398
|
+
restoreAnchor()
|
|
399
|
+
if (currentAutoScroll && following) scrollToEnd({ behavior: 'auto' })
|
|
400
|
+
flushPending()
|
|
401
|
+
edges.sync()
|
|
402
|
+
schedule()
|
|
403
|
+
})
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const contentSize = resize(this, {
|
|
407
|
+
target: () => content,
|
|
408
|
+
onResize: handleContentChange,
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
const applyInitialScroll = () => {
|
|
412
|
+
if (didInitialScroll || !viewport) return
|
|
413
|
+
|
|
414
|
+
didInitialScroll = true
|
|
415
|
+
initialized = true
|
|
416
|
+
|
|
417
|
+
if (pending) {
|
|
418
|
+
flushPending()
|
|
419
|
+
} else if (currentDefaultPosition === 'start') {
|
|
420
|
+
scrollToStart({ behavior: 'auto' })
|
|
421
|
+
} else if (currentDefaultPosition === 'last-anchor') {
|
|
422
|
+
const anchor = getItems(content)
|
|
423
|
+
.filter(item => item.dataset.scrollAnchor === 'true' && item.dataset.messageId)
|
|
424
|
+
.at(-1)
|
|
425
|
+
if (anchor?.dataset.messageId) {
|
|
426
|
+
scrollToMessage(anchor.dataset.messageId, { behavior: 'auto', peek: currentPeek })
|
|
427
|
+
} else {
|
|
428
|
+
scrollToEnd({ behavior: 'auto' })
|
|
429
|
+
}
|
|
430
|
+
} else {
|
|
431
|
+
scrollToEnd({ behavior: 'auto' })
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
sync()
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const scheduleInitial = frame(applyInitialScroll)
|
|
438
|
+
|
|
439
|
+
const scheduleInitialScroll = () => {
|
|
440
|
+
if (!browser() || didInitialScroll) return
|
|
441
|
+
|
|
442
|
+
scheduleInitial()
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const setRoot = (element: HTMLElement | null) => {
|
|
446
|
+
root = element
|
|
447
|
+
updateData()
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const setViewport = (element: HTMLElement | null) => {
|
|
451
|
+
if (viewport === element) return
|
|
452
|
+
|
|
453
|
+
viewport = element
|
|
454
|
+
viewportScroll.sync()
|
|
455
|
+
viewportSize.sync()
|
|
456
|
+
edges.sync()
|
|
457
|
+
|
|
458
|
+
if (!element) {
|
|
459
|
+
updateData()
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
updateData()
|
|
464
|
+
scheduleInitialScroll()
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const setContent = (element: HTMLElement | null) => {
|
|
468
|
+
if (content === element) return
|
|
469
|
+
|
|
470
|
+
mutation?.disconnect()
|
|
471
|
+
content = element
|
|
472
|
+
contentSize.sync()
|
|
473
|
+
edges.sync()
|
|
474
|
+
|
|
475
|
+
if (!element) {
|
|
476
|
+
updateData()
|
|
477
|
+
return
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (typeof MutationObserver !== 'undefined') {
|
|
481
|
+
mutation = new MutationObserver(handleContentChange)
|
|
482
|
+
mutation.observe(element, { childList: true, subtree: true })
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
updateData()
|
|
486
|
+
scheduleInitialScroll()
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const setItem = (
|
|
490
|
+
messageId: string | undefined,
|
|
491
|
+
element: HTMLElement | null,
|
|
492
|
+
) => {
|
|
493
|
+
if (element) {
|
|
494
|
+
for (const [registeredId, registered] of items) {
|
|
495
|
+
if (registered === element && registeredId !== messageId) items.delete(registeredId)
|
|
496
|
+
}
|
|
497
|
+
if (messageId) {
|
|
498
|
+
items.set(messageId, element)
|
|
499
|
+
flushPending()
|
|
500
|
+
}
|
|
501
|
+
} else if (messageId) {
|
|
502
|
+
items.delete(messageId)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
schedule()
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const setButton = (direction: MessageScrollerDirection, element: HTMLButtonElement | null) => {
|
|
509
|
+
if (element) {
|
|
510
|
+
for (const registeredDirection of ['start', 'end'] as const) {
|
|
511
|
+
if (registeredDirection !== direction && buttons[registeredDirection] === element) {
|
|
512
|
+
buttons[registeredDirection] = null
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
buttons[direction] = element
|
|
517
|
+
updateButton('start')
|
|
518
|
+
updateButton('end')
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const publicApi: MessageScrollerApi = {
|
|
522
|
+
scrollToEnd,
|
|
523
|
+
scrollToMessage,
|
|
524
|
+
scrollToStart,
|
|
525
|
+
scrollable,
|
|
526
|
+
visibility,
|
|
527
|
+
}
|
|
528
|
+
const parts: MessageScrollerPartsContextValue = {
|
|
529
|
+
setButton,
|
|
530
|
+
setContent,
|
|
531
|
+
setItem,
|
|
532
|
+
setRoot,
|
|
533
|
+
setViewport,
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
this.signal.addEventListener('abort', () => {
|
|
537
|
+
schedule.cancel()
|
|
538
|
+
scheduleInitial.cancel()
|
|
539
|
+
mutation?.disconnect()
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
for (const {
|
|
543
|
+
autoScroll = true,
|
|
544
|
+
children,
|
|
545
|
+
defaultScrollPosition = 'end',
|
|
546
|
+
preserveScrollOnPrepend = true,
|
|
547
|
+
scrollPreviousItemPeek = 0,
|
|
548
|
+
setApi,
|
|
549
|
+
} of this) {
|
|
550
|
+
const wasPreserving = currentPreserve
|
|
551
|
+
currentAutoScroll = autoScroll
|
|
552
|
+
currentDefaultPosition = defaultScrollPosition
|
|
553
|
+
currentPreserve = preserveScrollOnPrepend
|
|
554
|
+
if (!currentPreserve) preserveAnchor = null
|
|
555
|
+
else if (!wasPreserving && !following) preserveAnchor = readItems().preserveAnchor
|
|
556
|
+
currentPeek = scrollPreviousItemPeek
|
|
557
|
+
if (setApi && setApi !== currentSetApi) {
|
|
558
|
+
currentSetApi = setApi
|
|
559
|
+
setApi(publicApi)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
viewportScroll.sync()
|
|
563
|
+
viewportSize.sync()
|
|
564
|
+
edges.sync()
|
|
565
|
+
contentSize.sync()
|
|
566
|
+
|
|
567
|
+
MessageScrollerContext(publicApi)
|
|
568
|
+
MessageScrollerPartsContext(parts)
|
|
569
|
+
queueMicrotask(scheduleInitialScroll)
|
|
570
|
+
|
|
571
|
+
yield <>{children}</>
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
MessageScrollerProvider.attrs = {
|
|
576
|
+
'data-slot': 'message-scroller-provider',
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Unstyled root container for a scroll-managed message transcript. */
|
|
580
|
+
const MessageScroller: Stateless<MessageScrollerArgs> = ({ children, ref, ...attrs }) => {
|
|
581
|
+
const parts = messageScrollerParts()
|
|
582
|
+
|
|
583
|
+
return (
|
|
584
|
+
<div
|
|
585
|
+
{...attrs}
|
|
586
|
+
data-slot="message-scroller"
|
|
587
|
+
ref={element => {
|
|
588
|
+
parts.setRoot(element)
|
|
589
|
+
callRef(ref, element)
|
|
590
|
+
}}
|
|
591
|
+
>
|
|
592
|
+
{children}
|
|
593
|
+
</div>
|
|
594
|
+
)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Unstyled keyboard-focusable transcript viewport. */
|
|
598
|
+
const MessageScrollerViewport: Stateless<MessageScrollerViewportArgs> = ({
|
|
599
|
+
children,
|
|
600
|
+
role = 'region',
|
|
601
|
+
tabIndex = 0,
|
|
602
|
+
'aria-label': label = 'Messages',
|
|
603
|
+
ref,
|
|
604
|
+
...attrs
|
|
605
|
+
}) => {
|
|
606
|
+
const parts = messageScrollerParts()
|
|
607
|
+
|
|
608
|
+
return (
|
|
609
|
+
<div
|
|
610
|
+
{...attrs}
|
|
611
|
+
aria-label={label}
|
|
612
|
+
data-slot="message-scroller-viewport"
|
|
613
|
+
ref={element => {
|
|
614
|
+
parts.setViewport(element)
|
|
615
|
+
callRef(ref, element)
|
|
616
|
+
}}
|
|
617
|
+
role={role}
|
|
618
|
+
tabIndex={tabIndex}
|
|
619
|
+
>
|
|
620
|
+
{children}
|
|
621
|
+
</div>
|
|
622
|
+
)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Unstyled live-region content list for rendered messages. */
|
|
626
|
+
const MessageScrollerContent: Stateless<MessageScrollerContentArgs> = ({
|
|
627
|
+
children,
|
|
628
|
+
role = 'log',
|
|
629
|
+
'aria-live': live = 'polite',
|
|
630
|
+
'aria-relevant': relevant = 'additions',
|
|
631
|
+
ref,
|
|
632
|
+
...attrs
|
|
633
|
+
}) => {
|
|
634
|
+
const parts = messageScrollerParts()
|
|
635
|
+
|
|
636
|
+
return (
|
|
637
|
+
<div
|
|
638
|
+
{...attrs}
|
|
639
|
+
aria-live={live}
|
|
640
|
+
aria-relevant={relevant}
|
|
641
|
+
data-slot="message-scroller-content"
|
|
642
|
+
ref={element => {
|
|
643
|
+
parts.setContent(element)
|
|
644
|
+
callRef(ref, element)
|
|
645
|
+
}}
|
|
646
|
+
role={role}
|
|
647
|
+
>
|
|
648
|
+
{children}
|
|
649
|
+
</div>
|
|
650
|
+
)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** Unstyled scroll-trackable transcript item. */
|
|
654
|
+
const MessageScrollerItem: Stateless<MessageScrollerItemArgs> = ({
|
|
655
|
+
children,
|
|
656
|
+
messageId,
|
|
657
|
+
ref,
|
|
658
|
+
scrollAnchor = false,
|
|
659
|
+
...attrs
|
|
660
|
+
}) => {
|
|
661
|
+
const parts = messageScrollerParts()
|
|
662
|
+
|
|
663
|
+
return (
|
|
664
|
+
<div
|
|
665
|
+
{...attrs}
|
|
666
|
+
data-message-id={messageId}
|
|
667
|
+
data-scroll-anchor={String(scrollAnchor)}
|
|
668
|
+
data-slot="message-scroller-item"
|
|
669
|
+
ref={element => {
|
|
670
|
+
parts.setItem(messageId, element)
|
|
671
|
+
callRef(ref, element)
|
|
672
|
+
}}
|
|
673
|
+
>
|
|
674
|
+
{children}
|
|
675
|
+
</div>
|
|
676
|
+
)
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** Unstyled floating button that jumps to the start or end of the transcript. */
|
|
680
|
+
const MessageScrollerButton: Stateless<MessageScrollerButtonArgs> = ({
|
|
681
|
+
'aria-label': label,
|
|
682
|
+
children,
|
|
683
|
+
direction = 'end',
|
|
684
|
+
disabled,
|
|
685
|
+
ref,
|
|
686
|
+
type = 'button',
|
|
687
|
+
'set:onclick': onClick,
|
|
688
|
+
...attrs
|
|
689
|
+
}) => {
|
|
690
|
+
const scroller = messageScroller()
|
|
691
|
+
const parts = messageScrollerParts()
|
|
692
|
+
const buttonActive = (next: MessageScrollerDirection) =>
|
|
693
|
+
next === 'start' ? scroller.scrollable.start : scroller.scrollable.end
|
|
694
|
+
const active = buttonActive(direction)
|
|
695
|
+
const title = label ?? (direction === 'end' ? 'Scroll to end' : 'Scroll to start')
|
|
696
|
+
|
|
697
|
+
const click = (event: MouseEvent) => {
|
|
698
|
+
callHandler(onClick, event)
|
|
699
|
+
if (event.defaultPrevented || disabled || !buttonActive(direction)) return
|
|
700
|
+
if (direction === 'start') scroller.scrollToStart()
|
|
701
|
+
else scroller.scrollToEnd()
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
return (
|
|
705
|
+
<button
|
|
706
|
+
{...attrs}
|
|
707
|
+
aria-label={title}
|
|
708
|
+
data-active={active ? 'true' : 'false'}
|
|
709
|
+
data-direction={direction}
|
|
710
|
+
data-slot="message-scroller-button"
|
|
711
|
+
disabled={disabled}
|
|
712
|
+
ref={element => {
|
|
713
|
+
parts.setButton(direction, element)
|
|
714
|
+
callRef(ref, element)
|
|
715
|
+
}}
|
|
716
|
+
set:onclick={click}
|
|
717
|
+
tabIndex={active ? attrs.tabIndex : -1}
|
|
718
|
+
type={type}
|
|
719
|
+
>
|
|
720
|
+
{children}
|
|
721
|
+
</button>
|
|
722
|
+
)
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
export {
|
|
726
|
+
MessageScroller,
|
|
727
|
+
MessageScrollerButton,
|
|
728
|
+
MessageScrollerContent,
|
|
729
|
+
MessageScrollerItem,
|
|
730
|
+
MessageScrollerProvider,
|
|
731
|
+
MessageScrollerViewport,
|
|
732
|
+
}
|