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.
Files changed (103) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +126 -0
  3. package/dist/accordion.js +130 -0
  4. package/dist/avatar.js +54 -0
  5. package/dist/calendar.js +2 -0
  6. package/dist/carousel.js +239 -0
  7. package/dist/chart.js +790 -0
  8. package/dist/checkbox-group.js +70 -0
  9. package/dist/checkbox.js +77 -0
  10. package/dist/chunks/bar-CVafh6C1.js +99 -0
  11. package/dist/chunks/calendar-q8jZ8Cxr.js +1923 -0
  12. package/dist/chunks/collection-DtRB63U4.js +111 -0
  13. package/dist/chunks/data-table-DpkWv4y4.js +1039 -0
  14. package/dist/chunks/menu-B45IyHHC.js +704 -0
  15. package/dist/chunks/native-BJdhd9XJ.js +20 -0
  16. package/dist/chunks/popup-C8Bb3l_g.js +459 -0
  17. package/dist/chunks/popup-surface-BDCgtVU0.js +18 -0
  18. package/dist/chunks/position-D6_i_SRn.js +434 -0
  19. package/dist/chunks/virtual-list-B7hjGkjk.js +413 -0
  20. package/dist/collapsible.js +106 -0
  21. package/dist/command.js +263 -0
  22. package/dist/context-menu.js +112 -0
  23. package/dist/data-table.js +5 -0
  24. package/dist/dialog.js +207 -0
  25. package/dist/direction.js +22 -0
  26. package/dist/drawer.js +139 -0
  27. package/dist/field.js +26 -0
  28. package/dist/index.js +38 -0
  29. package/dist/input-date.js +994 -0
  30. package/dist/input-group.js +52 -0
  31. package/dist/input-otp.js +179 -0
  32. package/dist/menu.js +2 -0
  33. package/dist/menubar.js +236 -0
  34. package/dist/message-scroller.js +446 -0
  35. package/dist/navigation-menu.js +330 -0
  36. package/dist/popover.js +307 -0
  37. package/dist/progress.js +39 -0
  38. package/dist/radio-group.js +107 -0
  39. package/dist/resizable.js +172 -0
  40. package/dist/select.js +961 -0
  41. package/dist/sidebar.js +343 -0
  42. package/dist/slider.js +259 -0
  43. package/dist/switch.js +53 -0
  44. package/dist/tabs.js +182 -0
  45. package/dist/toast.js +492 -0
  46. package/dist/toggle-group.js +111 -0
  47. package/dist/toggle.js +52 -0
  48. package/dist/toolbar.js +127 -0
  49. package/dist/tooltip.js +196 -0
  50. package/dist/utils.js +104 -0
  51. package/dist/virtual-list.js +2 -0
  52. package/package.json +250 -0
  53. package/src/accordion.tsx +261 -0
  54. package/src/availability.ts +261 -0
  55. package/src/avatar.tsx +99 -0
  56. package/src/bar.ts +156 -0
  57. package/src/calendar.tsx +1441 -0
  58. package/src/carousel.tsx +424 -0
  59. package/src/chart.tsx +1194 -0
  60. package/src/checkbox-group.tsx +132 -0
  61. package/src/checkbox.tsx +130 -0
  62. package/src/collapsible.tsx +188 -0
  63. package/src/collection.ts +154 -0
  64. package/src/command.tsx +511 -0
  65. package/src/context-menu.tsx +233 -0
  66. package/src/data-table-contract.ts +143 -0
  67. package/src/data-table-model.ts +760 -0
  68. package/src/data-table.tsx +475 -0
  69. package/src/dialog.tsx +393 -0
  70. package/src/direction.tsx +45 -0
  71. package/src/drawer.tsx +251 -0
  72. package/src/field.tsx +61 -0
  73. package/src/index.ts +37 -0
  74. package/src/input-date.tsx +1539 -0
  75. package/src/input-group.tsx +142 -0
  76. package/src/input-otp.tsx +324 -0
  77. package/src/menu-cluster.ts +124 -0
  78. package/src/menu.tsx +1095 -0
  79. package/src/menubar.tsx +459 -0
  80. package/src/message-scroller.tsx +732 -0
  81. package/src/native.ts +26 -0
  82. package/src/navigation-menu.tsx +578 -0
  83. package/src/popover.tsx +519 -0
  84. package/src/popup-surface.tsx +31 -0
  85. package/src/popup.ts +569 -0
  86. package/src/position.ts +523 -0
  87. package/src/progress.tsx +70 -0
  88. package/src/radio-group.tsx +186 -0
  89. package/src/resizable.tsx +310 -0
  90. package/src/segments.ts +922 -0
  91. package/src/select.tsx +1501 -0
  92. package/src/sidebar.tsx +683 -0
  93. package/src/slider.tsx +424 -0
  94. package/src/switch.tsx +104 -0
  95. package/src/tabs.tsx +314 -0
  96. package/src/toast.tsx +923 -0
  97. package/src/toggle-group.tsx +249 -0
  98. package/src/toggle.tsx +91 -0
  99. package/src/toolbar.tsx +212 -0
  100. package/src/tooltip.tsx +359 -0
  101. package/src/utils.ts +204 -0
  102. package/src/virtual-list.tsx +205 -0
  103. 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
+ }