@pgcorp/ui-kit 0.8.0 → 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.
@@ -1,9 +1,8 @@
1
1
  @reference "../../../styles/reference.css";
2
2
 
3
3
  .s-action-list-item {
4
- @apply grid min-h-12 min-w-0 items-center gap-2;
4
+ @apply min-h-12 min-w-0;
5
5
  height: max-content;
6
- grid-template-columns: minmax(0, 1fr) auto;
7
6
  }
8
7
 
9
8
  .s-action-list-item[data-height-mode='fill'] {
@@ -94,7 +93,7 @@
94
93
  .s-action-list-item[data-density='compact'] .s-action-list-item__icon { @apply h-7 w-7; }
95
94
 
96
95
  .s-action-list-item[data-trailing-layout='stacked'] {
97
- grid-template-columns: minmax(0, 1fr);
96
+ @apply w-full;
98
97
  }
99
98
 
100
99
  .s-action-list-item[data-trailing-layout='stacked'] .s-action-list-item__trailing {
@@ -103,12 +102,12 @@
103
102
  }
104
103
 
105
104
  @container (max-width: 32rem) {
106
- .s-action-list-item[data-trailing-layout='responsive'] {
107
- grid-template-columns: minmax(0, 1fr);
108
- }
109
-
110
105
  .s-action-list-item[data-trailing-layout='responsive'] .s-action-list-item__trailing {
111
- @apply w-full justify-end pb-2 pr-2;
106
+ @apply w-full flex-wrap justify-end gap-y-1 pb-2 pr-2;
112
107
  padding-left: calc(3.5rem + var(--s-action-list-item-indent));
113
108
  }
109
+
110
+ .s-action-list-item[data-trailing-layout='responsive'] .s-action-list-item__trailing > * {
111
+ max-width: 100%;
112
+ }
114
113
  }
@@ -194,6 +194,7 @@ function emitAction(event: MouseEvent): void {
194
194
  :padding="surfacePadding"
195
195
  :radius="surfaceRadius"
196
196
  :fill="heightMode === 'fill' ? 'container' : 'content'"
197
+ :actions-layout="trailingLayout"
197
198
  @activate="emit('activate', $event)"
198
199
  >
199
200
  <div class="s-action-list-item__main" :class="{ 's-action-list-item__main--static': resolvedMode === 'static' }">
@@ -212,25 +213,27 @@ function emitAction(event: MouseEvent): void {
212
213
  </span>
213
214
  </span>
214
215
  </div>
216
+ <template v-if="hasTrailing" #actions>
217
+ <div class="s-action-list-item__trailing">
218
+ <slot name="status" />
219
+ <SBadge v-if="statusLabel" :severity="statusSeverity">{{ statusLabel }}</SBadge>
220
+ <SBadge v-for="badge in validatedBadges" :key="badge.id" :severity="badge.severity">
221
+ {{ badge.label }}
222
+ </SBadge>
223
+ <SButton
224
+ v-if="actionLabel"
225
+ size="sm"
226
+ :label="actionLabel"
227
+ :leading-icon="actionIcon"
228
+ :severity="actionSeverity"
229
+ :disabled="surfaceDisabled || actionDisabled"
230
+ @click="emitAction"
231
+ />
232
+ <slot name="actions" :disabled="surfaceDisabled || actionDisabled" :emit-action="emitAction" />
233
+ <component :is="trailingIcon" v-if="trailingIcon" class="s-action-list-item__trailing-icon" aria-hidden="true" />
234
+ </div>
235
+ </template>
215
236
  </SActionCard>
216
- <div v-if="hasTrailing" class="s-action-list-item__trailing">
217
- <slot name="status" />
218
- <SBadge v-if="statusLabel" :severity="statusSeverity">{{ statusLabel }}</SBadge>
219
- <SBadge v-for="badge in validatedBadges" :key="badge.id" :severity="badge.severity">
220
- {{ badge.label }}
221
- </SBadge>
222
- <SButton
223
- v-if="actionLabel"
224
- size="sm"
225
- :label="actionLabel"
226
- :leading-icon="actionIcon"
227
- :severity="actionSeverity"
228
- :disabled="surfaceDisabled || actionDisabled"
229
- @click="emitAction"
230
- />
231
- <slot name="actions" :disabled="surfaceDisabled || actionDisabled" :emit-action="emitAction" />
232
- <component :is="trailingIcon" v-if="trailingIcon" class="s-action-list-item__trailing-icon" aria-hidden="true" />
233
- </div>
234
237
  </div>
235
238
  </template>
236
239
 
@@ -352,7 +352,7 @@ watchEffect(() => {
352
352
  :y="tick.position"
353
353
  text-anchor="end"
354
354
  dominant-baseline="middle"
355
- >{{ tick.label }}</text>
355
+ >{{ tick.displayLabel }}<title v-if="tick.displayLabel !== tick.label">{{ tick.label }}</title></text>
356
356
  <text
357
357
  v-if="validatedYAxisLabel"
358
358
  class="s-chart__axis-title"
@@ -369,7 +369,7 @@ watchEffect(() => {
369
369
  :key="category.key"
370
370
  :x="category.x"
371
371
  :y="model.plotY + model.plotHeight + 18"
372
- text-anchor="middle"
372
+ :text-anchor="category.textAnchor"
373
373
  >
374
374
  <tspan
375
375
  v-for="(line, lineIndex) in category.lines"
@@ -8,12 +8,17 @@
8
8
  .s-chip[data-mode='action']:not([data-s-pill-disabled='true']),
9
9
  .s-chip[data-mode='link']:not([data-s-pill-disabled='true']) {
10
10
  @apply cursor-pointer;
11
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 42%, transparent);
11
12
  transition:
12
- filter var(--s-motion-standard) ease,
13
13
  box-shadow var(--s-motion-standard) ease;
14
14
  }
15
15
 
16
16
  .s-chip[data-mode='action']:not([data-s-pill-disabled='true']):hover,
17
17
  .s-chip[data-mode='link']:not([data-s-pill-disabled='true']):hover {
18
- filter: brightness(0.96);
18
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 68%, transparent);
19
+ }
20
+
21
+ .s-chip[data-mode='action']:active,
22
+ .s-chip[data-mode='link']:active {
23
+ box-shadow: inset 0 0 0 1px currentColor;
19
24
  }
@@ -38,8 +38,7 @@
38
38
  :capabilities="editorCapabilities"
39
39
  :read-only="validatedReadOnly"
40
40
  :viewport="validatedViewport"
41
- :aria-label="props.ariaLabel"
42
- :aria-labelledby="props.ariaLabelledby"
41
+ v-bind="editorAccessibleName"
43
42
  :placeholder="props.placeholder"
44
43
  :next-selection-pos="props.nextSelectionPos"
45
44
  @update:model-value="emit('update:code', $event)"
@@ -137,6 +136,9 @@ const codeEditor = ref<InstanceType<typeof import('./SCodeEditor.vue')['default'
137
136
  const plainSurface = ref<HTMLElement | null>(null);
138
137
  const AsyncSCodeEditor = defineAsyncComponent(() => import('../../../internal/lazyCodeEditor'));
139
138
  const ownedAttrs = useOwnedAttrs({ component: 'SCodeBlock', owner: 'code block root' });
139
+ const editorAccessibleName = computed<AccessibleName>(() => props.ariaLabel !== undefined
140
+ ? { ariaLabel: props.ariaLabel }
141
+ : { ariaLabelledby: props.ariaLabelledby });
140
142
  const emit = defineEmits<{
141
143
  (e: 'cursor-activity', payload: { pos: number; lineFrom: number; lineTo: number; lineText: string; cursorCol: number }): void
142
144
  (e: 'update:code', value: string): void
@@ -1,4 +1,37 @@
1
1
  /** Ключ элемента виртуального списка. / Virtual-list item key. */
2
2
  export type SVirtualListItemKey = string | number
3
3
 
4
+ /** Выравнивание явного перехода к строке. / Alignment for explicit row navigation. */
5
+ export type SVirtualListAlignment = 'auto' | 'start' | 'center' | 'end'
6
+
7
+ /** Устойчивый viewport anchor: доменный key и смещение внутри строки. */
8
+ export interface SVirtualListAnchor {
9
+ key: SVirtualListItemKey
10
+ offset: number
11
+ }
12
+
13
+ export interface SVirtualListVisibleRange {
14
+ firstIndex: number
15
+ lastIndex: number
16
+ firstKey: SVirtualListItemKey | null
17
+ lastKey: SVirtualListItemKey | null
18
+ }
19
+
20
+ export interface SVirtualListViewportState {
21
+ anchor: SVirtualListAnchor | null
22
+ visibleRange: SVirtualListVisibleRange
23
+ nearStart: boolean
24
+ atEnd: boolean
25
+ pinnedToEnd: boolean
26
+ }
27
+
28
+ /** Сериализуемый снимок позиции для переключения между keyed collections. */
29
+ export interface SVirtualListViewportSnapshot {
30
+ anchor: SVirtualListAnchor | null
31
+ followTail: boolean
32
+ }
33
+
34
+ export type SVirtualListSemanticRole = 'list' | 'log'
35
+ export type SVirtualListLiveMode = 'off' | 'polite' | 'assertive'
36
+
4
37
  export type { SVirtualListProps } from './SVirtualList.vue'
@@ -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()