@pgcorp/ui-kit 0.8.1 → 0.9.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.
@@ -6,10 +6,17 @@
6
6
  :style="containerStyle"
7
7
  :data-item-size="itemSize"
8
8
  :data-viewport-height="height ?? undefined"
9
- role="list"
9
+ :role="validatedSemanticRole"
10
10
  :aria-label="accessibleLabel"
11
+ :aria-live="validatedSemanticRole === 'log' ? validatedLive : undefined"
12
+ :aria-busy="validatedSemanticRole === 'log' ? validatedBusy : undefined"
13
+ :aria-relevant="validatedSemanticRole === 'log' ? 'additions text' : undefined"
11
14
  tabindex="0"
12
15
  @scroll.passive="updateViewport"
16
+ @wheel.passive="handleWheel"
17
+ @keydown="handleKeydown"
18
+ @pointerdown="handlePointerDown"
19
+ @pointerup="handlePointerUp"
13
20
  >
14
21
  <div class="s-virtual-list__spacer" :style="spacerStyle" aria-hidden="true" />
15
22
  <div
@@ -19,7 +26,7 @@
19
26
  class="s-virtual-list__item"
20
27
  :style="itemStyle(entry.index)"
21
28
  :data-virtual-index="entry.index"
22
- role="listitem"
29
+ :role="validatedSemanticRole === 'list' ? 'listitem' : 'article'"
23
30
  :aria-label="resolveItemLabel(entry.item, entry.index)"
24
31
  :aria-posinset="entry.index + 1"
25
32
  :aria-setsize="items.length"
@@ -36,7 +43,12 @@
36
43
  </template>
37
44
 
38
45
  <script lang="ts">
39
- import type { SVirtualListItemKey } from './SVirtualList'
46
+ import type {
47
+ SVirtualListItemKey,
48
+ SVirtualListLiveMode,
49
+ SVirtualListSemanticRole,
50
+ SVirtualListViewportState,
51
+ } from './SVirtualList'
40
52
 
41
53
  /** Публичный контракт virtual list принадлежит source-SFC runtime. / The public virtual-list contract is owned by the source-SFC runtime. */
42
54
  export interface SVirtualListProps<Item = unknown> {
@@ -50,6 +62,13 @@ export interface SVirtualListProps<Item = unknown> {
50
62
  itemLabel: (item: Item, index: number) => string
51
63
  accessibleLabel: string
52
64
  externalScrollTarget?: HTMLElement | null
65
+ /** Controlled follow-tail intent. Use v-model:followTail when user detachment must persist in the parent. */
66
+ followTail?: boolean
67
+ nearStartThreshold?: number
68
+ endThreshold?: number
69
+ semanticRole?: SVirtualListSemanticRole
70
+ live?: SVirtualListLiveMode
71
+ busy?: boolean
53
72
  }
54
73
  </script>
55
74
 
@@ -65,17 +84,40 @@ import {
65
84
  } from 'vue'
66
85
  import { useOwnedAttrs } from '../../../internal/ownedAttrs'
67
86
  import { useInteractiveLeafRegistration } from '../../../internal/passiveContentContract'
87
+ import {
88
+ validateBoolean,
89
+ validateBoundedNumber,
90
+ validateExactString,
91
+ } from '../../../internal/runtimeContract'
92
+ import type {
93
+ SVirtualListAlignment,
94
+ SVirtualListAnchor,
95
+ SVirtualListViewportSnapshot,
96
+ SVirtualListVisibleRange,
97
+ } from './SVirtualList'
68
98
  defineOptions({ inheritAttrs: false })
69
99
 
70
- type Alignment = 'auto' | 'start' | 'center' | 'end'
71
100
  type StyleObject = Record<string, string>
101
+ type ScrollIntent = 'up' | 'down' | 'unknown' | null
72
102
 
73
103
  const props = withDefaults(defineProps<SVirtualListProps<T>>(), {
74
104
  overscan: 8,
75
105
  height: null,
76
106
  externalScrollTarget: null,
107
+ followTail: false,
108
+ nearStartThreshold: 80,
109
+ endThreshold: 24,
110
+ semanticRole: 'list',
111
+ live: 'off',
112
+ busy: false,
77
113
  })
78
114
 
115
+ const emit = defineEmits<{
116
+ (event: 'update:followTail', value: boolean): void
117
+ (event: 'viewport-change', state: SVirtualListViewportState): void
118
+ (event: 'near-start', state: SVirtualListViewportState): void
119
+ }>()
120
+
79
121
  defineSlots<{
80
122
  default(props: { item: T; index: number; position: number; setSize: number }): unknown
81
123
  }>()
@@ -91,6 +133,37 @@ const renderedItemKeysByElement = new Map<HTMLElement, SVirtualListItemKey>()
91
133
  const renderedItemRefs = new Map<SVirtualListItemKey, VNodeRef>()
92
134
  let resizeObserver: ResizeObserver | null = null
93
135
  let boundExternalTarget: HTMLElement | null = null
136
+ let scrollIntent: ScrollIntent = null
137
+ let pointerOwnsScrollbar = false
138
+ let lastNearStart = false
139
+ let lastPublishedState = ''
140
+ const followTailIntent = ref(props.followTail)
141
+
142
+ const validatedSemanticRole = computed<SVirtualListSemanticRole>(() => validateExactString(
143
+ 'SVirtualList',
144
+ 'semanticRole',
145
+ props.semanticRole,
146
+ ['list', 'log'] as const,
147
+ ))
148
+ const validatedLive = computed<SVirtualListLiveMode>(() => validateExactString(
149
+ 'SVirtualList',
150
+ 'live',
151
+ props.live,
152
+ ['off', 'polite', 'assertive'] as const,
153
+ ))
154
+ const validatedBusy = computed(() => validateBoolean('SVirtualList', 'busy', props.busy))
155
+ const validatedNearStartThreshold = computed(() => validateBoundedNumber(
156
+ 'SVirtualList',
157
+ 'nearStartThreshold',
158
+ props.nearStartThreshold,
159
+ { minimum: 0, maximum: 10_000 },
160
+ ))
161
+ const validatedEndThreshold = computed(() => validateBoundedNumber(
162
+ 'SVirtualList',
163
+ 'endThreshold',
164
+ props.endThreshold,
165
+ { minimum: 0, maximum: 10_000 },
166
+ ))
94
167
 
95
168
  const isExternal = computed(() => props.externalScrollTarget !== null)
96
169
 
@@ -199,17 +272,151 @@ function externalOffset(): number {
199
272
  return root.getBoundingClientRect().top - target.getBoundingClientRect().top + target.scrollTop
200
273
  }
201
274
 
275
+ function scrollOwner(): HTMLElement | null {
276
+ return props.externalScrollTarget ?? container.value
277
+ }
278
+
279
+ function maximumScrollOffset(): number {
280
+ return Math.max(0, totalHeight.value - viewportHeight.value)
281
+ }
282
+
283
+ function currentAnchor(keys: readonly SVirtualListItemKey[] = itemKeys.value): SVirtualListAnchor | null {
284
+ if (keys.length === 0) return null
285
+ const sizes = keys.map((key) => measurements.value.get(key) ?? props.itemSize)
286
+ let accumulated = 0
287
+ let index = 0
288
+ const viewportOffset = Math.max(0, scrollTop.value)
289
+ while (index < sizes.length - 1 && accumulated + (sizes[index] ?? props.itemSize) <= viewportOffset) {
290
+ accumulated += sizes[index] ?? props.itemSize
291
+ index += 1
292
+ }
293
+ const key = keys[index]
294
+ if (key === undefined) return null
295
+ return { key, offset: viewportOffset - accumulated }
296
+ }
297
+
298
+ function visibleRange(): SVirtualListVisibleRange {
299
+ if (props.items.length === 0) {
300
+ return { firstIndex: -1, lastIndex: -1, firstKey: null, lastKey: null }
301
+ }
302
+ const firstIndex = firstVisibleIndex.value
303
+ const lastIndex = Math.max(firstIndex, lastVisibleIndex.value)
304
+ return {
305
+ firstIndex,
306
+ lastIndex,
307
+ firstKey: itemKeys.value[firstIndex] ?? null,
308
+ lastKey: itemKeys.value[lastIndex] ?? null,
309
+ }
310
+ }
311
+
312
+ function getViewportState(): SVirtualListViewportState {
313
+ const nearStart = scrollTop.value <= validatedNearStartThreshold.value
314
+ const atEnd = maximumScrollOffset() - scrollTop.value <= validatedEndThreshold.value
315
+ return {
316
+ anchor: currentAnchor(),
317
+ visibleRange: visibleRange(),
318
+ nearStart,
319
+ atEnd,
320
+ pinnedToEnd: followTailIntent.value,
321
+ }
322
+ }
323
+
324
+ function publishViewportState(): void {
325
+ const state = getViewportState()
326
+ const signature = JSON.stringify(state)
327
+ if (signature !== lastPublishedState) {
328
+ lastPublishedState = signature
329
+ emit('viewport-change', state)
330
+ }
331
+ if (state.nearStart && !lastNearStart) emit('near-start', state)
332
+ lastNearStart = state.nearStart
333
+ }
334
+
335
+ function setFollowTail(value: boolean): void {
336
+ const validated = validateBoolean('SVirtualList', 'setFollowTail value', value)
337
+ if (followTailIntent.value === validated) return
338
+ followTailIntent.value = validated
339
+ emit('update:followTail', validated)
340
+ publishViewportState()
341
+ }
342
+
202
343
  function updateViewport(): void {
203
- const target = props.externalScrollTarget ?? container.value
344
+ const target = scrollOwner()
204
345
  if (!target) return
205
346
  viewportHeight.value = target.clientHeight
206
347
  scrollTop.value = Math.max(0, target.scrollTop - (isExternal.value ? externalOffset() : 0))
348
+ if (scrollIntent === 'up') setFollowTail(false)
349
+ if (scrollIntent === 'down' && maximumScrollOffset() - scrollTop.value <= validatedEndThreshold.value) {
350
+ setFollowTail(true)
351
+ }
352
+ scrollIntent = null
353
+ publishViewportState()
207
354
  }
208
355
 
209
- function preserveAnchor(index: number, delta: number): void {
210
- if (index >= firstVisibleIndex.value || delta === 0) return
211
- const target = props.externalScrollTarget ?? container.value
212
- if (target) target.scrollTop += delta
356
+ function handleWheel(event: WheelEvent): void {
357
+ if (event.deltaY < 0) {
358
+ scrollIntent = 'up'
359
+ setFollowTail(false)
360
+ } else if (event.deltaY > 0) {
361
+ scrollIntent = 'down'
362
+ }
363
+ }
364
+
365
+ function handleKeydown(event: KeyboardEvent): void {
366
+ if (['ArrowUp', 'PageUp', 'Home'].includes(event.key)) {
367
+ scrollIntent = 'up'
368
+ setFollowTail(false)
369
+ } else if (['ArrowDown', 'PageDown', 'End'].includes(event.key)) {
370
+ scrollIntent = 'down'
371
+ }
372
+ }
373
+
374
+ function eventOwnsScrollbar(event: PointerEvent): boolean {
375
+ const target = scrollOwner()
376
+ if (!target || event.currentTarget !== target) return false
377
+ return event.offsetX >= target.clientWidth || event.offsetY >= target.clientHeight
378
+ }
379
+
380
+ function handlePointerDown(event: PointerEvent): void {
381
+ pointerOwnsScrollbar = eventOwnsScrollbar(event)
382
+ if (!pointerOwnsScrollbar) return
383
+ scrollIntent = 'unknown'
384
+ setFollowTail(false)
385
+ }
386
+
387
+ function handlePointerUp(): void {
388
+ if (!pointerOwnsScrollbar) return
389
+ pointerOwnsScrollbar = false
390
+ if (maximumScrollOffset() - scrollTop.value <= validatedEndThreshold.value) setFollowTail(true)
391
+ scrollIntent = null
392
+ }
393
+
394
+ function restoreAnchor(anchor: SVirtualListAnchor, failWhenMissing: boolean): boolean {
395
+ const index = itemKeyIndexes.value.get(anchor.key)
396
+ if (index === undefined) {
397
+ if (failWhenMissing) {
398
+ throw new RangeError(`SVirtualList cannot restore missing anchor key ${describeItemKey(anchor.key)}`)
399
+ }
400
+ return false
401
+ }
402
+ const target = scrollOwner()
403
+ if (!target) return false
404
+ const rowSize = itemSizes.value[index] ?? props.itemSize
405
+ if (!Number.isFinite(anchor.offset) || anchor.offset < 0) {
406
+ throw new RangeError(
407
+ `SVirtualList anchor offset ${anchor.offset} is outside row ${describeItemKey(anchor.key)} size ${rowSize}`,
408
+ )
409
+ }
410
+ if (failWhenMissing && anchor.offset >= rowSize) {
411
+ throw new RangeError(
412
+ `SVirtualList anchor offset ${anchor.offset} is outside row ${describeItemKey(anchor.key)} size ${rowSize}`,
413
+ )
414
+ }
415
+ const resolvedOffset = Math.min(anchor.offset, Math.max(0, rowSize - 1))
416
+ const nextOffset = (offsets.value[index] ?? 0) + resolvedOffset
417
+ target.scrollTop = Math.max(0, nextOffset + (isExternal.value ? externalOffset() : 0))
418
+ updateViewport()
419
+ return true
213
420
  }
214
421
 
215
422
  function unregisterRenderedItem(key: SVirtualListItemKey): void {
@@ -264,32 +471,38 @@ function renderedItemRef(key: SVirtualListItemKey): VNodeRef {
264
471
  return itemRef
265
472
  }
266
473
 
267
- function measureEntry(entry: ResizeObserverEntry): void {
268
- if (entry.target === container.value || entry.target === props.externalScrollTarget) {
269
- updateViewport()
270
- return
271
- }
272
- if (!(entry.target instanceof HTMLElement)) {
273
- throw new TypeError('SVirtualList received a ResizeObserver entry for a non-HTMLElement target')
274
- }
275
- const key = renderedItemKeysByElement.get(entry.target)
276
- if (key === undefined || renderedItemsByKey.get(key) !== entry.target) {
277
- throw new Error('SVirtualList received a ResizeObserver entry for an unregistered rendered item')
278
- }
279
- const index = itemKeyIndexes.value.get(key)
280
- if (index === undefined) {
281
- throw new Error(`SVirtualList received a ResizeObserver entry for stale key ${describeItemKey(key)}`)
282
- }
283
- const size = entry.borderBoxSize[0]?.blockSize ?? entry.contentRect.height
284
- if (!Number.isFinite(size) || size <= 0 || measurements.value.get(key) === size) return
285
- const previous = measurements.value.get(key) ?? props.itemSize
474
+ function measureEntries(entries: readonly ResizeObserverEntry[]): void {
475
+ let viewportChanged = false
476
+ const anchor = followTailIntent.value ? null : currentAnchor()
286
477
  const next = new Map(measurements.value)
287
- next.set(key, size)
288
- measurements.value = next
289
- preserveAnchor(index, size - previous)
478
+ let measurementsChanged = false
479
+ for (const entry of entries) {
480
+ if (entry.target === container.value || entry.target === props.externalScrollTarget) {
481
+ viewportChanged = true
482
+ continue
483
+ }
484
+ if (!(entry.target instanceof HTMLElement)) {
485
+ throw new TypeError('SVirtualList received a ResizeObserver entry for a non-HTMLElement target')
486
+ }
487
+ const key = renderedItemKeysByElement.get(entry.target)
488
+ if (key === undefined || renderedItemsByKey.get(key) !== entry.target) {
489
+ throw new Error('SVirtualList received a ResizeObserver entry for an unregistered rendered item')
490
+ }
491
+ if (itemKeyIndexes.value.get(key) === undefined) {
492
+ throw new Error(`SVirtualList received a ResizeObserver entry for stale key ${describeItemKey(key)}`)
493
+ }
494
+ const size = entry.borderBoxSize[0]?.blockSize ?? entry.contentRect.height
495
+ if (!Number.isFinite(size) || size <= 0 || next.get(key) === size) continue
496
+ next.set(key, size)
497
+ measurementsChanged = true
498
+ }
499
+ if (measurementsChanged) measurements.value = next
500
+ if (followTailIntent.value && measurementsChanged) scrollToEnd({ follow: true })
501
+ else if (anchor && measurementsChanged) restoreAnchor(anchor, false)
502
+ if (viewportChanged || measurementsChanged) updateViewport()
290
503
  }
291
504
 
292
- function scrollToIndex(index: number, alignment: Alignment = 'auto'): void {
505
+ function scrollToIndex(index: number, alignment: SVirtualListAlignment = 'auto'): void {
293
506
  if (!Number.isInteger(index) || index < 0 || index >= props.items.length) {
294
507
  throw new RangeError(`SVirtualList index ${index} is outside 0..${Math.max(props.items.length - 1, 0)}`)
295
508
  }
@@ -308,7 +521,48 @@ function scrollToIndex(index: number, alignment: Alignment = 'auto'): void {
308
521
  updateViewport()
309
522
  }
310
523
 
311
- async function focusItem(index: number, alignment: Alignment = 'auto'): Promise<void> {
524
+ function scrollToKey(key: SVirtualListItemKey, alignment: SVirtualListAlignment = 'auto'): void {
525
+ const index = itemKeyIndexes.value.get(key)
526
+ if (index === undefined) {
527
+ throw new RangeError(`SVirtualList cannot scroll to missing key ${describeItemKey(key)}`)
528
+ }
529
+ scrollToIndex(index, alignment)
530
+ }
531
+
532
+ function scrollToEnd(options: { follow?: boolean } = {}): void {
533
+ const target = scrollOwner()
534
+ if (!target) return
535
+ if (options.follow ?? true) setFollowTail(true)
536
+ target.scrollTop = maximumScrollOffset() + (isExternal.value ? externalOffset() : 0)
537
+ updateViewport()
538
+ }
539
+
540
+ function captureViewport(): SVirtualListViewportSnapshot {
541
+ return {
542
+ anchor: currentAnchor(),
543
+ followTail: followTailIntent.value,
544
+ }
545
+ }
546
+
547
+ async function restoreViewport(snapshot: SVirtualListViewportSnapshot): Promise<void> {
548
+ if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
549
+ throw new TypeError('SVirtualList restoreViewport snapshot must be an object')
550
+ }
551
+ const followTail = validateBoolean('SVirtualList', 'restoreViewport.followTail', snapshot.followTail)
552
+ await nextTick()
553
+ if (followTail) {
554
+ scrollToEnd({ follow: true })
555
+ return
556
+ }
557
+ if (snapshot.anchor === null) {
558
+ setFollowTail(false)
559
+ return
560
+ }
561
+ setFollowTail(false)
562
+ restoreAnchor(snapshot.anchor, true)
563
+ }
564
+
565
+ async function focusItem(index: number, alignment: SVirtualListAlignment = 'auto'): Promise<void> {
312
566
  const key = itemKeys.value[index]
313
567
  scrollToIndex(index, alignment)
314
568
  await nextTick()
@@ -331,29 +585,53 @@ async function focusItem(index: number, alignment: Alignment = 'auto'): Promise<
331
585
  element.focus()
332
586
  }
333
587
 
334
- defineExpose({ scrollToIndex, focusItem })
588
+ defineExpose({
589
+ captureViewport,
590
+ focusItem,
591
+ getViewportState,
592
+ restoreViewport,
593
+ scrollToEnd,
594
+ scrollToIndex,
595
+ scrollToKey,
596
+ setFollowTail,
597
+ })
335
598
 
336
599
  function bindExternalTarget(target: HTMLElement | null): void {
337
600
  boundExternalTarget?.removeEventListener('scroll', updateViewport)
601
+ boundExternalTarget?.removeEventListener('wheel', handleWheel)
602
+ boundExternalTarget?.removeEventListener('keydown', handleKeydown)
603
+ boundExternalTarget?.removeEventListener('pointerdown', handlePointerDown)
604
+ boundExternalTarget?.removeEventListener('pointerup', handlePointerUp)
338
605
  if (boundExternalTarget) resizeObserver?.unobserve(boundExternalTarget)
339
606
  boundExternalTarget = target
340
607
  boundExternalTarget?.addEventListener('scroll', updateViewport, { passive: true })
608
+ boundExternalTarget?.addEventListener('wheel', handleWheel, { passive: true })
609
+ boundExternalTarget?.addEventListener('keydown', handleKeydown)
610
+ boundExternalTarget?.addEventListener('pointerdown', handlePointerDown)
611
+ boundExternalTarget?.addEventListener('pointerup', handlePointerUp)
341
612
  if (boundExternalTarget) resizeObserver?.observe(boundExternalTarget)
342
613
  updateViewport()
343
614
  }
344
615
 
345
616
  onMounted(() => {
346
617
  if (typeof ResizeObserver !== 'undefined') {
347
- resizeObserver = new ResizeObserver((entries) => entries.forEach(measureEntry))
618
+ resizeObserver = new ResizeObserver((entries) => measureEntries(entries))
348
619
  if (container.value) resizeObserver.observe(container.value)
349
620
  for (const element of renderedItemsByKey.values()) resizeObserver.observe(element)
350
621
  }
351
622
  bindExternalTarget(props.externalScrollTarget)
352
623
  updateViewport()
624
+ if (followTailIntent.value) void nextTick(() => scrollToEnd({ follow: true }))
353
625
  })
354
626
 
355
627
  watch(() => props.externalScrollTarget, bindExternalTarget)
356
- watch(() => itemKeys.value, (keys) => {
628
+ watch(() => props.followTail, (value) => {
629
+ followTailIntent.value = validateBoolean('SVirtualList', 'followTail', value)
630
+ if (followTailIntent.value) void nextTick(() => scrollToEnd({ follow: false }))
631
+ else publishViewportState()
632
+ })
633
+ watch(() => itemKeys.value, async (keys, previousKeys) => {
634
+ const anchor = followTailIntent.value ? null : currentAnchor(previousKeys)
357
635
  const activeKeys = new Set(keys)
358
636
  measurements.value = new Map([...measurements.value].filter(([key]) => activeKeys.has(key)))
359
637
  for (const key of renderedItemsByKey.keys()) {
@@ -362,11 +640,18 @@ watch(() => itemKeys.value, (keys) => {
362
640
  for (const key of renderedItemRefs.keys()) {
363
641
  if (!activeKeys.has(key)) renderedItemRefs.delete(key)
364
642
  }
643
+ await nextTick()
644
+ if (followTailIntent.value) scrollToEnd({ follow: true })
645
+ else if (anchor) restoreAnchor(anchor, false)
365
646
  updateViewport()
366
647
  })
367
648
 
368
649
  onBeforeUnmount(() => {
369
650
  boundExternalTarget?.removeEventListener('scroll', updateViewport)
651
+ boundExternalTarget?.removeEventListener('wheel', handleWheel)
652
+ boundExternalTarget?.removeEventListener('keydown', handleKeydown)
653
+ boundExternalTarget?.removeEventListener('pointerdown', handlePointerDown)
654
+ boundExternalTarget?.removeEventListener('pointerup', handlePointerUp)
370
655
  resizeObserver?.disconnect()
371
656
  renderedItemsByKey.clear()
372
657
  renderedItemKeysByElement.clear()
@@ -116,6 +116,9 @@
116
116
  --s-control-hitbox-md: 2.5rem;
117
117
  --s-control-hitbox-lg: 3rem;
118
118
  --s-control-indicator-size: 1.25rem;
119
+ --s-resize-handle-hitbox-compact: 0.375rem;
120
+ --s-resize-handle-hitbox-standard: 0.75rem;
121
+ --s-resize-handle-hitbox-spacious: 2rem;
119
122
  --s-icon-size-2xs: 0.75rem;
120
123
  --s-icon-size-xs: 1rem;
121
124
  --s-icon-size-sm: 1.25rem;