wave-ui 3.27.1 → 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.
Files changed (43) hide show
  1. package/dist/types/types/$waveui.d.ts +6 -0
  2. package/dist/types/types/components/WAccordion.d.ts +7 -0
  3. package/dist/types/types/components/WBreadcrumbs.d.ts +7 -0
  4. package/dist/types/types/components/WButton.d.ts +7 -0
  5. package/dist/types/types/components/WList.d.ts +7 -0
  6. package/dist/types/types/components/WScrollable.d.ts +143 -0
  7. package/dist/types/types/components/WScrollable.js +2 -0
  8. package/dist/types/types/components/WTabs.d.ts +7 -0
  9. package/dist/types/types/components/WTag.d.ts +7 -0
  10. package/dist/types/types/components/index.d.ts +1 -0
  11. package/dist/wave-ui.cjs.js +3 -3
  12. package/dist/wave-ui.css +1 -1
  13. package/dist/wave-ui.esm.js +1440 -939
  14. package/dist/wave-ui.umd.js +3 -3
  15. package/package.json +6 -6
  16. package/src/wave-ui/components/w-accordion/index.vue +5 -1
  17. package/src/wave-ui/components/w-accordion/item.vue +42 -12
  18. package/src/wave-ui/components/w-breadcrumbs.vue +13 -2
  19. package/src/wave-ui/components/w-button/button.vue +15 -1
  20. package/src/wave-ui/components/w-button/index.vue +2 -1
  21. package/src/wave-ui/components/w-checkbox.vue +5 -1
  22. package/src/wave-ui/components/w-checkboxes.vue +5 -1
  23. package/src/wave-ui/components/w-input.vue +5 -1
  24. package/src/wave-ui/components/w-list.vue +12 -0
  25. package/src/wave-ui/components/w-radio.vue +6 -1
  26. package/src/wave-ui/components/w-radios.vue +5 -1
  27. package/src/wave-ui/components/w-rating.vue +5 -1
  28. package/src/wave-ui/components/w-scrollable.vue +667 -94
  29. package/src/wave-ui/components/w-select.vue +11 -7
  30. package/src/wave-ui/components/w-slider.vue +5 -1
  31. package/src/wave-ui/components/w-switch.vue +5 -1
  32. package/src/wave-ui/components/w-tabs/index.vue +10 -0
  33. package/src/wave-ui/components/w-tag.vue +14 -0
  34. package/src/wave-ui/components/w-textarea.vue +5 -1
  35. package/src/wave-ui/core.js +2 -0
  36. package/src/wave-ui/mixins/form-elements.js +5 -8
  37. package/src/wave-ui/mixins/ripple.js +39 -0
  38. package/src/wave-ui/scss/_ripple.scss +37 -0
  39. package/src/wave-ui/scss/index.scss +1 -0
  40. package/src/wave-ui/scss/variables/_variables.scss +0 -2
  41. package/src/wave-ui/utils/config.js +2 -0
  42. package/src/wave-ui/utils/ripple.js +71 -0
  43. 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
- @mousewheel="onMouseWheel"
10
+ @wheel="onWheel"
11
+ @keydown="onKeydown"
6
12
  :class="scrollableClasses"
7
- v-bind="$attrs"
8
13
  :style="scrollableStyles")
9
- .w-scrollable__content(ref="scrollableEl")
14
+ .w-scrollable__content(
15
+ :id="contentId"
16
+ ref="scrollableEl"
17
+ :class="contentClasses"
18
+ @scroll="onNativeScroll")
10
19
  slot
11
- .w-scrollable__scrollbar(ref="trackEl" @mousedown="onTrackMouseDown" :class="scrollbarClasses")
12
- .w-scrollable__scrollbar-thumb(ref="thumbEl" :style="thumbStyles")
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
- // Refs used in template and functions.
55
- const mounted = ref(false)
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 scrollableState = ref({
60
- top: null,
61
- left: null,
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 dragging = ref(false)
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 isHorizontal = computed(() => {
68
- if (!mounted.value) return false
69
- console.log('💂‍♂️', scrollableEl.value?.scrollWidth, scrollableEl.value?.offsetWidth)
70
- return (props.width && !props.height) || (scrollableEl.value?.scrollWidth > scrollableEl.value?.offsetWidth)
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 m = computed(() => domProps[isHorizontal.value ? 'h' : 'v'])
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 refreshThumb = ref(0)
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
- refreshThumb.value // Dependency to force re-evaluation.
87
- if (!mounted.value) return 0
88
- const size = props[m.value.size] ?? scrollableEl.value?.[m.value.offsetSize]
89
- return (size * 100 / scrollableEl.value?.[m.value.scrollSize]) || 0
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
- function forceRefreshThumb () {
93
- refreshThumb.value++
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
- [m.value.maxSize]: props[m.value.size] ? `${props[m.value.size]}px` : undefined
237
+ 'max-width': normalizeDimension(props.width),
238
+ 'max-height': normalizeDimension(props.height)
98
239
  }))
99
240
 
100
241
  const thumbStyles = computed(() => {
101
- let topOrLeftValue = scrollValuePercent.value
102
- topOrLeftValue = Math.max(0, Math.min(topOrLeftValue, 100 - thumbSizePercent.value))
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 onTrackMouseDown (e) {
110
- if (props.isDisabled || props.isReadonly) return
111
- // On touch screen don't listen for both touchstart & mousedown.
112
- if ('ontouchstart' in window && e.type === 'mousedown') return
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
- trackEl.value.width = width
117
- trackEl.value.left = left
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
- trackEl.value.height = height
121
- trackEl.value.top = top
436
+ document.documentElement.style.cursor = documentCursorBeforeDrag
437
+ documentCursorBeforeDrag = ''
122
438
  }
123
- dragging.value = true
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
- computeScroll(e.type === 'touchstart' ? e.touches[0][m.value.clientXorY] : e[m.value.clientXorY])
126
- scroll()
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
- document.addEventListener(e.type === 'touchstart' ? 'touchmove' : 'mousemove', onDrag)
129
- document.addEventListener(e.type === 'touchstart' ? 'touchend' : 'mouseup', onMouseUp, { once: true })
475
+ e.preventDefault()
476
+ setScrollPosition(nextValue, 'wheel')
130
477
  }
131
478
 
132
- function onDrag (e) {
133
- computeScroll((e.type === 'touchmove' ? e.touches[0][m.value.clientXorY] : e[m.value.clientXorY]))
134
- scroll()
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 onMouseUp (e) {
138
- dragging.value = false
139
- document.removeEventListener(e.type === 'touchend' ? 'touchmove' : 'mousemove', onDrag)
140
- if (thumbEl.value) thumbEl.value.focus()
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 onMouseEnter () {
144
- scrollableState.value.hovered = true
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 onMouseLeave () {
148
- scrollableState.value.hovered = false
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 onMouseWheel (e) {
152
- if (!scrollableState.value.hovered) return // Only scroll a w-scrollable element that is being hovered.
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
- // When scrolling beyond limits, release the mousewheel and scroll the parent.
155
- if (scrollValuePercent.value <= 0 && e[m.value.deltaXorY] < 0) return
156
- if (scrollValuePercent.value >= 100 - thumbSizePercent.value && e[m.value.deltaXorY] > 0) return
623
+ if (value && typeof value === 'object') {
624
+ scrollTo(value)
625
+ }
626
+ }
157
627
 
158
- e.preventDefault() // Hold the scroll in the hovered w-scrollable element.
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
- scrollValuePercent.value += e[m.value.deltaXorY] * 0.05
161
- scrollValuePercent.value = Math.max(0, Math.min(scrollValuePercent.value, 100))
162
- scroll()
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 computeScroll (cursorPositionXorY) {
166
- const { top, left, width, height } = scrollableEl.value.getBoundingClientRect()
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 scroll () {
173
- scrollableEl.value[m.value.scrollTopOrLeft] = scrollValuePercent.value * scrollableEl.value?.[m.value.scrollSize] / 100
174
- updateThumbPosition()
646
+ function scrollToEnd () {
647
+ setScrollPosition(maxScrollValue.value, 'api')
175
648
  }
176
649
 
177
- function updateThumbPosition () {
178
- thumbEl.value.style[m.value.topOrLeft] = scrollValuePercent.value
650
+ function focus () {
651
+ rootEl.value?.focus()
179
652
  }
180
653
 
181
654
  onMounted(() => {
182
- mounted.value = true
183
- const { top, left } = scrollableEl.value.getBoundingClientRect()
184
- scrollableState.value.top = top
185
- scrollableState.value.left = left
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
- window.addEventListener('resize', forceRefreshThumb)
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', forceRefreshThumb)
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({ scroll })
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
- overflow: hidden;
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: $scrollbar-bg-color;
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
- inset: auto 0 0;
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
- background: $scrollbar-thumb-color;
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
- will-change: top left;
235
-
236
- &:hover {background: $scrollbar-thumb-color;}
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>