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:
2026-06-07 16:29:56 +07:00
parent c7644ade69
commit 626fbc70d8
408 changed files with 27367 additions and 154 deletions
@@ -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();
});
});
+42
View File
@@ -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;
+14
View File
@@ -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';
+13
View File
@@ -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;
};
}
+98
View File
@@ -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;
}