fix(primitives): eslint/tsconfig migration, asChild refactor, type fixes
- Migrate to eslint flat config + composite tsconfig. - Complete the asChild→as="template" refactor (remove asChild prop + :as-child bindings across components, matching Primitive's slot model). - Fix test type errors and source type-safety (useGraceArea hull/point math, FocusScope/util ref typing). Note: ~53 vue-tsc errors remain (HTML attr/event passthrough typing on transparent wrapper components + a couple of duplicate-export naming collisions) — not gated by CI (build/lint/test green); pending a component-attribute-typing design decision.
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
export type ScrollAreaCornerProps = PrimitiveProps;
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onScopeDispose, ref, watch } from 'vue';
|
||||
import { Primitive } from '../primitive';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { useScrollAreaRootContext } from './context';
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
defineProps<ScrollAreaCornerProps>();
|
||||
|
||||
const ctx = useScrollAreaRootContext();
|
||||
const { forwardRef } = useForwardExpose();
|
||||
|
||||
const width = ref(0);
|
||||
const height = ref(0);
|
||||
|
||||
const hasSize = computed(() => width.value > 0 && height.value > 0);
|
||||
const hasBoth = computed(() => ctx.scrollbarXEnabled.value && ctx.scrollbarYEnabled.value);
|
||||
|
||||
function measure() {
|
||||
const x = ctx.scrollbarX.value;
|
||||
const y = ctx.scrollbarY.value;
|
||||
width.value = y ? y.offsetWidth : 0;
|
||||
height.value = x ? x.offsetHeight : 0;
|
||||
ctx.onCornerWidthChange(width.value);
|
||||
ctx.onCornerHeightChange(height.value);
|
||||
}
|
||||
|
||||
let xObs: ResizeObserver | null = null;
|
||||
let yObs: ResizeObserver | null = null;
|
||||
|
||||
function attach() {
|
||||
xObs?.disconnect();
|
||||
yObs?.disconnect();
|
||||
const x = ctx.scrollbarX.value;
|
||||
const y = ctx.scrollbarY.value;
|
||||
if (x) {
|
||||
xObs = new ResizeObserver(measure);
|
||||
xObs.observe(x);
|
||||
}
|
||||
if (y) {
|
||||
yObs = new ResizeObserver(measure);
|
||||
yObs.observe(y);
|
||||
}
|
||||
measure();
|
||||
}
|
||||
|
||||
onMounted(attach);
|
||||
watch([() => ctx.scrollbarX.value, () => ctx.scrollbarY.value], attach);
|
||||
onScopeDispose(() => {
|
||||
xObs?.disconnect();
|
||||
yObs?.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
v-if="hasBoth && hasSize"
|
||||
:ref="forwardRef"
|
||||
:as="as ?? 'div'"
|
||||
:style="{
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
}"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
import type { ScrollAreaType } from './types';
|
||||
|
||||
export interface ScrollAreaRootProps extends PrimitiveProps {
|
||||
/**
|
||||
* Visibility behaviour for scrollbars.
|
||||
* - `auto`: visible whenever content overflows.
|
||||
* - `always`: always visible.
|
||||
* - `scroll`: visible while the user is scrolling, then hides after `scrollHideDelay`.
|
||||
* - `hover`: visible while the pointer is over the root, then hides after `scrollHideDelay`.
|
||||
* @default 'hover'
|
||||
*/
|
||||
type?: ScrollAreaType;
|
||||
/** Reading direction. Inherits from `ConfigProvider` when omitted. */
|
||||
dir?: 'ltr' | 'rtl';
|
||||
/**
|
||||
* For `type='scroll'` and `type='hover'`, the time in ms before scrollbars hide
|
||||
* after the user stops interacting.
|
||||
* @default 600
|
||||
*/
|
||||
scrollHideDelay?: number;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, toRef } from 'vue';
|
||||
import { Primitive } from '../primitive';
|
||||
import { provideScrollAreaRootContext } from './context';
|
||||
import { useConfig, useId } from '../config-provider';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(defineProps<ScrollAreaRootProps>(), {
|
||||
type: 'hover',
|
||||
scrollHideDelay: 600,
|
||||
});
|
||||
|
||||
const config = useConfig();
|
||||
|
||||
const viewport = ref<HTMLElement | null>(null);
|
||||
const content = ref<HTMLElement | null>(null);
|
||||
const scrollbarX = ref<HTMLElement | null>(null);
|
||||
const scrollbarY = ref<HTMLElement | null>(null);
|
||||
const scrollbarXEnabled = ref(false);
|
||||
const scrollbarYEnabled = ref(false);
|
||||
const cornerWidth = ref(0);
|
||||
const cornerHeight = ref(0);
|
||||
const viewportId = useId(undefined, 'scroll-area-viewport');
|
||||
|
||||
const dir = computed(() => props.dir ?? config.dir.value);
|
||||
|
||||
const { forwardRef, currentElement: scrollArea } = useForwardExpose();
|
||||
|
||||
provideScrollAreaRootContext({
|
||||
type: toRef(props, 'type'),
|
||||
dir,
|
||||
scrollHideDelay: toRef(props, 'scrollHideDelay'),
|
||||
scrollArea,
|
||||
viewport,
|
||||
content,
|
||||
scrollbarX,
|
||||
scrollbarY,
|
||||
scrollbarXEnabled,
|
||||
scrollbarYEnabled,
|
||||
cornerWidth,
|
||||
cornerHeight,
|
||||
viewportId,
|
||||
onScrollbarXEnabledChange: (v) => { scrollbarXEnabled.value = v; },
|
||||
onScrollbarYEnabledChange: (v) => { scrollbarYEnabled.value = v; },
|
||||
onCornerWidthChange: (n) => { cornerWidth.value = n; },
|
||||
onCornerHeightChange: (n) => { cornerHeight.value = n; },
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
viewport,
|
||||
scrollTop: () => viewport.value?.scrollTo({ top: 0 }),
|
||||
scrollTopLeft: () => viewport.value?.scrollTo({ top: 0, left: 0 }),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
:dir="dir"
|
||||
:style="{
|
||||
position: 'relative',
|
||||
'--scroll-area-corner-width': `${cornerWidth}px`,
|
||||
'--scroll-area-corner-height': `${cornerHeight}px`,
|
||||
}"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
export interface ScrollAreaScrollbarProps extends PrimitiveProps {
|
||||
/** @default 'vertical' */
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
/** Keep mounted regardless of visibility state. */
|
||||
forceMount?: boolean;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, watch } from 'vue';
|
||||
import ScrollAreaScrollbarAuto from './ScrollAreaScrollbarAuto.vue';
|
||||
import ScrollAreaScrollbarHover from './ScrollAreaScrollbarHover.vue';
|
||||
import ScrollAreaScrollbarScroll from './ScrollAreaScrollbarScroll.vue';
|
||||
import ScrollAreaScrollbarVisible from './ScrollAreaScrollbarVisible.vue';
|
||||
import { useScrollAreaRootContext } from './context';
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(defineProps<ScrollAreaScrollbarProps>(), {
|
||||
orientation: 'vertical',
|
||||
});
|
||||
|
||||
const ctx = useScrollAreaRootContext();
|
||||
const isHorizontal = computed(() => props.orientation === 'horizontal');
|
||||
|
||||
watch(isHorizontal, (h) => {
|
||||
if (h)
|
||||
ctx.onScrollbarXEnabledChange(true);
|
||||
else
|
||||
ctx.onScrollbarYEnabledChange(true);
|
||||
}, { immediate: true });
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (isHorizontal.value)
|
||||
ctx.onScrollbarXEnabledChange(false);
|
||||
else
|
||||
ctx.onScrollbarYEnabledChange(false);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollAreaScrollbarHover
|
||||
v-if="ctx.type.value === 'hover'"
|
||||
v-bind="$attrs"
|
||||
:orientation="orientation"
|
||||
:force-mount="forceMount"
|
||||
:as="as"
|
||||
>
|
||||
<slot />
|
||||
</ScrollAreaScrollbarHover>
|
||||
<ScrollAreaScrollbarScroll
|
||||
v-else-if="ctx.type.value === 'scroll'"
|
||||
v-bind="$attrs"
|
||||
:orientation="orientation"
|
||||
:force-mount="forceMount"
|
||||
:as="as"
|
||||
>
|
||||
<slot />
|
||||
</ScrollAreaScrollbarScroll>
|
||||
<ScrollAreaScrollbarAuto
|
||||
v-else-if="ctx.type.value === 'auto'"
|
||||
v-bind="$attrs"
|
||||
:orientation="orientation"
|
||||
:force-mount="forceMount"
|
||||
:as="as"
|
||||
>
|
||||
<slot />
|
||||
</ScrollAreaScrollbarAuto>
|
||||
<ScrollAreaScrollbarVisible
|
||||
v-else
|
||||
v-bind="$attrs"
|
||||
:orientation="orientation"
|
||||
:as="as"
|
||||
data-state="visible"
|
||||
>
|
||||
<slot />
|
||||
</ScrollAreaScrollbarVisible>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
export interface ScrollAreaScrollbarAutoProps extends PrimitiveProps {
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
forceMount?: boolean;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onScopeDispose, ref, watch } from 'vue';
|
||||
import { Presence } from '../presence';
|
||||
import ScrollAreaScrollbarVisible from './ScrollAreaScrollbarVisible.vue';
|
||||
import { debounceCallback } from './utils';
|
||||
import { useScrollAreaRootContext } from './context';
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(defineProps<ScrollAreaScrollbarAutoProps>(), {
|
||||
orientation: 'vertical',
|
||||
});
|
||||
|
||||
const ctx = useScrollAreaRootContext();
|
||||
const visible = ref(false);
|
||||
const isHorizontal = computed(() => props.orientation === 'horizontal');
|
||||
|
||||
const handleResize = debounceCallback(() => {
|
||||
const vp = ctx.viewport.value;
|
||||
if (!vp)
|
||||
return;
|
||||
const overflowX = vp.offsetWidth < vp.scrollWidth;
|
||||
const overflowY = vp.offsetHeight < vp.scrollHeight;
|
||||
visible.value = isHorizontal.value ? overflowX : overflowY;
|
||||
}, 10);
|
||||
|
||||
let viewportObserver: ResizeObserver | null = null;
|
||||
let contentObserver: ResizeObserver | null = null;
|
||||
|
||||
function attach() {
|
||||
detach();
|
||||
const vp = ctx.viewport.value;
|
||||
const co = ctx.content.value;
|
||||
if (vp) {
|
||||
viewportObserver = new ResizeObserver(handleResize);
|
||||
viewportObserver.observe(vp);
|
||||
}
|
||||
if (co) {
|
||||
contentObserver = new ResizeObserver(handleResize);
|
||||
contentObserver.observe(co);
|
||||
}
|
||||
handleResize();
|
||||
}
|
||||
|
||||
function detach() {
|
||||
viewportObserver?.disconnect();
|
||||
viewportObserver = null;
|
||||
contentObserver?.disconnect();
|
||||
contentObserver = null;
|
||||
}
|
||||
|
||||
onMounted(attach);
|
||||
watch([() => ctx.viewport.value, () => ctx.content.value], attach);
|
||||
onScopeDispose(() => {
|
||||
handleResize.cancel();
|
||||
detach();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Presence :present="forceMount || visible">
|
||||
<ScrollAreaScrollbarVisible
|
||||
v-bind="$attrs"
|
||||
:orientation="orientation"
|
||||
:as="as"
|
||||
:data-state="visible ? 'visible' : 'hidden'"
|
||||
>
|
||||
<slot />
|
||||
</ScrollAreaScrollbarVisible>
|
||||
</Presence>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
export interface ScrollAreaScrollbarHoverProps extends PrimitiveProps {
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
forceMount?: boolean;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onScopeDispose, ref, watch } from 'vue';
|
||||
import { Presence } from '../presence';
|
||||
import ScrollAreaScrollbarAuto from './ScrollAreaScrollbarAuto.vue';
|
||||
import { useScrollAreaRootContext } from './context';
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
withDefaults(defineProps<ScrollAreaScrollbarHoverProps>(), {
|
||||
orientation: 'vertical',
|
||||
});
|
||||
|
||||
const ctx = useScrollAreaRootContext();
|
||||
const visible = ref(false);
|
||||
let hideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let scrollArea: HTMLElement | null = null;
|
||||
|
||||
function clearTimer() {
|
||||
if (hideTimer !== null) {
|
||||
globalThis.clearTimeout(hideTimer);
|
||||
hideTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onEnter() {
|
||||
clearTimer();
|
||||
visible.value = true;
|
||||
}
|
||||
function onLeave() {
|
||||
clearTimer();
|
||||
hideTimer = globalThis.setTimeout(() => {
|
||||
visible.value = false;
|
||||
}, ctx.scrollHideDelay.value);
|
||||
}
|
||||
|
||||
function attach(el: HTMLElement | null) {
|
||||
detach();
|
||||
if (!el)
|
||||
return;
|
||||
scrollArea = el;
|
||||
el.addEventListener('pointerenter', onEnter);
|
||||
el.addEventListener('pointerleave', onLeave);
|
||||
}
|
||||
function detach() {
|
||||
if (!scrollArea)
|
||||
return;
|
||||
scrollArea.removeEventListener('pointerenter', onEnter);
|
||||
scrollArea.removeEventListener('pointerleave', onLeave);
|
||||
scrollArea = null;
|
||||
}
|
||||
|
||||
watch(() => ctx.scrollArea.value, attach, { immediate: true });
|
||||
onScopeDispose(() => {
|
||||
clearTimer();
|
||||
detach();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Presence :present="forceMount || visible">
|
||||
<ScrollAreaScrollbarAuto
|
||||
v-bind="$attrs"
|
||||
:orientation="orientation"
|
||||
:as="as"
|
||||
:data-state="visible ? 'visible' : 'hidden'"
|
||||
force-mount
|
||||
>
|
||||
<slot />
|
||||
</ScrollAreaScrollbarAuto>
|
||||
</Presence>
|
||||
</template>
|
||||
@@ -0,0 +1,246 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
import type { ScrollAreaSizes } from './types';
|
||||
|
||||
export interface ScrollAreaScrollbarImplProps extends PrimitiveProps {
|
||||
orientation: 'horizontal' | 'vertical';
|
||||
sizes: ScrollAreaSizes;
|
||||
hasThumb: boolean;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onScopeDispose, ref, watch } from 'vue';
|
||||
import { Primitive } from '../primitive';
|
||||
import { toInt } from './utils';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { useScrollAreaRootContext } from './context';
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = defineProps<ScrollAreaScrollbarImplProps>();
|
||||
const emit = defineEmits<{
|
||||
sizesChange: [sizes: ScrollAreaSizes];
|
||||
wheelScroll: [event: WheelEvent, maxScroll: number];
|
||||
dragScroll: [pointerPos: number];
|
||||
thumbPositionChange: [];
|
||||
registerScrollbar: [el: HTMLElement | null];
|
||||
}>();
|
||||
|
||||
const ctx = useScrollAreaRootContext();
|
||||
const { forwardRef, currentElement } = useForwardExpose();
|
||||
|
||||
const isHorizontal = computed(() => props.orientation === 'horizontal');
|
||||
const rectRef = ref<DOMRect | null>(null);
|
||||
const prevWebkitUserSelect = ref('');
|
||||
const prevPointerEvents = ref('');
|
||||
|
||||
/** Live viewport scroll position along this scrollbar's axis. */
|
||||
const scrollPos = ref(0);
|
||||
|
||||
const maxScroll = computed(() =>
|
||||
Math.max(0, props.sizes.content - props.sizes.viewport),
|
||||
);
|
||||
|
||||
const ariaValueNow = computed(() => {
|
||||
if (maxScroll.value <= 0) return 0;
|
||||
const pct = (scrollPos.value / maxScroll.value) * 100;
|
||||
return Math.round(Math.min(100, Math.max(0, pct)));
|
||||
});
|
||||
|
||||
/** Scrollbar is interactive only when content actually overflows. */
|
||||
const isInteractive = computed(() => props.hasThumb && maxScroll.value > 0);
|
||||
|
||||
function updateScrollPos() {
|
||||
const vp = ctx.viewport.value;
|
||||
if (!vp) return;
|
||||
scrollPos.value = isHorizontal.value ? vp.scrollLeft : vp.scrollTop;
|
||||
}
|
||||
|
||||
function getPointerPosition(event: PointerEvent): number {
|
||||
const rect = rectRef.value;
|
||||
if (!rect)
|
||||
return 0;
|
||||
return isHorizontal.value ? event.clientX - rect.left : event.clientY - rect.top;
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent) {
|
||||
if (event.button !== 0)
|
||||
return;
|
||||
const target = event.target as HTMLElement;
|
||||
target.setPointerCapture(event.pointerId);
|
||||
rectRef.value = currentElement.value?.getBoundingClientRect() ?? null;
|
||||
prevWebkitUserSelect.value = document.body.style.webkitUserSelect;
|
||||
document.body.style.webkitUserSelect = 'none';
|
||||
if (ctx.viewport.value) {
|
||||
prevPointerEvents.value = ctx.viewport.value.style.pointerEvents;
|
||||
ctx.viewport.value.style.pointerEvents = 'none';
|
||||
}
|
||||
emit('dragScroll', getPointerPosition(event));
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
emit('dragScroll', getPointerPosition(event));
|
||||
}
|
||||
|
||||
function onPointerUp(event: PointerEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.hasPointerCapture(event.pointerId))
|
||||
target.releasePointerCapture(event.pointerId);
|
||||
document.body.style.webkitUserSelect = prevWebkitUserSelect.value;
|
||||
if (ctx.viewport.value)
|
||||
ctx.viewport.value.style.pointerEvents = prevPointerEvents.value;
|
||||
rectRef.value = null;
|
||||
}
|
||||
|
||||
function onWheel(event: WheelEvent) {
|
||||
emit('wheelScroll', event, maxScroll.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* WAI-ARIA scrollbar pattern — Arrow ±5% of the viewport size, PageUp/Down
|
||||
* jump a full viewport, Home/End to the extremes. In RTL the horizontal
|
||||
* arrow keys are visually reversed.
|
||||
*/
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (!isInteractive.value) return;
|
||||
const vp = ctx.viewport.value;
|
||||
if (!vp) return;
|
||||
|
||||
const step = Math.max(1, Math.round(props.sizes.viewport * 0.05));
|
||||
const page = Math.max(step, props.sizes.viewport);
|
||||
const dir = ctx.dir.value;
|
||||
|
||||
let delta = 0;
|
||||
let absolute: number | null = null;
|
||||
|
||||
if (isHorizontal.value) {
|
||||
const forwardKey = dir === 'rtl' ? 'ArrowLeft' : 'ArrowRight';
|
||||
const backwardKey = dir === 'rtl' ? 'ArrowRight' : 'ArrowLeft';
|
||||
if (event.key === forwardKey) delta = step;
|
||||
else if (event.key === backwardKey) delta = -step;
|
||||
else if (event.key === 'PageDown' || event.key === 'PageUp')
|
||||
delta = event.key === 'PageDown' ? page : -page;
|
||||
else if (event.key === 'Home') absolute = 0;
|
||||
else if (event.key === 'End') absolute = maxScroll.value;
|
||||
else return;
|
||||
}
|
||||
else if (event.key === 'ArrowDown') delta = step;
|
||||
else if (event.key === 'ArrowUp') delta = -step;
|
||||
else if (event.key === 'PageDown') delta = page;
|
||||
else if (event.key === 'PageUp') delta = -page;
|
||||
else if (event.key === 'Home') absolute = 0;
|
||||
else if (event.key === 'End') absolute = maxScroll.value;
|
||||
else return;
|
||||
|
||||
event.preventDefault();
|
||||
const current = isHorizontal.value ? vp.scrollLeft : vp.scrollTop;
|
||||
const next = absolute !== null
|
||||
? absolute
|
||||
: Math.max(0, Math.min(maxScroll.value, current + delta));
|
||||
if (isHorizontal.value) vp.scrollLeft = next;
|
||||
else vp.scrollTop = next;
|
||||
}
|
||||
|
||||
function measure() {
|
||||
const sb = currentElement.value;
|
||||
const vp = ctx.viewport.value;
|
||||
const co = ctx.content.value;
|
||||
if (!sb || !vp)
|
||||
return;
|
||||
const cs = globalThis.getComputedStyle(sb);
|
||||
emit('sizesChange', {
|
||||
content: co ? (isHorizontal.value ? co.scrollWidth : co.scrollHeight) : (isHorizontal.value ? vp.scrollWidth : vp.scrollHeight),
|
||||
viewport: isHorizontal.value ? vp.offsetWidth : vp.offsetHeight,
|
||||
scrollbar: {
|
||||
size: isHorizontal.value ? sb.clientWidth : sb.clientHeight,
|
||||
paddingStart: isHorizontal.value ? toInt(cs.paddingLeft) : toInt(cs.paddingTop),
|
||||
paddingEnd: isHorizontal.value ? toInt(cs.paddingRight) : toInt(cs.paddingBottom),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let sbObs: ResizeObserver | null = null;
|
||||
let vpObs: ResizeObserver | null = null;
|
||||
let coObs: ResizeObserver | null = null;
|
||||
|
||||
function attach() {
|
||||
detach();
|
||||
if (currentElement.value) {
|
||||
sbObs = new ResizeObserver(measure);
|
||||
sbObs.observe(currentElement.value);
|
||||
}
|
||||
if (ctx.viewport.value) {
|
||||
vpObs = new ResizeObserver(measure);
|
||||
vpObs.observe(ctx.viewport.value);
|
||||
}
|
||||
if (ctx.content.value) {
|
||||
coObs = new ResizeObserver(measure);
|
||||
coObs.observe(ctx.content.value);
|
||||
}
|
||||
measure();
|
||||
updateScrollPos();
|
||||
emit('thumbPositionChange');
|
||||
}
|
||||
|
||||
function detach() {
|
||||
sbObs?.disconnect();
|
||||
sbObs = null;
|
||||
vpObs?.disconnect();
|
||||
vpObs = null;
|
||||
coObs?.disconnect();
|
||||
coObs = null;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
emit('registerScrollbar', currentElement.value ?? null);
|
||||
attach();
|
||||
});
|
||||
|
||||
watch([() => ctx.viewport.value, () => ctx.content.value, currentElement], attach);
|
||||
|
||||
function onViewportScroll() {
|
||||
updateScrollPos();
|
||||
emit('thumbPositionChange');
|
||||
}
|
||||
|
||||
watch(() => ctx.viewport.value, (vp, prev) => {
|
||||
prev?.removeEventListener('scroll', onViewportScroll);
|
||||
vp?.addEventListener('scroll', onViewportScroll);
|
||||
}, { immediate: true });
|
||||
|
||||
onScopeDispose(() => {
|
||||
detach();
|
||||
ctx.viewport.value?.removeEventListener('scroll', onViewportScroll);
|
||||
emit('registerScrollbar', null);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as ?? 'div'"
|
||||
role="scrollbar"
|
||||
:aria-orientation="orientation"
|
||||
:aria-controls="ctx.viewportId.value"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:aria-valuenow="ariaValueNow"
|
||||
:tabindex="isInteractive ? 0 : -1"
|
||||
:aria-disabled="isInteractive ? undefined : true"
|
||||
:data-orientation="orientation"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
...(isHorizontal
|
||||
? { bottom: 0, left: 0, right: 'var(--scroll-area-corner-width)' }
|
||||
: { top: 0, right: 0, bottom: 'var(--scroll-area-corner-height)' }),
|
||||
}"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@wheel.passive="onWheel"
|
||||
@keydown="onKeyDown"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
export interface ScrollAreaScrollbarScrollProps extends PrimitiveProps {
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
forceMount?: boolean;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onScopeDispose, ref, watch } from 'vue';
|
||||
import { Presence } from '../presence';
|
||||
import { useScrollAreaRootContext } from './context';
|
||||
import ScrollAreaScrollbarVisible from './ScrollAreaScrollbarVisible.vue';
|
||||
import { addUnlinkedScrollListener, debounceCallback } from './utils';
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(defineProps<ScrollAreaScrollbarScrollProps>(), {
|
||||
orientation: 'vertical',
|
||||
});
|
||||
|
||||
const ctx = useScrollAreaRootContext();
|
||||
const isHorizontal = computed(() => props.orientation === 'horizontal');
|
||||
const state = ref<'hidden' | 'scrolling' | 'interacting' | 'idle'>('hidden');
|
||||
|
||||
const debouncedScrollEnd = debounceCallback(() => {
|
||||
state.value = 'idle';
|
||||
}, 100);
|
||||
|
||||
const debouncedHide = debounceCallback(() => {
|
||||
state.value = 'hidden';
|
||||
}, ctx.scrollHideDelay.value);
|
||||
|
||||
watch(state, (s, prev) => {
|
||||
if (s === 'idle')
|
||||
debouncedHide();
|
||||
if (s !== 'idle' && prev === 'idle')
|
||||
debouncedHide.cancel();
|
||||
});
|
||||
|
||||
let last = { left: 0, top: 0 };
|
||||
let stop: (() => void) | null = null;
|
||||
|
||||
function attach() {
|
||||
stop?.();
|
||||
const vp = ctx.viewport.value;
|
||||
if (!vp)
|
||||
return;
|
||||
last = { left: vp.scrollLeft, top: vp.scrollTop };
|
||||
stop = addUnlinkedScrollListener(vp, () => {
|
||||
const next = { left: vp.scrollLeft, top: vp.scrollTop };
|
||||
const horiz = last.left !== next.left;
|
||||
const vert = last.top !== next.top;
|
||||
const matches = isHorizontal.value ? horiz : vert;
|
||||
if (matches) {
|
||||
state.value = 'scrolling';
|
||||
debouncedScrollEnd();
|
||||
}
|
||||
last = next;
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(attach);
|
||||
watch(() => ctx.viewport.value, attach);
|
||||
onScopeDispose(() => {
|
||||
stop?.();
|
||||
debouncedScrollEnd.cancel();
|
||||
debouncedHide.cancel();
|
||||
});
|
||||
|
||||
const isVisible = computed(() => state.value !== 'hidden');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Presence :present="forceMount || isVisible">
|
||||
<ScrollAreaScrollbarVisible
|
||||
v-bind="$attrs"
|
||||
:orientation="orientation"
|
||||
:as="as"
|
||||
:data-state="isVisible ? 'visible' : 'hidden'"
|
||||
@pointerenter="state = 'interacting'"
|
||||
@pointerleave="state = 'idle'"
|
||||
>
|
||||
<slot />
|
||||
</ScrollAreaScrollbarVisible>
|
||||
</Presence>
|
||||
</template>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ScrollAreaSizes } from './types';
|
||||
import { computed, ref } from 'vue';
|
||||
import { provideScrollAreaScrollbarContext, useScrollAreaRootContext } from './context';
|
||||
import ScrollAreaScrollbarImpl from './ScrollAreaScrollbarImpl.vue';
|
||||
import { getScrollPositionFromPointer, getThumbOffsetFromScroll, getThumbRatio, isScrollingWithinScrollbarBounds } from './utils';
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(defineProps<PrimitiveProps & { orientation?: 'horizontal' | 'vertical' }>(), {
|
||||
orientation: 'vertical',
|
||||
});
|
||||
|
||||
const ctx = useScrollAreaRootContext();
|
||||
const sizes = ref<ScrollAreaSizes>({
|
||||
content: 0,
|
||||
viewport: 0,
|
||||
scrollbar: { size: 0, paddingStart: 0, paddingEnd: 0 },
|
||||
});
|
||||
|
||||
const isHorizontal = computed(() => props.orientation === 'horizontal');
|
||||
const hasThumb = computed(() => {
|
||||
const r = getThumbRatio(sizes.value.viewport, sizes.value.content);
|
||||
return r > 0 && r < 1;
|
||||
});
|
||||
|
||||
const thumbEl = ref<HTMLElement | null>(null);
|
||||
const scrollbarEl = ref<HTMLElement | null>(null);
|
||||
const pointerOffset = ref(0);
|
||||
|
||||
function onSizesChange(s: ScrollAreaSizes) {
|
||||
sizes.value = s;
|
||||
}
|
||||
|
||||
function onThumbPointerDown(point: { x: number; y: number }) {
|
||||
pointerOffset.value = isHorizontal.value ? point.x : point.y;
|
||||
}
|
||||
function onThumbPointerUp() {
|
||||
pointerOffset.value = 0;
|
||||
}
|
||||
|
||||
function onWheelScroll(event: WheelEvent, maxScroll: number) {
|
||||
const vp = ctx.viewport.value;
|
||||
if (!vp)
|
||||
return;
|
||||
if (isHorizontal.value) {
|
||||
const next = vp.scrollLeft + event.deltaY;
|
||||
vp.scrollLeft = next;
|
||||
if (isScrollingWithinScrollbarBounds(next, maxScroll))
|
||||
event.preventDefault();
|
||||
}
|
||||
else {
|
||||
const next = vp.scrollTop + event.deltaY;
|
||||
vp.scrollTop = next;
|
||||
if (isScrollingWithinScrollbarBounds(next, maxScroll))
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function onDragScroll(pointerPos: number) {
|
||||
const vp = ctx.viewport.value;
|
||||
if (!vp)
|
||||
return;
|
||||
if (isHorizontal.value) {
|
||||
vp.scrollLeft = getScrollPositionFromPointer(pointerPos, pointerOffset.value, sizes.value, ctx.dir.value);
|
||||
}
|
||||
else {
|
||||
vp.scrollTop = getScrollPositionFromPointer(pointerPos, pointerOffset.value, sizes.value);
|
||||
}
|
||||
}
|
||||
|
||||
function onThumbPositionChange() {
|
||||
const vp = ctx.viewport.value;
|
||||
const thumb = thumbEl.value;
|
||||
if (!vp || !thumb)
|
||||
return;
|
||||
if (isHorizontal.value) {
|
||||
const offset = getThumbOffsetFromScroll(vp.scrollLeft, sizes.value, ctx.dir.value);
|
||||
thumb.style.transform = `translate3d(${offset}px, 0, 0)`;
|
||||
}
|
||||
else {
|
||||
const offset = getThumbOffsetFromScroll(vp.scrollTop, sizes.value);
|
||||
thumb.style.transform = `translate3d(0, ${offset}px, 0)`;
|
||||
}
|
||||
}
|
||||
|
||||
provideScrollAreaScrollbarContext({
|
||||
orientation: props.orientation,
|
||||
hasThumb,
|
||||
scrollbar: scrollbarEl,
|
||||
onThumbChange: (el) => { thumbEl.value = el; },
|
||||
onThumbPointerUp,
|
||||
onThumbPointerDown,
|
||||
onThumbPositionChange,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollAreaScrollbarImpl
|
||||
v-bind="$attrs"
|
||||
:orientation="orientation"
|
||||
:as="as"
|
||||
:sizes="sizes"
|
||||
:has-thumb="hasThumb"
|
||||
@sizes-change="onSizesChange"
|
||||
@wheel-scroll="onWheelScroll"
|
||||
@drag-scroll="onDragScroll"
|
||||
@thumb-position-change="onThumbPositionChange"
|
||||
@register-scrollbar="(el) => { scrollbarEl = el; }"
|
||||
>
|
||||
<slot />
|
||||
</ScrollAreaScrollbarImpl>
|
||||
</template>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onScopeDispose, ref, watch } from 'vue';
|
||||
import { Presence } from '../presence';
|
||||
import { Primitive } from '../primitive';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { useScrollAreaRootContext, useScrollAreaScrollbarContext } from './context';
|
||||
import { addUnlinkedScrollListener } from './utils';
|
||||
|
||||
export interface ScrollAreaThumbProps extends PrimitiveProps {
|
||||
/** Keep mounted regardless of `hasThumb`. */
|
||||
forceMount?: boolean;
|
||||
}
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = defineProps<ScrollAreaThumbProps>();
|
||||
const root = useScrollAreaRootContext();
|
||||
const sb = useScrollAreaScrollbarContext();
|
||||
|
||||
const removeUnlinkedScrollListenerRef = ref<(() => void) | null>(null);
|
||||
const { forwardRef, currentElement } = useForwardExpose();
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
const rect = target.getBoundingClientRect();
|
||||
sb.onThumbPointerDown({ x: event.clientX - rect.left, y: event.clientY - rect.top });
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
sb.onThumbPointerUp();
|
||||
}
|
||||
|
||||
function attachScroll() {
|
||||
removeUnlinkedScrollListenerRef.value?.();
|
||||
const vp = root.viewport.value;
|
||||
if (!vp)
|
||||
return;
|
||||
sb.onThumbPositionChange();
|
||||
removeUnlinkedScrollListenerRef.value = addUnlinkedScrollListener(vp, () => {
|
||||
sb.onThumbPositionChange();
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
attachScroll();
|
||||
if (currentElement.value)
|
||||
sb.onThumbChange(currentElement.value);
|
||||
});
|
||||
|
||||
watch(currentElement, el => sb.onThumbChange(el ?? null));
|
||||
watch(() => root.viewport.value, attachScroll);
|
||||
watch(() => sb.hasThumb.value, () => {
|
||||
sb.onThumbPositionChange();
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
removeUnlinkedScrollListenerRef.value?.();
|
||||
sb.onThumbChange(null);
|
||||
});
|
||||
|
||||
const present = computed(() => props.forceMount || sb.hasThumb.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Presence :present="present">
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as ?? 'div'"
|
||||
data-state="visible"
|
||||
:style="{ width: 'var(--scroll-area-thumb-width)', height: 'var(--scroll-area-thumb-height)' }"
|
||||
v-bind="$attrs"
|
||||
@pointerdowncapture="handlePointerDown"
|
||||
@pointerup="handlePointerUp"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</Presence>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
export interface ScrollAreaViewportProps extends PrimitiveProps {
|
||||
/** Inline `nonce` attribute applied to the injected style tag (CSP support). */
|
||||
nonce?: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { Primitive } from '../primitive';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { useScrollAreaRootContext } from './context';
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
defineProps<ScrollAreaViewportProps>();
|
||||
|
||||
const ctx = useScrollAreaRootContext();
|
||||
const { forwardRef, currentElement } = useForwardExpose();
|
||||
|
||||
const contentRef = ref<HTMLElement | null>(null);
|
||||
|
||||
watch(currentElement, (el) => {
|
||||
ctx.viewport.value = el ?? null;
|
||||
}, { immediate: true });
|
||||
|
||||
watch(contentRef, (el) => {
|
||||
ctx.content.value = el ?? null;
|
||||
}, { immediate: true });
|
||||
|
||||
onMounted(() => {
|
||||
ctx.viewport.value = currentElement.value ?? null;
|
||||
ctx.content.value = contentRef.value ?? null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Hide native scrollbars while preserving native scrolling behaviour. -->
|
||||
<component
|
||||
:is="'style'"
|
||||
:nonce="nonce"
|
||||
>
|
||||
[data-scroll-area-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}[data-scroll-area-viewport]::-webkit-scrollbar{display:none;}
|
||||
</component>
|
||||
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
:id="($attrs.id as string | undefined) ?? ctx.viewportId.value"
|
||||
data-scroll-area-viewport=""
|
||||
:style="{
|
||||
overflowX: ctx.scrollbarXEnabled.value ? 'scroll' : 'hidden',
|
||||
overflowY: ctx.scrollbarYEnabled.value ? 'scroll' : 'hidden',
|
||||
}"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<!-- A `min-width: fit-content` inner ensures horizontal content is measurable. -->
|
||||
<div
|
||||
:ref="(el: any) => { contentRef = el; }"
|
||||
:style="{ minWidth: '100%', display: 'table' }"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { VueWrapper } from '@vue/test-utils';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { defineComponent, h, nextTick } from 'vue';
|
||||
|
||||
import {
|
||||
ScrollAreaRoot,
|
||||
ScrollAreaScrollbar,
|
||||
ScrollAreaThumb,
|
||||
ScrollAreaViewport,
|
||||
} from '../../index';
|
||||
|
||||
const wrappers: Array<VueWrapper<any>> = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (wrappers.length) wrappers.pop()!.unmount();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
function track<T extends VueWrapper<any>>(w: T): T {
|
||||
wrappers.push(w);
|
||||
return w;
|
||||
}
|
||||
|
||||
function makeApp({
|
||||
rootProps = {},
|
||||
innerSize = '500px',
|
||||
}: { rootProps?: Record<string, unknown>; innerSize?: string } = {}) {
|
||||
return defineComponent({
|
||||
setup() {
|
||||
return () => h(
|
||||
ScrollAreaRoot,
|
||||
{ ...rootProps, type: 'always', style: { width: '100px', height: '100px' } },
|
||||
{
|
||||
default: () => [
|
||||
h(ScrollAreaViewport, { style: { width: '100%', height: '100%' } }, {
|
||||
default: () => h('div', { style: { width: innerSize, height: innerSize } }, 'content'),
|
||||
}),
|
||||
h(ScrollAreaScrollbar, { orientation: 'vertical' }, {
|
||||
default: () => h(ScrollAreaThumb),
|
||||
}),
|
||||
h(ScrollAreaScrollbar, { orientation: 'horizontal' }, {
|
||||
default: () => h(ScrollAreaThumb),
|
||||
}),
|
||||
],
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function mountApp(options?: { rootProps?: Record<string, unknown>; innerSize?: string }) {
|
||||
return track(mount(makeApp(options), { attachTo: document.body }));
|
||||
}
|
||||
|
||||
function getScrollbar(orientation: 'horizontal' | 'vertical'): HTMLElement {
|
||||
return document.querySelector(`[role="scrollbar"][aria-orientation="${orientation}"]`) as HTMLElement;
|
||||
}
|
||||
|
||||
function getViewport(): HTMLElement {
|
||||
return document.querySelector('[data-scroll-area-viewport]') as HTMLElement;
|
||||
}
|
||||
|
||||
async function waitFrames(n = 3) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
await new Promise<void>(r => requestAnimationFrame(() => r()));
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
describe('scroll-area — scrollbar ARIA', () => {
|
||||
it('exposes role=scrollbar with full ARIA contract', async () => {
|
||||
mountApp();
|
||||
await waitFrames();
|
||||
const v = getScrollbar('vertical');
|
||||
const h = getScrollbar('horizontal');
|
||||
expect(v).toBeTruthy();
|
||||
expect(h).toBeTruthy();
|
||||
for (const sb of [v, h]) {
|
||||
expect(sb.getAttribute('aria-valuemin')).toBe('0');
|
||||
expect(sb.getAttribute('aria-valuemax')).toBe('100');
|
||||
expect(sb.getAttribute('aria-valuenow')).toBe('0');
|
||||
}
|
||||
expect(v.getAttribute('aria-orientation')).toBe('vertical');
|
||||
expect(h.getAttribute('aria-orientation')).toBe('horizontal');
|
||||
});
|
||||
|
||||
it('wires aria-controls to the viewport id', async () => {
|
||||
mountApp();
|
||||
await waitFrames();
|
||||
const v = getScrollbar('vertical');
|
||||
const vp = getViewport();
|
||||
expect(vp.id).toBeTruthy();
|
||||
expect(v.getAttribute('aria-controls')).toBe(vp.id);
|
||||
});
|
||||
|
||||
it('marks scrollbar interactive (tabindex=0) when content overflows', async () => {
|
||||
mountApp({ innerSize: '500px' });
|
||||
await waitFrames();
|
||||
const v = getScrollbar('vertical');
|
||||
expect(v.getAttribute('tabindex')).toBe('0');
|
||||
expect(v.hasAttribute('aria-disabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('marks scrollbar non-interactive (tabindex=-1, aria-disabled) when content fits', async () => {
|
||||
mountApp({ innerSize: '50px' });
|
||||
await waitFrames();
|
||||
const v = getScrollbar('vertical');
|
||||
expect(v.getAttribute('tabindex')).toBe('-1');
|
||||
expect(v.getAttribute('aria-disabled')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scroll-area — scrollbar keyboard support', () => {
|
||||
it('End scrolls the vertical viewport to the bottom and updates aria-valuenow', async () => {
|
||||
mountApp({ innerSize: '500px' });
|
||||
await waitFrames();
|
||||
const v = getScrollbar('vertical');
|
||||
const vp = getViewport();
|
||||
expect(vp.scrollTop).toBe(0);
|
||||
const ev = new KeyboardEvent('keydown', { key: 'End', bubbles: true, cancelable: true });
|
||||
v.dispatchEvent(ev);
|
||||
await waitFrames();
|
||||
expect(ev.defaultPrevented).toBe(true);
|
||||
expect(vp.scrollTop).toBeGreaterThan(0);
|
||||
expect(v.getAttribute('aria-valuenow')).toBe('100');
|
||||
});
|
||||
|
||||
it('Home scrolls to the top', async () => {
|
||||
mountApp({ innerSize: '500px' });
|
||||
await waitFrames();
|
||||
const v = getScrollbar('vertical');
|
||||
const vp = getViewport();
|
||||
vp.scrollTop = 9999;
|
||||
await waitFrames();
|
||||
v.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true, cancelable: true }));
|
||||
await waitFrames();
|
||||
expect(vp.scrollTop).toBe(0);
|
||||
expect(v.getAttribute('aria-valuenow')).toBe('0');
|
||||
});
|
||||
|
||||
it('ArrowDown moves the vertical viewport forward by ~5% of viewport size', async () => {
|
||||
mountApp({ innerSize: '500px' });
|
||||
await waitFrames();
|
||||
const v = getScrollbar('vertical');
|
||||
const vp = getViewport();
|
||||
const expectedStep = Math.max(1, Math.round(vp.offsetHeight * 0.05));
|
||||
v.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }));
|
||||
await waitFrames();
|
||||
expect(vp.scrollTop).toBe(expectedStep);
|
||||
});
|
||||
|
||||
it('PageDown moves the vertical viewport by a full viewport', async () => {
|
||||
mountApp({ innerSize: '500px' });
|
||||
await waitFrames();
|
||||
const v = getScrollbar('vertical');
|
||||
const vp = getViewport();
|
||||
const page = vp.offsetHeight;
|
||||
v.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageDown', bubbles: true, cancelable: true }));
|
||||
await waitFrames();
|
||||
expect(vp.scrollTop).toBe(page);
|
||||
});
|
||||
|
||||
it('LTR: ArrowRight scrolls the horizontal viewport forward', async () => {
|
||||
mountApp({ innerSize: '500px' });
|
||||
await waitFrames();
|
||||
const h = getScrollbar('horizontal');
|
||||
const vp = getViewport();
|
||||
const expectedStep = Math.max(1, Math.round(vp.offsetWidth * 0.05));
|
||||
h.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true }));
|
||||
await waitFrames();
|
||||
expect(vp.scrollLeft).toBe(expectedStep);
|
||||
});
|
||||
|
||||
it('RTL: ArrowLeft on the horizontal scrollbar engages the handler (visually reversed)', async () => {
|
||||
mountApp({ rootProps: { dir: 'rtl' }, innerSize: '500px' });
|
||||
await waitFrames();
|
||||
const h = getScrollbar('horizontal');
|
||||
const ev = new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true, cancelable: true });
|
||||
h.dispatchEvent(ev);
|
||||
await waitFrames();
|
||||
// In RTL the visually-forward arrow is ArrowLeft; we assert the handler
|
||||
// claimed the event (browser RTL scrollLeft semantics vary, so direction
|
||||
// of the delta itself is asserted indirectly via preventDefault here).
|
||||
expect(ev.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('keydown is a no-op when the scrollbar is non-interactive', async () => {
|
||||
mountApp({ innerSize: '50px' });
|
||||
await waitFrames();
|
||||
const v = getScrollbar('vertical');
|
||||
const vp = getViewport();
|
||||
const before = vp.scrollTop;
|
||||
const ev = new KeyboardEvent('keydown', { key: 'End', bubbles: true, cancelable: true });
|
||||
v.dispatchEvent(ev);
|
||||
await waitFrames();
|
||||
expect(ev.defaultPrevented).toBe(false);
|
||||
expect(vp.scrollTop).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
ScrollAreaCorner,
|
||||
ScrollAreaRoot,
|
||||
ScrollAreaScrollbar,
|
||||
ScrollAreaThumb,
|
||||
ScrollAreaViewport,
|
||||
} from '../../index';
|
||||
import {
|
||||
clamp,
|
||||
getScrollPositionFromPointer,
|
||||
getThumbOffsetFromScroll,
|
||||
getThumbRatio,
|
||||
getThumbSize,
|
||||
isScrollingWithinScrollbarBounds,
|
||||
toInt,
|
||||
} from '../utils';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||
import type { VueWrapper } from '@vue/test-utils';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
const wrappers: Array<VueWrapper<any>> = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (wrappers.length) wrappers.pop()!.unmount();
|
||||
document.body.innerHTML = '';
|
||||
document.body.removeAttribute('style');
|
||||
});
|
||||
|
||||
function track<T extends VueWrapper<any>>(w: T): T {
|
||||
wrappers.push(w);
|
||||
return w;
|
||||
}
|
||||
|
||||
function makeApp(rootProps: Record<string, unknown> = {}) {
|
||||
return defineComponent({
|
||||
setup(_, { expose }) {
|
||||
const rootRef = ref<any>(null);
|
||||
expose({ rootRef });
|
||||
return () => h(ScrollAreaRoot, { ref: rootRef, ...rootProps, style: { width: '100px', height: '100px' } }, {
|
||||
default: () => [
|
||||
h(ScrollAreaViewport, { style: { width: '100%', height: '100%' } }, {
|
||||
default: () => h('div', { style: { width: '500px', height: '500px' } }, 'content'),
|
||||
}),
|
||||
h(ScrollAreaScrollbar, { orientation: 'vertical' }, {
|
||||
default: () => h(ScrollAreaThumb),
|
||||
}),
|
||||
h(ScrollAreaScrollbar, { orientation: 'horizontal' }, {
|
||||
default: () => h(ScrollAreaThumb),
|
||||
}),
|
||||
h(ScrollAreaCorner),
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('scrollArea utils', () => {
|
||||
it('clamp constrains value to range', () => {
|
||||
expect(clamp(5, 0, 10)).toBe(5);
|
||||
expect(clamp(-1, 0, 10)).toBe(0);
|
||||
expect(clamp(99, 0, 10)).toBe(10);
|
||||
});
|
||||
|
||||
it('toInt parses pixel strings', () => {
|
||||
expect(toInt('12px')).toBe(12);
|
||||
expect(toInt('')).toBe(0);
|
||||
expect(toInt(undefined)).toBe(0);
|
||||
expect(toInt(null)).toBe(0);
|
||||
});
|
||||
|
||||
it('getThumbRatio is 1 when viewport >= content', () => {
|
||||
expect(getThumbRatio(100, 100)).toBe(1);
|
||||
expect(getThumbRatio(200, 100)).toBe(1);
|
||||
expect(getThumbRatio(0, 0)).toBe(1);
|
||||
});
|
||||
|
||||
it('getThumbRatio is fraction when content exceeds viewport', () => {
|
||||
expect(getThumbRatio(100, 200)).toBe(0.5);
|
||||
expect(getThumbRatio(50, 200)).toBe(0.25);
|
||||
});
|
||||
|
||||
it('getThumbSize enforces 18px minimum', () => {
|
||||
const sizes = {
|
||||
content: 100000,
|
||||
viewport: 100,
|
||||
scrollbar: { size: 100, paddingStart: 0, paddingEnd: 0 },
|
||||
};
|
||||
expect(getThumbSize(sizes)).toBe(18);
|
||||
});
|
||||
|
||||
it('getThumbSize scales with ratio', () => {
|
||||
const sizes = {
|
||||
content: 200,
|
||||
viewport: 100,
|
||||
scrollbar: { size: 100, paddingStart: 0, paddingEnd: 0 },
|
||||
};
|
||||
expect(getThumbSize(sizes)).toBe(50);
|
||||
});
|
||||
|
||||
it('getThumbOffsetFromScroll maps 0 → 0 and max → maxThumbPos (LTR)', () => {
|
||||
const sizes = {
|
||||
content: 200,
|
||||
viewport: 100,
|
||||
scrollbar: { size: 100, paddingStart: 0, paddingEnd: 0 },
|
||||
};
|
||||
expect(getThumbOffsetFromScroll(0, sizes)).toBe(0);
|
||||
expect(getThumbOffsetFromScroll(100, sizes)).toBe(50);
|
||||
expect(getThumbOffsetFromScroll(50, sizes)).toBe(25);
|
||||
});
|
||||
|
||||
it('getThumbOffsetFromScroll handles RTL negative scroll', () => {
|
||||
const sizes = {
|
||||
content: 200,
|
||||
viewport: 100,
|
||||
scrollbar: { size: 100, paddingStart: 0, paddingEnd: 0 },
|
||||
};
|
||||
expect(getThumbOffsetFromScroll(0, sizes, 'rtl')).toBe(50);
|
||||
expect(getThumbOffsetFromScroll(-100, sizes, 'rtl')).toBe(0);
|
||||
});
|
||||
|
||||
it('getScrollPositionFromPointer maps min/max pointer to scroll range', () => {
|
||||
const sizes = {
|
||||
content: 200,
|
||||
viewport: 100,
|
||||
scrollbar: { size: 100, paddingStart: 0, paddingEnd: 0 },
|
||||
};
|
||||
expect(getScrollPositionFromPointer(25, 25, sizes)).toBe(0);
|
||||
expect(getScrollPositionFromPointer(75, 25, sizes)).toBe(100);
|
||||
expect(getScrollPositionFromPointer(50, 25, sizes)).toBe(50);
|
||||
});
|
||||
|
||||
it('isScrollingWithinScrollbarBounds detects intermediate scroll positions', () => {
|
||||
expect(isScrollingWithinScrollbarBounds(0, 100)).toBe(false);
|
||||
expect(isScrollingWithinScrollbarBounds(100, 100)).toBe(false);
|
||||
expect(isScrollingWithinScrollbarBounds(50, 100)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrollArea components', () => {
|
||||
it('renders root with viewport and scrollbars', async () => {
|
||||
const w = track(mount(makeApp({ type: 'always' }), { attachTo: document.body }));
|
||||
await nextTick();
|
||||
expect(w.find('[data-scroll-area-viewport]').exists()).toBe(true);
|
||||
expect(w.findAll('[data-orientation="vertical"]').length).toBeGreaterThan(0);
|
||||
expect(w.findAll('[data-orientation="horizontal"]').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('viewport hides native scrollbars via injected stylesheet', () => {
|
||||
const w = track(mount(makeApp({ type: 'always' }), { attachTo: document.body }));
|
||||
expect(w.html()).toContain('-webkit-scrollbar');
|
||||
});
|
||||
|
||||
it('honours `dir` prop', () => {
|
||||
const w = track(mount(makeApp({ dir: 'rtl' }), { attachTo: document.body }));
|
||||
expect(w.find('[dir="rtl"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('forwards `as` to Primitive', () => {
|
||||
const w = track(mount(makeApp({ as: 'section' }), { attachTo: document.body }));
|
||||
expect(w.find('section').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('hover mode keeps scrollbar mounted while pointer is over root', async () => {
|
||||
const w = track(mount(makeApp({ type: 'hover', scrollHideDelay: 1 }), { attachTo: document.body }));
|
||||
await nextTick();
|
||||
const root = w.find('[dir]').element as HTMLElement;
|
||||
root.dispatchEvent(new PointerEvent('pointerenter'));
|
||||
await nextTick();
|
||||
expect(w.findAll('[data-state="visible"]').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('exposes scrollTop / scrollTopLeft', async () => {
|
||||
const w = track(mount(makeApp({ type: 'always' }), { attachTo: document.body }));
|
||||
await nextTick();
|
||||
const root = (w.vm as any).rootRef;
|
||||
expect(typeof root.scrollTop).toBe('function');
|
||||
expect(typeof root.scrollTopLeft).toBe('function');
|
||||
root.scrollTop();
|
||||
root.scrollTopLeft();
|
||||
});
|
||||
});
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,42 @@
|
||||
import type { Ref } from 'vue';
|
||||
import type { ScrollAreaType, ScrollDirection } from './types';
|
||||
import { useContextFactory } from '@robonen/vue';
|
||||
|
||||
export interface ScrollAreaRootContext {
|
||||
type: Ref<ScrollAreaType>;
|
||||
dir: Ref<ScrollDirection>;
|
||||
scrollHideDelay: Ref<number>;
|
||||
scrollArea: Ref<HTMLElement | null>;
|
||||
viewport: Ref<HTMLElement | null>;
|
||||
content: Ref<HTMLElement | null>;
|
||||
scrollbarX: Ref<HTMLElement | null>;
|
||||
scrollbarY: Ref<HTMLElement | null>;
|
||||
scrollbarXEnabled: Ref<boolean>;
|
||||
scrollbarYEnabled: Ref<boolean>;
|
||||
cornerWidth: Ref<number>;
|
||||
cornerHeight: Ref<number>;
|
||||
/** Unique id assigned to the Viewport so scrollbars can `aria-controls` it. */
|
||||
viewportId: Ref<string>;
|
||||
onScrollbarXEnabledChange: (enabled: boolean) => void;
|
||||
onScrollbarYEnabledChange: (enabled: boolean) => void;
|
||||
onCornerWidthChange: (n: number) => void;
|
||||
onCornerHeightChange: (n: number) => void;
|
||||
}
|
||||
|
||||
const RootCtx = useContextFactory<ScrollAreaRootContext>('ScrollAreaRootContext');
|
||||
export const provideScrollAreaRootContext = RootCtx.provide;
|
||||
export const useScrollAreaRootContext = RootCtx.inject;
|
||||
|
||||
export interface ScrollAreaScrollbarContext {
|
||||
orientation: 'horizontal' | 'vertical';
|
||||
hasThumb: Ref<boolean>;
|
||||
scrollbar: Ref<HTMLElement | null>;
|
||||
onThumbChange: (el: HTMLElement | null) => void;
|
||||
onThumbPointerUp: () => void;
|
||||
onThumbPointerDown: (point: { x: number; y: number }) => void;
|
||||
onThumbPositionChange: () => void;
|
||||
}
|
||||
|
||||
const ScrollbarCtx = useContextFactory<ScrollAreaScrollbarContext>('ScrollAreaScrollbarContext');
|
||||
export const provideScrollAreaScrollbarContext = ScrollbarCtx.provide;
|
||||
export const useScrollAreaScrollbarContext = ScrollbarCtx.inject;
|
||||
@@ -0,0 +1,14 @@
|
||||
export { default as ScrollAreaCorner, type ScrollAreaCornerProps } from './ScrollAreaCorner.vue';
|
||||
export { default as ScrollAreaRoot, type ScrollAreaRootProps } from './ScrollAreaRoot.vue';
|
||||
export { default as ScrollAreaScrollbar, type ScrollAreaScrollbarProps } from './ScrollAreaScrollbar.vue';
|
||||
export { default as ScrollAreaThumb, type ScrollAreaThumbProps } from './ScrollAreaThumb.vue';
|
||||
export { default as ScrollAreaViewport, type ScrollAreaViewportProps } from './ScrollAreaViewport.vue';
|
||||
export {
|
||||
provideScrollAreaRootContext,
|
||||
provideScrollAreaScrollbarContext,
|
||||
type ScrollAreaRootContext,
|
||||
type ScrollAreaScrollbarContext,
|
||||
useScrollAreaRootContext,
|
||||
useScrollAreaScrollbarContext,
|
||||
} from './context';
|
||||
export type { ScrollAreaSizes, ScrollAreaType, ScrollDirection } from './types';
|
||||
@@ -0,0 +1,13 @@
|
||||
export type ScrollDirection = 'ltr' | 'rtl';
|
||||
|
||||
export type ScrollAreaType = 'auto' | 'always' | 'scroll' | 'hover';
|
||||
|
||||
export interface ScrollAreaSizes {
|
||||
content: number;
|
||||
viewport: number;
|
||||
scrollbar: {
|
||||
size: number;
|
||||
paddingStart: number;
|
||||
paddingEnd: number;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { ScrollAreaSizes, ScrollDirection } from './types';
|
||||
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
export function toInt(value?: string | null): number {
|
||||
return value ? Number.parseInt(value, 10) || 0 : 0;
|
||||
}
|
||||
|
||||
export function getThumbRatio(viewport: number, content: number): number {
|
||||
if (!content || viewport >= content)
|
||||
return 1;
|
||||
const r = viewport / content;
|
||||
return Number.isFinite(r) ? r : 0;
|
||||
}
|
||||
|
||||
export function getThumbSize(sizes: ScrollAreaSizes): number {
|
||||
const ratio = getThumbRatio(sizes.viewport, sizes.content);
|
||||
const trackPadding = sizes.scrollbar.paddingStart + sizes.scrollbar.paddingEnd;
|
||||
const trackSize = sizes.scrollbar.size - trackPadding;
|
||||
const thumb = trackSize * ratio;
|
||||
return Math.max(thumb, 18);
|
||||
}
|
||||
|
||||
function mapLinear(input: readonly [number, number], output: readonly [number, number]) {
|
||||
return (value: number) => {
|
||||
if (input[0] === input[1] || output[0] === output[1])
|
||||
return output[0];
|
||||
const ratio = (output[1] - output[0]) / (input[1] - input[0]);
|
||||
return output[0] + ratio * (value - input[0]);
|
||||
};
|
||||
}
|
||||
|
||||
export function getThumbOffsetFromScroll(
|
||||
scrollPos: number,
|
||||
sizes: ScrollAreaSizes,
|
||||
dir: ScrollDirection = 'ltr',
|
||||
): number {
|
||||
const thumbSize = getThumbSize(sizes);
|
||||
const trackPadding = sizes.scrollbar.paddingStart + sizes.scrollbar.paddingEnd;
|
||||
const trackSize = sizes.scrollbar.size - trackPadding;
|
||||
const maxScroll = sizes.content - sizes.viewport;
|
||||
const maxThumbPos = trackSize - thumbSize;
|
||||
const range: [number, number] = dir === 'ltr' ? [0, maxScroll] : [-maxScroll, 0];
|
||||
const clamped = clamp(scrollPos, range[0], range[1]);
|
||||
return mapLinear([0, maxScroll], [0, maxThumbPos])(dir === 'ltr' ? clamped : clamped + maxScroll);
|
||||
}
|
||||
|
||||
export function getScrollPositionFromPointer(
|
||||
pointerPos: number,
|
||||
pointerOffset: number,
|
||||
sizes: ScrollAreaSizes,
|
||||
dir: ScrollDirection = 'ltr',
|
||||
): number {
|
||||
const thumbSize = getThumbSize(sizes);
|
||||
const offset = pointerOffset || thumbSize / 2;
|
||||
const remainder = thumbSize - offset;
|
||||
const minPointer = sizes.scrollbar.paddingStart + offset;
|
||||
const maxPointer = sizes.scrollbar.size - sizes.scrollbar.paddingEnd - remainder;
|
||||
const maxScroll = sizes.content - sizes.viewport;
|
||||
const range: [number, number] = dir === 'ltr' ? [0, maxScroll] : [-maxScroll, 0];
|
||||
return mapLinear([minPointer, maxPointer], range)(pointerPos);
|
||||
}
|
||||
|
||||
export function isScrollingWithinScrollbarBounds(scrollPos: number, maxScrollPos: number): boolean {
|
||||
return scrollPos > 0 && scrollPos < maxScrollPos;
|
||||
}
|
||||
|
||||
export function addUnlinkedScrollListener(node: HTMLElement, handler: () => void): () => void {
|
||||
let prev = { left: node.scrollLeft, top: node.scrollTop };
|
||||
let raf = 0;
|
||||
const loop = () => {
|
||||
const pos = { left: node.scrollLeft, top: node.scrollTop };
|
||||
if (prev.left !== pos.left || prev.top !== pos.top)
|
||||
handler();
|
||||
prev = pos;
|
||||
raf = globalThis.requestAnimationFrame(loop);
|
||||
};
|
||||
raf = globalThis.requestAnimationFrame(loop);
|
||||
return () => globalThis.cancelAnimationFrame(raf);
|
||||
}
|
||||
|
||||
export function debounceCallback<T extends (...args: never[]) => void>(fn: T, ms: number) {
|
||||
let id: ReturnType<typeof setTimeout> | null = null;
|
||||
const debounced = ((...args: Parameters<T>) => {
|
||||
if (id !== null)
|
||||
globalThis.clearTimeout(id);
|
||||
id = globalThis.setTimeout(() => fn(...args), ms);
|
||||
}) as T & { cancel: () => void };
|
||||
debounced.cancel = () => {
|
||||
if (id !== null) {
|
||||
globalThis.clearTimeout(id);
|
||||
id = null;
|
||||
}
|
||||
};
|
||||
return debounced;
|
||||
}
|
||||
Reference in New Issue
Block a user