@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.
- package/README.md +174 -0
- package/docs/public-api.md +8 -0
- package/docs/theming.md +17 -0
- package/package.json +10 -5
- package/src/components/layout/SResizeHandle.vue +45 -5
- package/src/components/shared/_internal/SDialogSurface.css +18 -0
- package/src/components/shared/_internal/SDialogSurface.vue +6 -1
- package/src/components/shared/_internal/dialogSurface.ts +7 -1
- package/src/components/shared/containers/SDrawer.css +11 -2
- package/src/components/shared/containers/SDrawer.vue +43 -7
- package/src/components/shared/controls/SInteractiveSurface.css +50 -4
- package/src/components/shared/controls/SInteractiveSurface.vue +44 -30
- package/src/components/shared/controls/STextarea.ts +13 -0
- package/src/components/shared/controls/STextarea.vue +391 -37
- package/src/components/shared/data-display/SActionCard.css +6 -2
- package/src/components/shared/data-display/SActionCard.vue +13 -3
- package/src/components/shared/data-display/SActionListItem.css +7 -8
- package/src/components/shared/data-display/SActionListItem.vue +21 -18
- package/src/components/shared/data-display/SChart.vue +2 -2
- package/src/components/shared/data-display/SChip.css +7 -2
- package/src/components/shared/data-display/SCodeBlock.vue +4 -2
- package/src/components/shared/data-display/SVirtualList.ts +33 -0
- package/src/components/shared/data-display/SVirtualList.vue +321 -36
- package/src/internal/chartGeometry.ts +126 -22
- package/src/styles/tokens.css +3 -0
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
@reference "../../../styles/reference.css";
|
|
2
2
|
|
|
3
3
|
.s-action-list-item {
|
|
4
|
-
@apply
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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 {
|
|
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 =
|
|
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
|
|
210
|
-
if (
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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:
|
|
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
|
-
|
|
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({
|
|
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
|
|
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(() =>
|
|
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()
|