wave-ui 3.27.2 → 3.28.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/dist/types/types/$waveui.d.ts +6 -0
- package/dist/types/types/components/WAccordion.d.ts +7 -0
- package/dist/types/types/components/WBreadcrumbs.d.ts +7 -0
- package/dist/types/types/components/WButton.d.ts +7 -0
- package/dist/types/types/components/WList.d.ts +7 -0
- package/dist/types/types/components/WScrollable.d.ts +143 -0
- package/dist/types/types/components/WScrollable.js +2 -0
- package/dist/types/types/components/WTabs.d.ts +7 -0
- package/dist/types/types/components/WTag.d.ts +7 -0
- package/dist/types/types/components/index.d.ts +1 -0
- package/dist/wave-ui.cjs.js +3 -3
- package/dist/wave-ui.css +1 -1
- package/dist/wave-ui.esm.js +1392 -925
- package/dist/wave-ui.umd.js +3 -3
- package/package.json +6 -6
- package/src/wave-ui/components/w-accordion/index.vue +5 -1
- package/src/wave-ui/components/w-accordion/item.vue +42 -12
- package/src/wave-ui/components/w-breadcrumbs.vue +13 -2
- package/src/wave-ui/components/w-button/button.vue +15 -1
- package/src/wave-ui/components/w-button/index.vue +2 -1
- package/src/wave-ui/components/w-list.vue +12 -0
- package/src/wave-ui/components/w-scrollable.vue +667 -94
- package/src/wave-ui/components/w-tabs/index.vue +10 -0
- package/src/wave-ui/components/w-tag.vue +14 -0
- package/src/wave-ui/core.js +2 -0
- package/src/wave-ui/mixins/ripple.js +39 -0
- package/src/wave-ui/scss/_ripple.scss +37 -0
- package/src/wave-ui/scss/index.scss +1 -0
- package/src/wave-ui/scss/variables/_variables.scss +0 -2
- package/src/wave-ui/utils/config.js +2 -0
- package/src/wave-ui/utils/ripple.js +71 -0
- package/src/wave-ui/utils/wave-ripple-directive.js +40 -0
|
@@ -1,19 +1,47 @@
|
|
|
1
1
|
<template lang="pug">
|
|
2
2
|
.w-scrollable(
|
|
3
|
+
ref="rootEl"
|
|
4
|
+
v-bind="$attrs"
|
|
5
|
+
:tabindex="computedTabindex"
|
|
6
|
+
role="region"
|
|
7
|
+
:aria-disabled="props.disabled ? 'true' : undefined"
|
|
3
8
|
@mouseenter="onMouseEnter"
|
|
4
9
|
@mouseleave="onMouseLeave"
|
|
5
|
-
@
|
|
10
|
+
@wheel="onWheel"
|
|
11
|
+
@keydown="onKeydown"
|
|
6
12
|
:class="scrollableClasses"
|
|
7
|
-
v-bind="$attrs"
|
|
8
13
|
:style="scrollableStyles")
|
|
9
|
-
.w-scrollable__content(
|
|
14
|
+
.w-scrollable__content(
|
|
15
|
+
:id="contentId"
|
|
16
|
+
ref="scrollableEl"
|
|
17
|
+
:class="contentClasses"
|
|
18
|
+
@scroll="onNativeScroll")
|
|
10
19
|
slot
|
|
11
|
-
.w-scrollable__scrollbar(
|
|
12
|
-
|
|
20
|
+
.w-scrollable__scrollbar(
|
|
21
|
+
v-show="hasOverflow && showCustomTrack"
|
|
22
|
+
ref="trackEl"
|
|
23
|
+
:class="scrollbarClasses"
|
|
24
|
+
role="presentation"
|
|
25
|
+
@pointerdown.prevent="onTrackPointerDown")
|
|
26
|
+
.w-scrollable__scrollbar-thumb(
|
|
27
|
+
ref="thumbEl"
|
|
28
|
+
:class="{ [props.color]: !!props.color }"
|
|
29
|
+
:style="thumbStyles"
|
|
30
|
+
role="scrollbar"
|
|
31
|
+
:aria-controls="contentId"
|
|
32
|
+
:aria-orientation="scrollbarOrientation"
|
|
33
|
+
:aria-valuemin="0"
|
|
34
|
+
:aria-valuemax="scrollbarValueMax"
|
|
35
|
+
:aria-valuenow="scrollbarValueNow"
|
|
36
|
+
:aria-disabled="props.disabled ? 'true' : undefined"
|
|
37
|
+
:aria-label="thumbAriaLabel"
|
|
38
|
+
tabindex="-1"
|
|
39
|
+
@pointerdown.stop.prevent="onThumbPointerDown")
|
|
13
40
|
</template>
|
|
14
41
|
|
|
15
42
|
<script setup>
|
|
16
|
-
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
|
43
|
+
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch, useId, useAttrs } from 'vue'
|
|
44
|
+
import { objectifyClasses } from '../utils/index'
|
|
17
45
|
|
|
18
46
|
defineOptions({ name: 'WScrollable' })
|
|
19
47
|
|
|
@@ -21,10 +49,45 @@ const props = defineProps({
|
|
|
21
49
|
color: { type: String, default: 'primary' },
|
|
22
50
|
bgColor: { type: String },
|
|
23
51
|
width: { type: [Number, String] },
|
|
24
|
-
height: { type: [Number, String] }
|
|
52
|
+
height: { type: [Number, String] },
|
|
53
|
+
/** Classes for the inner scroll container (`.w-scrollable__content`). */
|
|
54
|
+
contentClass: { type: [String, Object, Array], default: undefined },
|
|
55
|
+
disabled: { type: Boolean, default: false },
|
|
56
|
+
tabindex: { type: [Number, String], default: 0 },
|
|
57
|
+
wheelStep: { type: Number, default: 40 },
|
|
58
|
+
/** When true, scrolls along X (scrollLeft); default false is vertical (scrollTop). */
|
|
59
|
+
horizontal: { type: Boolean, default: false },
|
|
60
|
+
/**
|
|
61
|
+
* Custom scrollbar: `1` default (always when overflow), `0` hide track, `'hover'`, `'interaction'`.
|
|
62
|
+
* String `'0'` / `'1'` from templates are accepted.
|
|
63
|
+
*/
|
|
64
|
+
scrollbar: {
|
|
65
|
+
type: [Number, String],
|
|
66
|
+
default: 1,
|
|
67
|
+
validator: value => (
|
|
68
|
+
value === 0 || value === 1 || value === '0' || value === '1'
|
|
69
|
+
|| value === 'hover' || value === 'interaction'
|
|
70
|
+
)
|
|
71
|
+
},
|
|
72
|
+
/**
|
|
73
|
+
* Programmatic scroll: a number sets the primary axis offset (top when vertical, left when horizontal),
|
|
74
|
+
* or an object `{ top?, left? }` sets pixel offsets per axis (defined keys only).
|
|
75
|
+
*/
|
|
76
|
+
scrollPosition: {
|
|
77
|
+
type: [Number, Object],
|
|
78
|
+
default: null,
|
|
79
|
+
validator: value => (
|
|
80
|
+
value == null
|
|
81
|
+
|| (typeof value === 'number' && !Number.isNaN(value))
|
|
82
|
+
|| (typeof value === 'object' && value !== null && !Array.isArray(value))
|
|
83
|
+
)
|
|
84
|
+
}
|
|
25
85
|
})
|
|
26
86
|
|
|
27
|
-
const emit = defineEmits([])
|
|
87
|
+
const emit = defineEmits(['scroll', 'scroll-start', 'scroll-end'])
|
|
88
|
+
|
|
89
|
+
const SCROLL_END_DEBOUNCE_MS = 120
|
|
90
|
+
const ACTIVE_SCROLL_INDICATOR_MS = 450
|
|
28
91
|
|
|
29
92
|
const domProps = {
|
|
30
93
|
h: {
|
|
@@ -32,8 +95,8 @@ const domProps = {
|
|
|
32
95
|
topOrLeft: 'left',
|
|
33
96
|
size: 'width',
|
|
34
97
|
offsetSize: 'offsetWidth',
|
|
35
|
-
maxSize: 'max-width',
|
|
36
98
|
scrollSize: 'scrollWidth',
|
|
99
|
+
clientSize: 'clientWidth',
|
|
37
100
|
clientXorY: 'clientX',
|
|
38
101
|
deltaXorY: 'deltaX',
|
|
39
102
|
scrollTopOrLeft: 'scrollLeft'
|
|
@@ -43,177 +106,648 @@ const domProps = {
|
|
|
43
106
|
topOrLeft: 'top',
|
|
44
107
|
size: 'height',
|
|
45
108
|
offsetSize: 'offsetHeight',
|
|
46
|
-
maxSize: 'max-height',
|
|
47
109
|
scrollSize: 'scrollHeight',
|
|
110
|
+
clientSize: 'clientHeight',
|
|
48
111
|
clientXorY: 'clientY',
|
|
49
112
|
deltaXorY: 'deltaY',
|
|
50
113
|
scrollTopOrLeft: 'scrollTop'
|
|
51
114
|
}
|
|
52
115
|
}
|
|
53
116
|
|
|
54
|
-
|
|
55
|
-
const
|
|
117
|
+
const contentId = useId()
|
|
118
|
+
const attrs = useAttrs()
|
|
119
|
+
|
|
120
|
+
const rootEl = ref(null)
|
|
56
121
|
const scrollableEl = ref(null)
|
|
57
122
|
const trackEl = ref(null)
|
|
58
123
|
const thumbEl = ref(null)
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
hovered: false
|
|
63
|
-
})
|
|
124
|
+
const hovered = ref(false)
|
|
125
|
+
const isActiveScroll = ref(false)
|
|
126
|
+
const isDragging = ref(false)
|
|
64
127
|
const scrollValuePercent = ref(0)
|
|
65
|
-
const
|
|
128
|
+
const metricsVersion = ref(0)
|
|
129
|
+
const scrollSource = ref('native')
|
|
130
|
+
const skipNextNativeScrollEvent = ref(false)
|
|
131
|
+
const hasScrollStarted = ref(false)
|
|
132
|
+
const dragState = ref({ offset: 0, trackStart: 0, trackSize: 0 })
|
|
133
|
+
const dragPointerId = ref(null)
|
|
134
|
+
let scrollEndTimeout = null
|
|
135
|
+
let activeScrollTimeout = null
|
|
136
|
+
let resizeObserver = null
|
|
137
|
+
let documentCursorBeforeDrag = ''
|
|
138
|
+
|
|
139
|
+
const isHorizontal = computed(() => props.horizontal)
|
|
66
140
|
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
141
|
+
const m = computed(() => domProps[isHorizontal.value ? 'h' : 'v'])
|
|
142
|
+
const computedTabindex = computed(() => (props.disabled ? -1 : props.tabindex))
|
|
143
|
+
|
|
144
|
+
const scrollbarMode = computed(() => {
|
|
145
|
+
const s = props.scrollbar
|
|
146
|
+
if (s === 0 || s === '0') return 'off'
|
|
147
|
+
if (s === 1 || s === '1') return 'always'
|
|
148
|
+
if (s === 'hover') return 'hover'
|
|
149
|
+
if (s === 'interaction') return 'interaction'
|
|
150
|
+
return 'always'
|
|
71
151
|
})
|
|
72
152
|
|
|
73
|
-
const
|
|
153
|
+
const showCustomTrack = computed(() => scrollbarMode.value !== 'off')
|
|
74
154
|
|
|
75
155
|
const scrollableClasses = computed(() => ({
|
|
76
|
-
[`w-scrollable--${m.value.direction}`]: true
|
|
156
|
+
[`w-scrollable--${m.value.direction}`]: true,
|
|
157
|
+
'w-scrollable--disabled': props.disabled,
|
|
158
|
+
'w-scrollable--dragging': isDragging.value,
|
|
159
|
+
'w-scrollable--scrolling': isActiveScroll.value || isDragging.value
|
|
77
160
|
}))
|
|
78
161
|
|
|
79
162
|
const scrollbarClasses = computed(() => ({
|
|
80
|
-
[`w-scrollable__scrollbar--${m.value.direction}`]: true
|
|
163
|
+
[`w-scrollable__scrollbar--${m.value.direction}`]: true,
|
|
164
|
+
'w-scrollable__scrollbar--hidden': !isScrollbarVisible.value,
|
|
165
|
+
[`${props.bgColor}--bg`]: !!props.bgColor
|
|
81
166
|
}))
|
|
82
167
|
|
|
83
|
-
const
|
|
168
|
+
const contentClasses = computed(() => objectifyClasses(props.contentClass ?? {}))
|
|
169
|
+
|
|
170
|
+
const hasOverflow = computed(() => {
|
|
171
|
+
metricsVersion.value // Force recompute when manual refresh is called.
|
|
172
|
+
if (!scrollableEl.value) return false
|
|
173
|
+
return scrollableEl.value[m.value.scrollSize] > scrollableEl.value[m.value.clientSize] + 1
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
const maxScrollValue = computed(() => {
|
|
177
|
+
metricsVersion.value // Force recompute when manual refresh is called.
|
|
178
|
+
if (!scrollableEl.value) return 0
|
|
179
|
+
return Math.max(0, scrollableEl.value[m.value.scrollSize] - scrollableEl.value[m.value.clientSize])
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// WAI-ARIA: role "scrollbar" on the thumb; values are pixels along the scrollable axis
|
|
183
|
+
// (0 .. max), matching the native scroll offset on the controlled element.
|
|
184
|
+
const scrollbarOrientation = computed(() => (isHorizontal.value ? 'horizontal' : 'vertical'))
|
|
185
|
+
|
|
186
|
+
const scrollbarValueMax = computed(() => {
|
|
187
|
+
if (!hasOverflow.value) return 0
|
|
188
|
+
return Math.max(0, Math.round(maxScrollValue.value))
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
const scrollbarValueNow = computed(() => {
|
|
192
|
+
if (!scrollableEl.value) return 0
|
|
193
|
+
const raw = Math.round(scrollableEl.value[m.value.scrollTopOrLeft])
|
|
194
|
+
const max = scrollbarValueMax.value
|
|
195
|
+
return max ? Math.min(max, Math.max(0, raw)) : 0
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
const rootAriaName = computed(() => {
|
|
199
|
+
const v = attrs['aria-label'] ?? attrs.ariaLabel
|
|
200
|
+
return typeof v === 'string' && v ? v : ''
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
const thumbAriaLabel = computed(() => {
|
|
204
|
+
if (rootAriaName.value) return `${rootAriaName.value}, scroll position`
|
|
205
|
+
return isHorizontal.value ? 'Horizontal scrollbar' : 'Vertical scrollbar'
|
|
206
|
+
})
|
|
84
207
|
|
|
85
208
|
const thumbSizePercent = computed(() => {
|
|
86
|
-
|
|
87
|
-
if (!
|
|
88
|
-
const
|
|
89
|
-
|
|
209
|
+
metricsVersion.value // Force recompute when manual refresh is called.
|
|
210
|
+
if (!scrollableEl.value || !hasOverflow.value) return 100
|
|
211
|
+
const availableSize = scrollableEl.value[m.value.clientSize]
|
|
212
|
+
const fullSize = scrollableEl.value[m.value.scrollSize]
|
|
213
|
+
return Math.max(8, Math.min(100, (availableSize * 100) / fullSize))
|
|
90
214
|
})
|
|
91
215
|
|
|
92
|
-
|
|
93
|
-
|
|
216
|
+
const isScrollbarVisible = computed(() => {
|
|
217
|
+
const mode = scrollbarMode.value
|
|
218
|
+
if (!hasOverflow.value || mode === 'off') return false
|
|
219
|
+
if (mode === 'always') return true
|
|
220
|
+
if (mode === 'hover') {
|
|
221
|
+
return hovered.value || isDragging.value || isActiveScroll.value
|
|
222
|
+
}
|
|
223
|
+
if (mode === 'interaction') {
|
|
224
|
+
return isDragging.value || isActiveScroll.value
|
|
225
|
+
}
|
|
226
|
+
return true
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
function normalizeDimension (value) {
|
|
230
|
+
if (value === undefined || value === null || value === '') return undefined
|
|
231
|
+
if (typeof value === 'number') return `${value}px`
|
|
232
|
+
if (typeof value === 'string' && /^-?\d+(\.\d+)?$/.test(value.trim())) return `${value.trim()}px`
|
|
233
|
+
return value
|
|
94
234
|
}
|
|
95
235
|
|
|
96
236
|
const scrollableStyles = computed(() => ({
|
|
97
|
-
|
|
237
|
+
'max-width': normalizeDimension(props.width),
|
|
238
|
+
'max-height': normalizeDimension(props.height)
|
|
98
239
|
}))
|
|
99
240
|
|
|
100
241
|
const thumbStyles = computed(() => {
|
|
101
|
-
|
|
102
|
-
topOrLeftValue =
|
|
242
|
+
const maxPercent = Math.max(0, 100 - thumbSizePercent.value)
|
|
243
|
+
const topOrLeftValue = clamp((scrollValuePercent.value / 100) * maxPercent, 0, maxPercent)
|
|
103
244
|
return {
|
|
104
245
|
[m.value.size]: `${thumbSizePercent.value}%`,
|
|
105
246
|
[m.value.topOrLeft]: `${topOrLeftValue}%`
|
|
106
247
|
}
|
|
107
248
|
})
|
|
108
249
|
|
|
109
|
-
function
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
250
|
+
function clamp (value, min, max) {
|
|
251
|
+
return Math.max(min, Math.min(value, max))
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function isEditableTarget (target) {
|
|
255
|
+
if (!target || target === rootEl.value) return false
|
|
256
|
+
const tagName = target.tagName
|
|
257
|
+
if (!tagName) return false
|
|
258
|
+
if (target.isContentEditable) return true
|
|
259
|
+
return ['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON'].includes(tagName)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function buildPayload (source = 'native') {
|
|
263
|
+
if (!scrollableEl.value) {
|
|
264
|
+
return {
|
|
265
|
+
top: 0,
|
|
266
|
+
left: 0,
|
|
267
|
+
maxTop: 0,
|
|
268
|
+
maxLeft: 0,
|
|
269
|
+
topPercent: 0,
|
|
270
|
+
leftPercent: 0,
|
|
271
|
+
source
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const top = scrollableEl.value.scrollTop
|
|
276
|
+
const left = scrollableEl.value.scrollLeft
|
|
277
|
+
const maxTop = Math.max(0, scrollableEl.value.scrollHeight - scrollableEl.value.clientHeight)
|
|
278
|
+
const maxLeft = Math.max(0, scrollableEl.value.scrollWidth - scrollableEl.value.clientWidth)
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
top,
|
|
282
|
+
left,
|
|
283
|
+
maxTop,
|
|
284
|
+
maxLeft,
|
|
285
|
+
topPercent: maxTop ? (top / maxTop) * 100 : 0,
|
|
286
|
+
leftPercent: maxLeft ? (left / maxLeft) * 100 : 0,
|
|
287
|
+
source
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function emitScrollLifecycle (source = 'native') {
|
|
292
|
+
const payload = buildPayload(source)
|
|
293
|
+
emit('scroll', payload)
|
|
294
|
+
|
|
295
|
+
if (!hasScrollStarted.value) {
|
|
296
|
+
hasScrollStarted.value = true
|
|
297
|
+
emit('scroll-start', payload)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (scrollEndTimeout) window.clearTimeout(scrollEndTimeout)
|
|
301
|
+
scrollEndTimeout = window.setTimeout(() => {
|
|
302
|
+
hasScrollStarted.value = false
|
|
303
|
+
emit('scroll-end', buildPayload('native'))
|
|
304
|
+
}, SCROLL_END_DEBOUNCE_MS)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function onMouseEnter () {
|
|
308
|
+
hovered.value = true
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function onMouseLeave () {
|
|
312
|
+
hovered.value = false
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function syncThumbFromScrollPosition () {
|
|
316
|
+
if (!scrollableEl.value) return
|
|
317
|
+
scrollValuePercent.value = maxScrollValue.value
|
|
318
|
+
? (scrollableEl.value[m.value.scrollTopOrLeft] * 100) / maxScrollValue.value
|
|
319
|
+
: 0
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function setScrollPosition (nextValue, source = 'api') {
|
|
323
|
+
if (!scrollableEl.value) return
|
|
324
|
+
const clampedValue = clamp(nextValue, 0, maxScrollValue.value)
|
|
325
|
+
const previousValue = scrollableEl.value[m.value.scrollTopOrLeft]
|
|
326
|
+
scrollSource.value = source
|
|
327
|
+
|
|
328
|
+
if (Math.abs(previousValue - clampedValue) < 1) {
|
|
329
|
+
syncThumbFromScrollPosition()
|
|
330
|
+
emitScrollLifecycle(source)
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (source === 'wheel' || source === 'keyboard') markActiveScroll()
|
|
335
|
+
skipNextNativeScrollEvent.value = true
|
|
336
|
+
scrollableEl.value[m.value.scrollTopOrLeft] = clampedValue
|
|
337
|
+
syncThumbFromScrollPosition()
|
|
338
|
+
emitScrollLifecycle(source)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function setScrollPercent (percent, source = 'api') {
|
|
342
|
+
const normalizedPercent = clamp(percent, 0, 100)
|
|
343
|
+
setScrollPosition((normalizedPercent / 100) * maxScrollValue.value, source)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function refresh () {
|
|
347
|
+
nextTick(() => {
|
|
348
|
+
metricsVersion.value++
|
|
349
|
+
syncThumbFromScrollPosition()
|
|
350
|
+
// Run a second pass on next frame so show/hide/layout transitions settle.
|
|
351
|
+
requestAnimationFrame(() => {
|
|
352
|
+
metricsVersion.value++
|
|
353
|
+
syncThumbFromScrollPosition()
|
|
354
|
+
})
|
|
355
|
+
})
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function onNativeScroll () {
|
|
359
|
+
if (skipNextNativeScrollEvent.value) {
|
|
360
|
+
skipNextNativeScrollEvent.value = false
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
syncThumbFromScrollPosition()
|
|
365
|
+
emitScrollLifecycle(scrollSource.value)
|
|
366
|
+
scrollSource.value = 'native'
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function getNormalizedWheelDelta (e) {
|
|
370
|
+
const deltaX = e.deltaX || 0
|
|
371
|
+
const deltaY = e.deltaY || 0
|
|
372
|
+
let rawDelta = 0
|
|
113
373
|
|
|
114
|
-
const { top, left, width, height } = trackEl.value.getBoundingClientRect()
|
|
115
374
|
if (isHorizontal.value) {
|
|
116
|
-
|
|
117
|
-
|
|
375
|
+
// Trackpads often emit horizontal intent on either axis depending on gesture/settings.
|
|
376
|
+
rawDelta = Math.abs(deltaX) >= Math.abs(deltaY) ? deltaX : deltaY
|
|
377
|
+
if (!rawDelta && e.shiftKey) rawDelta = deltaY
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
rawDelta = Math.abs(deltaY) >= Math.abs(deltaX) ? deltaY : deltaX
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (!scrollableEl.value) return rawDelta
|
|
384
|
+
if (e.deltaMode === 1) return rawDelta * props.wheelStep
|
|
385
|
+
if (e.deltaMode === 2) return rawDelta * scrollableEl.value[m.value.clientSize]
|
|
386
|
+
return rawDelta
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function markActiveScroll () {
|
|
390
|
+
isActiveScroll.value = true
|
|
391
|
+
if (activeScrollTimeout) window.clearTimeout(activeScrollTimeout)
|
|
392
|
+
activeScrollTimeout = window.setTimeout(() => {
|
|
393
|
+
isActiveScroll.value = false
|
|
394
|
+
activeScrollTimeout = null
|
|
395
|
+
}, ACTIVE_SCROLL_INDICATOR_MS)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function applyScrollPositionFromProps () {
|
|
399
|
+
const pos = props.scrollPosition
|
|
400
|
+
if (pos == null || !scrollableEl.value) return
|
|
401
|
+
|
|
402
|
+
if (typeof pos === 'number') {
|
|
403
|
+
if (Number.isNaN(pos)) return
|
|
404
|
+
setScrollPosition(pos, 'api')
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (typeof pos !== 'object' || pos === null) return
|
|
409
|
+
|
|
410
|
+
const el = scrollableEl.value
|
|
411
|
+
const maxT = Math.max(0, el.scrollHeight - el.clientHeight)
|
|
412
|
+
const maxL = Math.max(0, el.scrollWidth - el.clientWidth)
|
|
413
|
+
|
|
414
|
+
const hasTop = 'top' in pos && pos.top != null && typeof pos.top === 'number' && !Number.isNaN(pos.top)
|
|
415
|
+
const hasLeft = 'left' in pos && pos.left != null && typeof pos.left === 'number' && !Number.isNaN(pos.left)
|
|
416
|
+
|
|
417
|
+
if (hasTop || hasLeft) {
|
|
418
|
+
skipNextNativeScrollEvent.value = true
|
|
419
|
+
if (hasTop) el.scrollTop = clamp(pos.top, 0, maxT)
|
|
420
|
+
if (hasLeft) el.scrollLeft = clamp(pos.left, 0, maxL)
|
|
421
|
+
syncThumbFromScrollPosition()
|
|
422
|
+
emitScrollLifecycle('api')
|
|
423
|
+
return
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const target = isHorizontal.value ? (pos.left ?? pos.top) : (pos.top ?? pos.left)
|
|
427
|
+
if (typeof target === 'number' && !Number.isNaN(target)) setScrollPosition(target, 'api')
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function setDocumentGrabbingCursor (on) {
|
|
431
|
+
if (on) {
|
|
432
|
+
documentCursorBeforeDrag = document.documentElement.style.cursor
|
|
433
|
+
document.documentElement.style.cursor = 'grabbing'
|
|
118
434
|
}
|
|
119
435
|
else {
|
|
120
|
-
|
|
121
|
-
|
|
436
|
+
document.documentElement.style.cursor = documentCursorBeforeDrag
|
|
437
|
+
documentCursorBeforeDrag = ''
|
|
122
438
|
}
|
|
123
|
-
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function bindDragListeners () {
|
|
442
|
+
document.addEventListener('pointermove', onPointerDrag, true)
|
|
443
|
+
document.addEventListener('pointerup', onPointerUp, true)
|
|
444
|
+
document.addEventListener('pointercancel', onPointerUp, true)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function unbindDragListeners () {
|
|
448
|
+
document.removeEventListener('pointermove', onPointerDrag, true)
|
|
449
|
+
document.removeEventListener('pointerup', onPointerUp, true)
|
|
450
|
+
document.removeEventListener('pointercancel', onPointerUp, true)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function onWheel (e) {
|
|
454
|
+
if (props.disabled || !hasOverflow.value) return
|
|
455
|
+
|
|
456
|
+
// macOS: ⌘+scroll jumps to start/end of the scrollable (like native large-document jumps).
|
|
457
|
+
if (e.metaKey) {
|
|
458
|
+
const jumpDelta = getNormalizedWheelDelta(e)
|
|
459
|
+
if (!jumpDelta) return
|
|
460
|
+
e.preventDefault()
|
|
461
|
+
if (jumpDelta < 0) setScrollPosition(0, 'wheel')
|
|
462
|
+
else setScrollPosition(maxScrollValue.value, 'wheel')
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const wheelDelta = getNormalizedWheelDelta(e)
|
|
467
|
+
if (!wheelDelta) return
|
|
124
468
|
|
|
125
|
-
|
|
126
|
-
|
|
469
|
+
const currentValue = scrollableEl.value[m.value.scrollTopOrLeft]
|
|
470
|
+
const nextValue = clamp(currentValue + wheelDelta, 0, maxScrollValue.value)
|
|
471
|
+
const isAtStart = currentValue <= 0 && wheelDelta < 0
|
|
472
|
+
const isAtEnd = currentValue >= maxScrollValue.value && wheelDelta > 0
|
|
473
|
+
if (isAtStart || isAtEnd) return
|
|
127
474
|
|
|
128
|
-
|
|
129
|
-
|
|
475
|
+
e.preventDefault()
|
|
476
|
+
setScrollPosition(nextValue, 'wheel')
|
|
130
477
|
}
|
|
131
478
|
|
|
132
|
-
function
|
|
133
|
-
|
|
134
|
-
|
|
479
|
+
function onKeydown (e) {
|
|
480
|
+
if (props.disabled || !hasOverflow.value) return
|
|
481
|
+
if (isEditableTarget(e.target)) return
|
|
482
|
+
|
|
483
|
+
const current = scrollableEl.value[m.value.scrollTopOrLeft]
|
|
484
|
+
const step = props.wheelStep
|
|
485
|
+
const page = scrollableEl.value[m.value.clientSize]
|
|
486
|
+
let nextValue = null
|
|
487
|
+
|
|
488
|
+
if (e.metaKey) {
|
|
489
|
+
if (e.key === 'ArrowUp' && !isHorizontal.value) {
|
|
490
|
+
e.preventDefault()
|
|
491
|
+
setScrollPosition(0, 'keyboard')
|
|
492
|
+
return
|
|
493
|
+
}
|
|
494
|
+
if (e.key === 'ArrowDown' && !isHorizontal.value) {
|
|
495
|
+
e.preventDefault()
|
|
496
|
+
setScrollPosition(maxScrollValue.value, 'keyboard')
|
|
497
|
+
return
|
|
498
|
+
}
|
|
499
|
+
if (e.key === 'ArrowLeft' && isHorizontal.value) {
|
|
500
|
+
e.preventDefault()
|
|
501
|
+
setScrollPosition(0, 'keyboard')
|
|
502
|
+
return
|
|
503
|
+
}
|
|
504
|
+
if (e.key === 'ArrowRight' && isHorizontal.value) {
|
|
505
|
+
e.preventDefault()
|
|
506
|
+
setScrollPosition(maxScrollValue.value, 'keyboard')
|
|
507
|
+
return
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
switch (e.key) {
|
|
512
|
+
case 'ArrowUp':
|
|
513
|
+
if (!isHorizontal.value) nextValue = current - step
|
|
514
|
+
break
|
|
515
|
+
case 'ArrowDown':
|
|
516
|
+
if (!isHorizontal.value) nextValue = current + step
|
|
517
|
+
break
|
|
518
|
+
case 'ArrowLeft':
|
|
519
|
+
if (isHorizontal.value) nextValue = current - step
|
|
520
|
+
break
|
|
521
|
+
case 'ArrowRight':
|
|
522
|
+
if (isHorizontal.value) nextValue = current + step
|
|
523
|
+
break
|
|
524
|
+
case 'PageUp':
|
|
525
|
+
nextValue = current - page
|
|
526
|
+
break
|
|
527
|
+
case 'PageDown':
|
|
528
|
+
nextValue = current + page
|
|
529
|
+
break
|
|
530
|
+
case 'Home':
|
|
531
|
+
nextValue = 0
|
|
532
|
+
break
|
|
533
|
+
case 'End':
|
|
534
|
+
nextValue = maxScrollValue.value
|
|
535
|
+
break
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (nextValue === null) return
|
|
539
|
+
e.preventDefault()
|
|
540
|
+
setScrollPosition(nextValue, 'keyboard')
|
|
135
541
|
}
|
|
136
542
|
|
|
137
|
-
function
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
543
|
+
function startDrag (clientPosition, offset, pointerEvent) {
|
|
544
|
+
if (!trackEl.value) return
|
|
545
|
+
const rect = trackEl.value.getBoundingClientRect()
|
|
546
|
+
dragState.value.trackStart = rect[m.value.topOrLeft]
|
|
547
|
+
dragState.value.trackSize = rect[m.value.size]
|
|
548
|
+
dragState.value.offset = Number.isFinite(offset) ? offset : 0
|
|
549
|
+
isDragging.value = true
|
|
550
|
+
const pid = pointerEvent?.pointerId
|
|
551
|
+
if (pid != null && rootEl.value?.setPointerCapture) {
|
|
552
|
+
try {
|
|
553
|
+
rootEl.value.setPointerCapture(pid)
|
|
554
|
+
dragPointerId.value = pid
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
dragPointerId.value = null
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
setDocumentGrabbingCursor(true)
|
|
561
|
+
updateDragPosition(clientPosition)
|
|
562
|
+
bindDragListeners()
|
|
141
563
|
}
|
|
142
564
|
|
|
143
|
-
function
|
|
144
|
-
|
|
565
|
+
function updateDragPosition (clientPosition) {
|
|
566
|
+
if (!isDragging.value || !dragState.value.trackSize) return
|
|
567
|
+
|
|
568
|
+
const thumbSizePx = dragState.value.trackSize * (thumbSizePercent.value / 100)
|
|
569
|
+
const availableSize = Math.max(1, dragState.value.trackSize - thumbSizePx)
|
|
570
|
+
const thumbPosition = clamp(clientPosition - dragState.value.trackStart - dragState.value.offset, 0, availableSize)
|
|
571
|
+
const scrollPercent = (thumbPosition / availableSize) * 100
|
|
572
|
+
setScrollPercent(scrollPercent, 'drag')
|
|
145
573
|
}
|
|
146
574
|
|
|
147
|
-
function
|
|
148
|
-
|
|
575
|
+
function stopDrag () {
|
|
576
|
+
if (dragPointerId.value != null && rootEl.value?.releasePointerCapture) {
|
|
577
|
+
try {
|
|
578
|
+
rootEl.value.releasePointerCapture(dragPointerId.value)
|
|
579
|
+
}
|
|
580
|
+
catch { /* pointer may already be released */ }
|
|
581
|
+
dragPointerId.value = null
|
|
582
|
+
}
|
|
583
|
+
setDocumentGrabbingCursor(false)
|
|
584
|
+
isDragging.value = false
|
|
585
|
+
unbindDragListeners()
|
|
149
586
|
}
|
|
150
587
|
|
|
151
|
-
function
|
|
152
|
-
if (
|
|
588
|
+
function onTrackPointerDown (e) {
|
|
589
|
+
if (props.disabled) return
|
|
590
|
+
if (e.button !== 0 && e.pointerType !== 'touch') return
|
|
591
|
+
stopDrag()
|
|
592
|
+
const thumbRect = thumbEl.value?.getBoundingClientRect()
|
|
593
|
+
const offset = thumbRect ? thumbRect[m.value.size] / 2 : 0
|
|
594
|
+
startDrag(e[m.value.clientXorY], offset, e)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function onThumbPointerDown (e) {
|
|
598
|
+
if (props.disabled) return
|
|
599
|
+
if (e.button !== 0 && e.pointerType !== 'touch') return
|
|
600
|
+
stopDrag()
|
|
601
|
+
const thumbRect = thumbEl.value?.getBoundingClientRect()
|
|
602
|
+
const offset = thumbRect ? e[m.value.clientXorY] - thumbRect[m.value.topOrLeft] : 0
|
|
603
|
+
startDrag(e[m.value.clientXorY], offset, e)
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function onPointerDrag (e) {
|
|
607
|
+
if (!isDragging.value) return
|
|
608
|
+
if (e.cancelable) e.preventDefault()
|
|
609
|
+
updateDragPosition(e[m.value.clientXorY])
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function onPointerUp () {
|
|
613
|
+
if (!isDragging.value) return
|
|
614
|
+
stopDrag()
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function scroll (value = scrollValuePercent.value) {
|
|
618
|
+
if (typeof value === 'number') {
|
|
619
|
+
setScrollPercent(value, 'api')
|
|
620
|
+
return
|
|
621
|
+
}
|
|
153
622
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
623
|
+
if (value && typeof value === 'object') {
|
|
624
|
+
scrollTo(value)
|
|
625
|
+
}
|
|
626
|
+
}
|
|
157
627
|
|
|
158
|
-
|
|
628
|
+
function scrollTo ({ top, left, behavior = 'auto' } = {}) {
|
|
629
|
+
const targetValue = isHorizontal.value ? (left ?? top) : (top ?? left)
|
|
630
|
+
if (typeof targetValue !== 'number') return
|
|
631
|
+
void behavior
|
|
632
|
+
setScrollPosition(targetValue, 'api')
|
|
633
|
+
}
|
|
159
634
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
635
|
+
function scrollBy ({ top = 0, left = 0, behavior = 'auto' } = {}) {
|
|
636
|
+
if (!scrollableEl.value) return
|
|
637
|
+
const delta = isHorizontal.value ? left : top
|
|
638
|
+
void behavior
|
|
639
|
+
setScrollPosition(scrollableEl.value[m.value.scrollTopOrLeft] + delta, 'api')
|
|
163
640
|
}
|
|
164
641
|
|
|
165
|
-
function
|
|
166
|
-
|
|
167
|
-
const topOrLeft = isHorizontal.value ? left : top
|
|
168
|
-
const size = isHorizontal.value ? width : height
|
|
169
|
-
scrollValuePercent.value = Math.max(0, Math.min(((cursorPositionXorY - topOrLeft) / size) * 100, 100))
|
|
642
|
+
function scrollToStart () {
|
|
643
|
+
setScrollPosition(0, 'api')
|
|
170
644
|
}
|
|
171
645
|
|
|
172
|
-
function
|
|
173
|
-
|
|
174
|
-
updateThumbPosition()
|
|
646
|
+
function scrollToEnd () {
|
|
647
|
+
setScrollPosition(maxScrollValue.value, 'api')
|
|
175
648
|
}
|
|
176
649
|
|
|
177
|
-
function
|
|
178
|
-
|
|
650
|
+
function focus () {
|
|
651
|
+
rootEl.value?.focus()
|
|
179
652
|
}
|
|
180
653
|
|
|
181
654
|
onMounted(() => {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
655
|
+
refresh()
|
|
656
|
+
window.addEventListener('resize', refresh)
|
|
657
|
+
|
|
658
|
+
if (typeof ResizeObserver !== 'undefined' && scrollableEl.value) {
|
|
659
|
+
resizeObserver = new ResizeObserver(() => refresh())
|
|
660
|
+
resizeObserver.observe(scrollableEl.value)
|
|
661
|
+
}
|
|
186
662
|
|
|
187
|
-
|
|
663
|
+
nextTick(() => {
|
|
664
|
+
requestAnimationFrame(() => {
|
|
665
|
+
applyScrollPositionFromProps()
|
|
666
|
+
syncThumbFromScrollPosition()
|
|
667
|
+
})
|
|
668
|
+
})
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
watch(() => [props.horizontal, props.width, props.height], () => refresh())
|
|
672
|
+
|
|
673
|
+
watch(
|
|
674
|
+
() => props.scrollPosition,
|
|
675
|
+
() => {
|
|
676
|
+
if (props.scrollPosition == null) return
|
|
677
|
+
nextTick(() => {
|
|
678
|
+
applyScrollPositionFromProps()
|
|
679
|
+
syncThumbFromScrollPosition()
|
|
680
|
+
})
|
|
681
|
+
},
|
|
682
|
+
{ deep: true }
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
watch(isHorizontal, () => {
|
|
686
|
+
if (props.scrollPosition == null) return
|
|
687
|
+
nextTick(() => {
|
|
688
|
+
applyScrollPositionFromProps()
|
|
689
|
+
syncThumbFromScrollPosition()
|
|
690
|
+
})
|
|
188
691
|
})
|
|
189
692
|
|
|
190
|
-
// Clean up event listener.
|
|
191
693
|
onBeforeUnmount(() => {
|
|
192
|
-
window.removeEventListener('resize',
|
|
694
|
+
window.removeEventListener('resize', refresh)
|
|
695
|
+
stopDrag()
|
|
696
|
+
if (scrollEndTimeout) window.clearTimeout(scrollEndTimeout)
|
|
697
|
+
if (activeScrollTimeout) window.clearTimeout(activeScrollTimeout)
|
|
698
|
+
if (resizeObserver && scrollableEl.value) resizeObserver.unobserve(scrollableEl.value)
|
|
699
|
+
resizeObserver = null
|
|
193
700
|
})
|
|
194
701
|
|
|
195
|
-
defineExpose({
|
|
702
|
+
defineExpose({
|
|
703
|
+
scroll,
|
|
704
|
+
scrollTo,
|
|
705
|
+
scrollBy,
|
|
706
|
+
scrollToStart,
|
|
707
|
+
scrollToEnd,
|
|
708
|
+
focus,
|
|
709
|
+
refresh
|
|
710
|
+
})
|
|
196
711
|
</script>
|
|
197
712
|
|
|
198
713
|
<style lang="scss">
|
|
199
714
|
.w-scrollable {
|
|
200
715
|
display: flex;
|
|
201
716
|
border-radius: inherit;
|
|
717
|
+
outline: none;
|
|
718
|
+
|
|
719
|
+
&:focus-visible .w-scrollable__scrollbar-thumb {
|
|
720
|
+
box-shadow: 0 0 0 2px color-mix(in srgb, var(--w-primary) 35%, transparent);
|
|
721
|
+
}
|
|
202
722
|
|
|
203
723
|
&__content {
|
|
204
724
|
padding: 0;
|
|
205
725
|
flex: 1 1 auto;
|
|
206
|
-
|
|
726
|
+
min-width: 0;
|
|
727
|
+
min-height: 0;
|
|
728
|
+
overflow: auto;
|
|
729
|
+
scrollbar-width: none;
|
|
730
|
+
-ms-overflow-style: none;
|
|
731
|
+
|
|
732
|
+
&::-webkit-scrollbar {
|
|
733
|
+
display: none;
|
|
734
|
+
width: 0;
|
|
735
|
+
height: 0;
|
|
736
|
+
}
|
|
207
737
|
}
|
|
208
738
|
|
|
209
739
|
&__scrollbar {
|
|
210
740
|
position: relative;
|
|
211
741
|
flex: 0 0 auto;
|
|
212
|
-
background:
|
|
742
|
+
background: color-mix(in srgb, var(--w-contrast-bg-color) 8%, var(--w-base-bg-color));
|
|
213
743
|
user-select: none;
|
|
744
|
+
cursor: pointer;
|
|
745
|
+
touch-action: none;
|
|
746
|
+
opacity: 1;
|
|
747
|
+
transition: opacity $fast-transition-duration ease;
|
|
214
748
|
|
|
215
749
|
&--horizontal {
|
|
216
|
-
|
|
750
|
+
width: 100%;
|
|
217
751
|
border-bottom-left-radius: inherit;
|
|
218
752
|
border-bottom-right-radius: inherit;
|
|
219
753
|
height: $scrollbar-size;
|
|
@@ -224,17 +758,38 @@ defineExpose({ scroll })
|
|
|
224
758
|
border-bottom-right-radius: inherit;
|
|
225
759
|
width: $scrollbar-size;
|
|
226
760
|
}
|
|
761
|
+
|
|
762
|
+
&--hidden {
|
|
763
|
+
opacity: 0;
|
|
764
|
+
pointer-events: none;
|
|
765
|
+
}
|
|
227
766
|
}
|
|
228
767
|
|
|
229
768
|
&__scrollbar-thumb {
|
|
230
769
|
position: absolute;
|
|
231
|
-
|
|
770
|
+
color: color-mix(in srgb, var(--w-contrast-bg-color) 8%, var(--w-base-bg-color));
|
|
771
|
+
background: currentColor;
|
|
772
|
+
|
|
232
773
|
border-radius: $border-radius;
|
|
233
774
|
z-index: 1;
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
775
|
+
transition: box-shadow 0.15s ease;
|
|
776
|
+
cursor: grab;
|
|
777
|
+
touch-action: none;
|
|
778
|
+
|
|
779
|
+
&:before {
|
|
780
|
+
content: '';
|
|
781
|
+
position: absolute;
|
|
782
|
+
inset: 0;
|
|
783
|
+
background: var(--w-base-bg-color);
|
|
784
|
+
border-radius: inherit;
|
|
785
|
+
z-index: -1;
|
|
786
|
+
opacity: 0;
|
|
787
|
+
transition: opacity 0.15s ease;
|
|
788
|
+
}
|
|
237
789
|
}
|
|
790
|
+
&--dragging &__scrollbar-thumb:before,
|
|
791
|
+
&__scrollbar-thumb:hover:before {opacity: 0.2;}
|
|
792
|
+
|
|
238
793
|
&--horizontal &__scrollbar-thumb {
|
|
239
794
|
height: 6px;
|
|
240
795
|
left: 0;
|
|
@@ -249,5 +804,23 @@ defineExpose({ scroll })
|
|
|
249
804
|
margin-left: 1px;
|
|
250
805
|
margin-right: 1px;
|
|
251
806
|
}
|
|
807
|
+
|
|
808
|
+
&--disabled {
|
|
809
|
+
pointer-events: none;
|
|
810
|
+
opacity: 0.65;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
&--horizontal {flex-direction: column;}
|
|
814
|
+
|
|
815
|
+
&--dragging {
|
|
816
|
+
cursor: grabbing;
|
|
817
|
+
user-select: none;
|
|
818
|
+
|
|
819
|
+
.w-scrollable__content,
|
|
820
|
+
.w-scrollable__scrollbar,
|
|
821
|
+
.w-scrollable__scrollbar-thumb {
|
|
822
|
+
cursor: grabbing;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
252
825
|
}
|
|
253
826
|
</style>
|