feat: update useSnapPoints to improve drawer snapping behavior and add new features
Publish to NPM / Check version changes and publish (push) Successful in 11m14s
Publish to NPM / Check version changes and publish (push) Successful in 11m14s
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@robonen/stdlib",
|
||||
"version": "0.0.11",
|
||||
"version": "0.0.12",
|
||||
"license": "Apache-2.0",
|
||||
"description": "A collection of tools, utilities, and helpers for TypeScript",
|
||||
"keywords": [
|
||||
|
||||
@@ -21,4 +21,28 @@ describe('createMachine', () => {
|
||||
it('send returns the (typed) resulting state', () => {
|
||||
expectTypeOf(machine.send('START')).toEqualTypeOf<'idle' | 'running'>();
|
||||
});
|
||||
|
||||
it('empty terminal nodes do not widen the event union to string', () => {
|
||||
const terminal = createMachine({
|
||||
initial: 'idle',
|
||||
states: {
|
||||
idle: { on: { START: 'done' } },
|
||||
done: {},
|
||||
},
|
||||
});
|
||||
|
||||
expectTypeOf(terminal.send).parameter(0).toEqualTypeOf<'START'>();
|
||||
});
|
||||
|
||||
it('entry/exit-only nodes do not widen the event union either', () => {
|
||||
const hooked = createMachine({
|
||||
initial: 'idle',
|
||||
states: {
|
||||
idle: { on: { START: 'done' } },
|
||||
done: { entry: () => {} },
|
||||
},
|
||||
});
|
||||
|
||||
expectTypeOf(hooked.send).parameter(0).toEqualTypeOf<'START'>();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -418,6 +418,7 @@ describe('asyncStateMachine', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-expect-error -- deliberately undeclared event: runtime must ignore it
|
||||
const result = await machine.send('STOP');
|
||||
|
||||
expect(result).toBe('idle');
|
||||
@@ -597,6 +598,7 @@ describe('asyncStateMachine', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-expect-error -- deliberately undeclared event: can() must report false
|
||||
expect(await machine.can('STOP')).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -57,8 +57,12 @@ export type AsyncStateNodeConfig<Context> = StateNodeConfig<Context, MaybePromis
|
||||
|
||||
export type ExtractStates<T> = keyof T & string;
|
||||
|
||||
// `on` is matched as REQUIRED here on purpose: an empty terminal node (`{}`)
|
||||
// satisfies an optional-`on` pattern with no inference candidate, so `infer E`
|
||||
// would fall back to its constraint and collapse the whole union to `string`,
|
||||
// silently accepting any event name in `send`/`can`.
|
||||
export type ExtractEvents<T> = {
|
||||
[K in keyof T]: T[K] extends { readonly on?: Readonly<Record<infer E extends string, unknown>> }
|
||||
[K in keyof T]: T[K] extends { readonly on: Readonly<Record<infer E extends string, unknown>> }
|
||||
? E
|
||||
: never;
|
||||
}[keyof T];
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import type { DialogCloseProps } from '../dialog';
|
||||
|
||||
/**
|
||||
* A button that closes the drawer when activated. A thin wrapper over Dialog's
|
||||
* Close that tags the resulting `update:open` with the `close-press` reason.
|
||||
*/
|
||||
export interface DrawerCloseProps extends DialogCloseProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { DialogClose } from '../dialog';
|
||||
import { injectDrawerRootContext } from './context';
|
||||
|
||||
const props = defineProps<DrawerCloseProps>();
|
||||
const { armReason } = injectDrawerRootContext();
|
||||
const { forwardRef } = useForwardExpose();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogClose v-bind="props" :ref="forwardRef" @click="armReason('close-press')">
|
||||
<slot />
|
||||
</DialogClose>
|
||||
</template>
|
||||
@@ -14,6 +14,7 @@ export type DrawerContentEmits = DialogContentEmits;
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, watchEffect } from 'vue';
|
||||
import { isClient } from '@robonen/platform/multi';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { DialogContent } from '../dialog';
|
||||
import { injectDrawerRootContext } from './context';
|
||||
@@ -30,6 +31,9 @@ const {
|
||||
onPress,
|
||||
onDrag,
|
||||
onRelease,
|
||||
onCancel,
|
||||
armReason,
|
||||
isAllowedToDrag,
|
||||
modal,
|
||||
dismissible,
|
||||
keyboardIsOpen,
|
||||
@@ -49,10 +53,12 @@ useScaleBackground();
|
||||
const delayedSnapPoints = ref(false);
|
||||
|
||||
const snapPointHeight = computed(() => {
|
||||
if (snapPointsOffset.value && snapPointsOffset.value.length > 0)
|
||||
return `${snapPointsOffset.value[0]}px`;
|
||||
const offset = snapPointsOffset.value?.[0];
|
||||
|
||||
return '0';
|
||||
if (typeof offset === 'number' && Number.isFinite(offset))
|
||||
return `${offset}px`;
|
||||
|
||||
return '0px';
|
||||
});
|
||||
|
||||
function handlePointerDownOutside(event: Event) {
|
||||
@@ -66,13 +72,21 @@ function handlePointerDownOutside(event: Event) {
|
||||
|
||||
// Let the underlying DismissableLayer close a dismissible modal drawer;
|
||||
// otherwise hold it open.
|
||||
if (!dismissible.value)
|
||||
if (!dismissible.value) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
armReason('outside-press');
|
||||
}
|
||||
|
||||
function handleEscapeKeyDown(event: KeyboardEvent) {
|
||||
if (!dismissible.value)
|
||||
if (!dismissible.value) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
armReason('escape-key');
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
@@ -88,8 +102,9 @@ function handlePointerMove(event: PointerEvent) {
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (hasSnapPoints.value) {
|
||||
globalThis.requestAnimationFrame(() => {
|
||||
// `flush: 'pre'` effects run during SSR, where rAF doesn't exist.
|
||||
if (hasSnapPoints.value && isClient) {
|
||||
requestAnimationFrame(() => {
|
||||
delayedSnapPoints.value = true;
|
||||
});
|
||||
}
|
||||
@@ -103,10 +118,13 @@ watchEffect(() => {
|
||||
:data-drawer-direction="direction"
|
||||
:data-drawer-delayed-snap-points="delayedSnapPoints ? 'true' : 'false'"
|
||||
:data-drawer-snap-points="isOpen && hasSnapPoints ? 'true' : 'false'"
|
||||
:data-swiping="isAllowedToDrag ? 'true' : undefined"
|
||||
:style="{ '--snap-point-height': snapPointHeight }"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="onRelease"
|
||||
@pointercancel="onCancel"
|
||||
@lostpointercapture="onCancel"
|
||||
@open-auto-focus.prevent
|
||||
@pointer-down-outside="handlePointerDownOutside"
|
||||
@escape-key-down="handleEscapeKeyDown"
|
||||
|
||||
@@ -11,7 +11,8 @@ export type { DrawerHandleProps } from './controls';
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, useTemplateRef, watchPostEffect } from 'vue';
|
||||
import { onScopeDispose, useTemplateRef, watch, watchPostEffect } from 'vue';
|
||||
import { onLongPress, useStateMachine } from '@robonen/vue';
|
||||
import { injectDrawerRootContext } from './context';
|
||||
|
||||
const { preventCycle = false } = defineProps<DrawerHandleProps>();
|
||||
@@ -19,7 +20,7 @@ const { preventCycle = false } = defineProps<DrawerHandleProps>();
|
||||
const LONG_HANDLE_PRESS_TIMEOUT = 250;
|
||||
const DOUBLE_TAP_TIMEOUT = 120;
|
||||
|
||||
const { onPress, onDrag, handleRef, handleOnly, isOpen, snapPoints, activeSnapPoint, isDragging, dismissible, closeDrawer }
|
||||
const { onPress, onDrag, onCancel, handleRef, handleOnly, isOpen, snapPoints, activeSnapPoint, isDragging, isAllowedToDrag, dismissible, closeDrawer }
|
||||
= injectDrawerRootContext();
|
||||
|
||||
// Mirror the element into the shared context ref. A local template ref + watch
|
||||
@@ -31,33 +32,67 @@ watchPostEffect(() => {
|
||||
handleRef.value = handleElement.value;
|
||||
});
|
||||
|
||||
const closeTimeoutId = ref<number | null>(null);
|
||||
const shouldCancelInteraction = ref(false);
|
||||
let cycleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function handleStartCycle() {
|
||||
// Ignore the second tap of a double-tap.
|
||||
if (shouldCancelInteraction.value) {
|
||||
handleCancelInteraction();
|
||||
return;
|
||||
}
|
||||
// Tap-to-cycle as an explicit machine: a tap schedules the cycle after the
|
||||
// double-tap window, a long hold suppresses it, and a second press inside the
|
||||
// window cancels the pending cycle — so a double-tap cycles once, never twice.
|
||||
const tap = useStateMachine({
|
||||
initial: 'idle',
|
||||
states: {
|
||||
idle: { on: { PRESS: 'pressed', TAP: 'tapPending' } },
|
||||
pressed: { on: { LONG_PRESS: 'suppressed', DRAG: 'suppressed', TAP: 'tapPending', CANCEL: 'idle' } },
|
||||
suppressed: { on: { TAP: 'idle', PRESS: 'pressed', CANCEL: 'idle' } },
|
||||
tapPending: {
|
||||
entry: () => {
|
||||
cycleTimer = setTimeout(fireCycleElapsed, DOUBLE_TAP_TIMEOUT);
|
||||
},
|
||||
exit: () => clearTimeout(cycleTimer),
|
||||
on: {
|
||||
ELAPSED: { target: 'idle', action: cycleSnapPoints },
|
||||
PRESS: 'pressed',
|
||||
// A long-press timer armed before the release can still outrace the
|
||||
// pending cycle — treat it as suppression, like the release-time flag
|
||||
// check of the pre-machine code did.
|
||||
LONG_PRESS: 'suppressed',
|
||||
DRAG: 'suppressed',
|
||||
CANCEL: 'idle',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
globalThis.setTimeout(() => {
|
||||
handleCycleSnapPoints();
|
||||
}, DOUBLE_TAP_TIMEOUT);
|
||||
// The exit hook covers every transition; this covers unmount mid-window.
|
||||
onScopeDispose(() => clearTimeout(cycleTimer));
|
||||
|
||||
// A gesture that actually engaged the drawer must never read as a tap: pointer
|
||||
// capture keeps the release's click on the handle, and 120ms later the drag is
|
||||
// long over (isDragging is false again), so only a latch armed DURING the
|
||||
// press can tell a short drag apart from a tap.
|
||||
watch(isAllowedToDrag, (dragging) => {
|
||||
if (dragging)
|
||||
tap.send('DRAG');
|
||||
});
|
||||
|
||||
// Annotated `: void` so the machine config can reference it without a type cycle.
|
||||
function fireCycleElapsed(): void {
|
||||
tap.send('ELAPSED');
|
||||
}
|
||||
|
||||
function handleCycleSnapPoints() {
|
||||
// Don't treat an accidental tap during a resize as a cycle.
|
||||
if (isDragging.value || preventCycle || shouldCancelInteraction.value) {
|
||||
handleCancelInteraction();
|
||||
return;
|
||||
}
|
||||
// A long hold suppresses the tap-to-cycle. `distanceThreshold: false` keeps the
|
||||
// original semantics: the hold counts even while the pointer drags the drawer.
|
||||
onLongPress(handleElement, () => {
|
||||
tap.send('LONG_PRESS');
|
||||
}, { delay: LONG_HANDLE_PRESS_TIMEOUT, distanceThreshold: false });
|
||||
|
||||
handleCancelInteraction();
|
||||
function cycleSnapPoints() {
|
||||
// Don't treat an accidental tap during a resize as a cycle.
|
||||
if (isDragging.value || preventCycle)
|
||||
return;
|
||||
|
||||
if (!snapPoints.value || snapPoints.value.length === 0) {
|
||||
if (!dismissible.value)
|
||||
closeDrawer();
|
||||
if (dismissible.value)
|
||||
closeDrawer('handle-press');
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -65,7 +100,7 @@ function handleCycleSnapPoints() {
|
||||
const isLastSnapPoint = activeSnapPoint.value === snapPoints.value[snapPoints.value.length - 1];
|
||||
|
||||
if (isLastSnapPoint && dismissible.value) {
|
||||
closeDrawer();
|
||||
closeDrawer('handle-press');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,30 +113,38 @@ function handleCycleSnapPoints() {
|
||||
activeSnapPoint.value = snapPoints.value[nextSnapPointIndex];
|
||||
}
|
||||
|
||||
function handleStartInteraction() {
|
||||
closeTimeoutId.value = globalThis.setTimeout(() => {
|
||||
// A long press cancels the tap-to-cycle.
|
||||
shouldCancelInteraction.value = true;
|
||||
}, LONG_HANDLE_PRESS_TIMEOUT);
|
||||
}
|
||||
|
||||
function handleCancelInteraction() {
|
||||
if (closeTimeoutId.value)
|
||||
globalThis.clearTimeout(closeTimeoutId.value);
|
||||
|
||||
shouldCancelInteraction.value = false;
|
||||
function handleClick() {
|
||||
tap.send('TAP');
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
tap.send('PRESS');
|
||||
|
||||
// In handleOnly mode the handle is the capture target so moves keep
|
||||
// arriving here even when the pointer leaves it.
|
||||
if (handleOnly.value)
|
||||
onPress(event);
|
||||
handleStartInteraction();
|
||||
onPress(event, handleElement.value ?? undefined);
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
if (handleOnly.value)
|
||||
onDrag(event);
|
||||
}
|
||||
|
||||
function handlePointerCancel(event: PointerEvent) {
|
||||
tap.send('CANCEL');
|
||||
|
||||
if (handleOnly.value)
|
||||
onCancel(event);
|
||||
}
|
||||
|
||||
// Fires after every normal release too (pointer capture sits on the pressed
|
||||
// element), so it must NOT cancel the tap intent — that would defeat the
|
||||
// long-press suppression. Only the drag engine cares, and it ignores stale calls.
|
||||
function handleLostPointerCapture(event: PointerEvent) {
|
||||
if (handleOnly.value)
|
||||
onCancel(event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -110,8 +153,9 @@ function handlePointerMove(event: PointerEvent) {
|
||||
:data-drawer-visible="isOpen ? 'true' : 'false'"
|
||||
data-drawer-handle
|
||||
aria-hidden="true"
|
||||
@click="handleStartCycle"
|
||||
@pointercancel="handleCancelInteraction"
|
||||
@click="handleClick"
|
||||
@pointercancel="handlePointerCancel"
|
||||
@lostpointercapture="handleLostPointerCapture"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
>
|
||||
|
||||
@@ -17,7 +17,7 @@ import { injectDrawerRootContext } from './context';
|
||||
|
||||
defineProps<DrawerOverlayProps>();
|
||||
|
||||
const { overlayRef, hasSnapPoints, isOpen, shouldFade } = injectDrawerRootContext();
|
||||
const { overlayRef, hasSnapPoints, isOpen, shouldFade, isAllowedToDrag } = injectDrawerRootContext();
|
||||
const { forwardRef, currentElement } = useForwardExpose();
|
||||
|
||||
watch(currentElement, (el) => {
|
||||
@@ -31,6 +31,7 @@ watch(currentElement, (el) => {
|
||||
data-drawer-overlay
|
||||
:data-drawer-snap-points="isOpen && hasSnapPoints ? 'true' : 'false'"
|
||||
:data-drawer-snap-points-overlay="isOpen && shouldFade ? 'true' : 'false'"
|
||||
:data-swiping="isAllowedToDrag ? 'true' : undefined"
|
||||
>
|
||||
<slot />
|
||||
</DialogOverlay>
|
||||
|
||||
@@ -18,12 +18,13 @@ export type { DrawerRootEmits, DrawerRootProps } from './controls';
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, toRefs, watch } from 'vue';
|
||||
import { useStyleTag } from '@robonen/vue';
|
||||
import { useEventListener, useStyleTag } from '@robonen/vue';
|
||||
import { isClient } from '@robonen/platform/multi';
|
||||
import { DialogRoot } from '../dialog';
|
||||
import { provideDrawerRootContext } from './context';
|
||||
import { useDrawer } from './controls';
|
||||
import { CLOSE_THRESHOLD, SCROLL_LOCK_TIMEOUT, TRANSITIONS } from './constants';
|
||||
import { DRAWER_STYLES, DRAWER_STYLE_ID } from './style';
|
||||
import { DRAWER_STYLES, DRAWER_STYLE_ID, registerDrawerCssProperties } from './style';
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
@@ -45,6 +46,7 @@ const props = withDefaults(defineProps<DrawerRootProps>(), {
|
||||
noBodyStyles: false,
|
||||
handleOnly: false,
|
||||
preventScrollRestoration: false,
|
||||
snapToSequentialPoints: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<DrawerRootEmits>();
|
||||
@@ -52,6 +54,9 @@ const emit = defineEmits<DrawerRootEmits>();
|
||||
// Inject the critical drawer CSS once (reference-counted across every drawer).
|
||||
useStyleTag(DRAWER_STYLES, { id: DRAWER_STYLE_ID });
|
||||
|
||||
if (isClient)
|
||||
registerDrawerCssProperties();
|
||||
|
||||
const fadeFromIndex = computed(() => props.fadeFromIndex ?? (props.snapPoints && props.snapPoints.length - 1));
|
||||
|
||||
// `isOpen` is the single source of truth for the open state. It's seeded from the
|
||||
@@ -64,14 +69,6 @@ watch(() => props.open, (value) => {
|
||||
isOpen.value = value;
|
||||
});
|
||||
|
||||
// Every change to `isOpen` (from any source) notifies the consumer's `v-model`
|
||||
// once and schedules `animationEnd`. Close-specific effects (`close`, snap reset)
|
||||
// live in the engine's own watch on the same ref.
|
||||
watch(isOpen, (o) => {
|
||||
emit('update:open', o);
|
||||
setTimeout(() => emit('animationEnd', o), TRANSITIONS.DURATION * 1000);
|
||||
});
|
||||
|
||||
const localActiveSnapPoint = ref<number | string | null | undefined>(
|
||||
props.activeSnapPoint ?? props.snapPoints?.[0] ?? null,
|
||||
);
|
||||
@@ -91,7 +88,7 @@ const emitHandlers = {
|
||||
emitClose: () => emit('close'),
|
||||
};
|
||||
|
||||
const { modal } = provideDrawerRootContext(
|
||||
const { modal, drawerRef, pendingReason, notifySettled, hasSnapPoints } = provideDrawerRootContext(
|
||||
useDrawer({
|
||||
...emitHandlers,
|
||||
...toRefs(props),
|
||||
@@ -101,6 +98,68 @@ const { modal } = provideDrawerRootContext(
|
||||
}),
|
||||
);
|
||||
|
||||
// `animationEnd` fires on the drawer element's own transitionend/animationend
|
||||
// (so dynamic settle durations and consumer-tuned animations report honestly),
|
||||
// with a fixed-duration timeout kept as an upper-bound fallback for
|
||||
// reduced-motion and animation-less environments. The listener rides the
|
||||
// reactive `drawerRef`, so it (re)attaches whenever the content (re)mounts;
|
||||
// `pendingAnimationEnd` gates it to the transition armed by the open flip.
|
||||
let pendingAnimationEnd: boolean | null = null;
|
||||
let animationEndTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function fireAnimationEnd() {
|
||||
if (pendingAnimationEnd === null)
|
||||
return;
|
||||
|
||||
const open = pendingAnimationEnd;
|
||||
|
||||
pendingAnimationEnd = null;
|
||||
clearTimeout(animationEndTimer);
|
||||
// Advance the engine's lifecycle phase first, so `animationEnd` observers see
|
||||
// the settled state (e.g. the snap point already reset after a close).
|
||||
notifySettled();
|
||||
emit('animationEnd', open);
|
||||
}
|
||||
|
||||
useEventListener(drawerRef, ['transitionend', 'animationend'], (event) => {
|
||||
// Only the drawer's own settle counts — ignore bubbled child transitions.
|
||||
if (event.target !== event.currentTarget)
|
||||
return;
|
||||
|
||||
if (event.type === 'transitionend') {
|
||||
// Transform transitions signal a settle only for snap-point drawers; the
|
||||
// keyframe-driven enter/exit of plain drawers also sees transform
|
||||
// transitions from other sources (a nested child writing to this element,
|
||||
// a drag settle) that must not consume an armed flip.
|
||||
if (!hasSnapPoints.value || (event as TransitionEvent).propertyName !== 'transform')
|
||||
return;
|
||||
}
|
||||
// Only the stylesheet's slide keyframes mark a settle; consumer keyframes on
|
||||
// the content fall through to the fallback timeout instead.
|
||||
else if (!(event as AnimationEvent).animationName.startsWith('slide')) {
|
||||
return;
|
||||
}
|
||||
|
||||
fireAnimationEnd();
|
||||
});
|
||||
|
||||
// Every change to `isOpen` (from any source) notifies the consumer's `v-model`
|
||||
// once — tagged with the reason armed by whichever part caused the flip — and
|
||||
// arms `animationEnd`. Close-specific effects (`close`, snap reset) live in the
|
||||
// engine's own watch on the same ref.
|
||||
watch(isOpen, (o, _prev, onCleanup) => {
|
||||
const reason = pendingReason.current;
|
||||
|
||||
pendingReason.current = undefined;
|
||||
emit('update:open', o, reason ? { reason } : undefined);
|
||||
|
||||
pendingAnimationEnd = o;
|
||||
animationEndTimer = setTimeout(fireAnimationEnd, TRANSITIONS.DURATION * 1000);
|
||||
// Runs before the next flip re-arms, and on unmount — the fallback never
|
||||
// outlives the transition it was armed for.
|
||||
onCleanup(() => clearTimeout(animationEndTimer));
|
||||
});
|
||||
|
||||
// The Dialog reports its own dismissals (trigger, close button, escape, outside
|
||||
// click) here; mirror them into `isOpen` and let the watchers do the rest.
|
||||
function handleOpenChange(o: boolean) {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<script setup lang="ts">
|
||||
import DrawerRoot from './DrawerRoot.vue';
|
||||
import type { DrawerRootEmits, DrawerRootProps } from './controls';
|
||||
import type { DrawerOpenChangeDetails } from './types';
|
||||
import { injectDrawerRootContext } from './context';
|
||||
|
||||
const props = defineProps<DrawerRootProps>();
|
||||
@@ -31,10 +32,10 @@ function onRelease(open: boolean) {
|
||||
emit('release', open);
|
||||
}
|
||||
|
||||
function onOpenChange(open: boolean) {
|
||||
function onOpenChange(open: boolean, details?: DrawerOpenChangeDetails) {
|
||||
if (open)
|
||||
onNestedOpenChange(open);
|
||||
emit('update:open', open);
|
||||
emit('update:open', open, details);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import type { DialogTriggerProps } from '../dialog';
|
||||
|
||||
/**
|
||||
* The button that toggles the drawer open. A thin wrapper over Dialog's Trigger
|
||||
* that tags the resulting `update:open` with the `trigger-press` reason.
|
||||
*/
|
||||
export interface DrawerTriggerProps extends DialogTriggerProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { DialogTrigger } from '../dialog';
|
||||
import { injectDrawerRootContext } from './context';
|
||||
|
||||
const props = defineProps<DrawerTriggerProps>();
|
||||
const { armReason } = injectDrawerRootContext();
|
||||
const { forwardRef } = useForwardExpose();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTrigger v-bind="props" :ref="forwardRef" @click="armReason('trigger-press')">
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
</template>
|
||||
@@ -2,6 +2,7 @@ import type { VueWrapper } from '@vue/test-utils';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||
import type { VNode } from 'vue';
|
||||
import {
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from '../index';
|
||||
import { DRAWER_STYLE_ID } from '../style';
|
||||
|
||||
const wrappers: Array<VueWrapper<any>> = [];
|
||||
|
||||
@@ -20,7 +22,7 @@ afterEach(() => {
|
||||
while (wrappers.length) wrappers.pop()!.unmount();
|
||||
document.body.innerHTML = '';
|
||||
document.body.removeAttribute('style');
|
||||
document.getElementById('robonen-drawer')?.remove();
|
||||
document.getElementById(DRAWER_STYLE_ID)?.remove();
|
||||
});
|
||||
|
||||
function track<T extends VueWrapper<any>>(w: T): T {
|
||||
@@ -35,6 +37,16 @@ async function flush(): Promise<void> {
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** Waits out the 500ms "no dragging during the open animation" guard. */
|
||||
async function openSettled(): Promise<void> {
|
||||
await flush();
|
||||
await sleep(600);
|
||||
}
|
||||
|
||||
function $<T extends Element = HTMLElement>(selector: string): T | null {
|
||||
return document.querySelector<T>(selector);
|
||||
}
|
||||
@@ -51,14 +63,52 @@ function $close(): HTMLButtonElement | undefined {
|
||||
return [...document.querySelectorAll('button')].find(b => b.textContent === 'Close');
|
||||
}
|
||||
|
||||
function pointer(el: Element, type: string, x: number, y: number) {
|
||||
el.dispatchEvent(new PointerEvent(type, {
|
||||
button: type === 'pointermove' ? -1 : 0,
|
||||
pointerId: 1,
|
||||
isPrimary: true,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick drag: ~10ms between moves keeps the velocity tracker's samples fresh,
|
||||
* so releasing right after reads as a fling.
|
||||
*/
|
||||
async function fastDrag(el: Element, points: Array<[number, number]>) {
|
||||
pointer(el, 'pointerdown', points[0]![0], points[0]![1]);
|
||||
|
||||
for (const [x, y] of points.slice(1)) {
|
||||
await sleep(10);
|
||||
pointer(el, 'pointermove', x, y);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drag, then pause past MAX_VELOCITY_AGE so the release velocity reads 0. */
|
||||
async function slowDrag(el: Element, points: Array<[number, number]>) {
|
||||
await fastDrag(el, points);
|
||||
await sleep(120);
|
||||
}
|
||||
|
||||
interface MountOptions {
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
modal?: boolean;
|
||||
dismissible?: boolean;
|
||||
direction?: 'top' | 'bottom' | 'left' | 'right';
|
||||
snapPoints?: Array<number | string>;
|
||||
handleOnly?: boolean;
|
||||
withHandle?: boolean;
|
||||
onUpdateOpen?: (v: boolean) => void;
|
||||
contentStyle?: Record<string, string>;
|
||||
extraContent?: () => VNode;
|
||||
onUpdateOpen?: (v: boolean, details?: { reason?: string }) => void;
|
||||
onUpdateActiveSnapPoint?: (v: number | string) => void;
|
||||
onRelease?: (open: boolean) => void;
|
||||
onAnimationEnd?: (open: boolean) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
@@ -75,7 +125,12 @@ function mountDrawer(options: MountOptions = {}) {
|
||||
modal: options.modal ?? true,
|
||||
dismissible: options.dismissible ?? true,
|
||||
direction: options.direction ?? 'bottom',
|
||||
snapPoints: options.snapPoints,
|
||||
handleOnly: options.handleOnly,
|
||||
'onUpdate:open': options.onUpdateOpen,
|
||||
'onUpdate:activeSnapPoint': options.onUpdateActiveSnapPoint,
|
||||
onRelease: options.onRelease,
|
||||
onAnimationEnd: options.onAnimationEnd,
|
||||
onClose: options.onClose,
|
||||
},
|
||||
{
|
||||
@@ -84,12 +139,13 @@ function mountDrawer(options: MountOptions = {}) {
|
||||
h(DrawerPortal, null, {
|
||||
default: () => [
|
||||
h(DrawerOverlay, { 'data-testid': 'overlay' }),
|
||||
h(DrawerContent, null, {
|
||||
h(DrawerContent, { style: { height: '200px', width: '200px', ...options.contentStyle } }, {
|
||||
default: () => [
|
||||
withHandle ? h(DrawerHandle) : null,
|
||||
h(DrawerTitle, null, { default: () => 'Title' }),
|
||||
h(DrawerDescription, null, { default: () => 'Desc' }),
|
||||
h(DrawerClose, null, { default: () => 'Close' }),
|
||||
options.extraContent ? options.extraContent() : null,
|
||||
],
|
||||
}),
|
||||
],
|
||||
@@ -113,7 +169,7 @@ describe('Drawer / markup', () => {
|
||||
it('injects the critical drawer stylesheet once', async () => {
|
||||
mountDrawer({ defaultOpen: true });
|
||||
await flush();
|
||||
const tags = document.querySelectorAll('#robonen-drawer');
|
||||
const tags = document.querySelectorAll(`#${DRAWER_STYLE_ID}`);
|
||||
expect(tags.length).toBe(1);
|
||||
expect(tags[0]!.textContent).toContain('@keyframes slideFromBottom');
|
||||
});
|
||||
@@ -152,13 +208,13 @@ describe('Drawer / open state', () => {
|
||||
expect($content()?.getAttribute('data-state') ?? 'closed').toBe('closed');
|
||||
});
|
||||
|
||||
it('emits update:open when the trigger is clicked (controlled)', async () => {
|
||||
it('emits update:open with a trigger-press reason (controlled)', async () => {
|
||||
const onUpdateOpen = vi.fn();
|
||||
mountDrawer({ open: false, onUpdateOpen });
|
||||
|
||||
$trigger().click();
|
||||
await flush();
|
||||
expect(onUpdateOpen).toHaveBeenCalledWith(true);
|
||||
expect(onUpdateOpen).toHaveBeenCalledWith(true, { reason: 'trigger-press' });
|
||||
});
|
||||
|
||||
it('emits close exactly once when dismissed via DrawerClose', async () => {
|
||||
@@ -175,6 +231,7 @@ describe('Drawer / open state', () => {
|
||||
// Regression: closing purely by setting the bound `open` prop to false (not
|
||||
// via a dialog dismissal) must still run the close side effects.
|
||||
const onClose = vi.fn();
|
||||
const onUpdateOpen = vi.fn();
|
||||
const state = ref(true);
|
||||
const Wrapper = defineComponent({
|
||||
setup() {
|
||||
@@ -182,7 +239,10 @@ describe('Drawer / open state', () => {
|
||||
DrawerRoot,
|
||||
{
|
||||
open: state.value,
|
||||
'onUpdate:open': (v: boolean) => { state.value = v; },
|
||||
'onUpdate:open': (v: boolean, details?: unknown) => {
|
||||
state.value = v;
|
||||
onUpdateOpen(v, details);
|
||||
},
|
||||
onClose,
|
||||
},
|
||||
{
|
||||
@@ -203,6 +263,8 @@ describe('Drawer / open state', () => {
|
||||
state.value = false;
|
||||
await flush();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
// A programmatic flip carries no reason.
|
||||
expect(onUpdateOpen).toHaveBeenCalledWith(false, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -219,3 +281,497 @@ describe('Drawer / overlay', () => {
|
||||
expect($('[data-drawer-overlay]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Drawer / dismiss reasons', () => {
|
||||
it('tags an Escape dismissal with escape-key', async () => {
|
||||
const onUpdateOpen = vi.fn();
|
||||
mountDrawer({ defaultOpen: true, onUpdateOpen });
|
||||
await flush();
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }));
|
||||
await flush();
|
||||
expect(onUpdateOpen).toHaveBeenCalledWith(false, { reason: 'escape-key' });
|
||||
});
|
||||
|
||||
it('tags a DrawerClose click with close-press', async () => {
|
||||
const onUpdateOpen = vi.fn();
|
||||
mountDrawer({ defaultOpen: true, onUpdateOpen });
|
||||
await flush();
|
||||
|
||||
$close()!.click();
|
||||
await flush();
|
||||
expect(onUpdateOpen).toHaveBeenCalledWith(false, { reason: 'close-press' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Drawer / handle', () => {
|
||||
it('closes a dismissible drawer without snap points on a handle tap', async () => {
|
||||
const onUpdateOpen = vi.fn();
|
||||
mountDrawer({ defaultOpen: true, onUpdateOpen });
|
||||
await flush();
|
||||
|
||||
$('[data-drawer-handle]')!.click();
|
||||
await sleep(250); // past the double-tap window
|
||||
await flush();
|
||||
expect(onUpdateOpen).toHaveBeenCalledWith(false, { reason: 'handle-press' });
|
||||
});
|
||||
|
||||
it('keeps a non-dismissible drawer open on a handle tap', async () => {
|
||||
// Regression: the condition used to be inverted — a handle tap closed
|
||||
// exactly the drawers that declared themselves non-dismissible.
|
||||
const onUpdateOpen = vi.fn();
|
||||
mountDrawer({ defaultOpen: true, dismissible: false, onUpdateOpen });
|
||||
await flush();
|
||||
|
||||
$('[data-drawer-handle]')!.click();
|
||||
await sleep(250);
|
||||
await flush();
|
||||
expect(onUpdateOpen).not.toHaveBeenCalled();
|
||||
expect($content()!.getAttribute('data-state')).toBe('open');
|
||||
});
|
||||
|
||||
it('cycles snap points on tap and reports the new active point', async () => {
|
||||
const onUpdateActiveSnapPoint = vi.fn();
|
||||
mountDrawer({ defaultOpen: true, snapPoints: [0.5, 1], onUpdateActiveSnapPoint });
|
||||
await flush();
|
||||
|
||||
$('[data-drawer-handle]')!.click();
|
||||
await sleep(250);
|
||||
await flush();
|
||||
expect(onUpdateActiveSnapPoint).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('cycles once on a double tap, not twice', async () => {
|
||||
const onUpdateActiveSnapPoint = vi.fn();
|
||||
mountDrawer({ defaultOpen: true, snapPoints: [0.3, 0.6, 1], onUpdateActiveSnapPoint });
|
||||
await flush();
|
||||
onUpdateActiveSnapPoint.mockClear();
|
||||
|
||||
const handle = $('[data-drawer-handle]')!;
|
||||
|
||||
// A full tap is pointerdown → pointerup → click. Both taps are dispatched
|
||||
// in the same synchronous block: no timer can fire in between, so the
|
||||
// second press deterministically lands inside the double-tap window and
|
||||
// must cancel the first pending cycle.
|
||||
pointer(handle, 'pointerdown', 100, 100);
|
||||
pointer(handle, 'pointerup', 100, 100);
|
||||
handle.click();
|
||||
pointer(handle, 'pointerdown', 100, 100);
|
||||
pointer(handle, 'pointerup', 100, 100);
|
||||
handle.click();
|
||||
|
||||
await sleep(300);
|
||||
await flush();
|
||||
|
||||
expect(onUpdateActiveSnapPoint).toHaveBeenCalledWith(0.6);
|
||||
expect(onUpdateActiveSnapPoint).not.toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('does not cycle when a short drag on the handle ends in a click', async () => {
|
||||
const onUpdateActiveSnapPoint = vi.fn();
|
||||
const onUpdateOpen = vi.fn();
|
||||
// Full-height content: fraction snap points assume the drawer can cover
|
||||
// the window, otherwise every release projects as "closer to closed".
|
||||
mountDrawer({ defaultOpen: true, snapPoints: [0.5, 1], contentStyle: { height: '100vh' }, onUpdateActiveSnapPoint, onUpdateOpen });
|
||||
await openSettled();
|
||||
onUpdateActiveSnapPoint.mockClear();
|
||||
|
||||
const handle = $('[data-drawer-handle]')!;
|
||||
|
||||
// A small real drag from the handle (upward, so a dismissible drawer at
|
||||
// its first snap point doesn't legitimately close), then the click the
|
||||
// browser dispatches after release — pointer capture keeps it on the
|
||||
// handle. The engaged drag must suppress the tap-to-cycle.
|
||||
await slowDrag(handle, [[100, 300], [100, 290], [100, 280]]);
|
||||
pointer(handle, 'pointerup', 100, 280);
|
||||
handle.click();
|
||||
|
||||
await sleep(300);
|
||||
await flush();
|
||||
|
||||
expect(onUpdateActiveSnapPoint).not.toHaveBeenCalledWith(1);
|
||||
expect(onUpdateOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fire a pending tap cycle after the handle unmounts', async () => {
|
||||
const showHandle = ref(true);
|
||||
const onUpdateActiveSnapPoint = vi.fn();
|
||||
|
||||
// The root must stay mounted (its emitter alive) while only the handle
|
||||
// unmounts — otherwise a leaked timer could never be observed.
|
||||
const Wrapper = defineComponent({
|
||||
setup() {
|
||||
return () => h(
|
||||
DrawerRoot,
|
||||
{ defaultOpen: true, snapPoints: [0.5, 1], 'onUpdate:activeSnapPoint': onUpdateActiveSnapPoint },
|
||||
{
|
||||
default: () => h(DrawerPortal, null, {
|
||||
default: () => h(DrawerContent, { style: { height: '200px' } }, {
|
||||
default: () => [
|
||||
showHandle.value ? h(DrawerHandle) : null,
|
||||
h(DrawerTitle, null, { default: () => 'Title' }),
|
||||
h(DrawerDescription, null, { default: () => 'Desc' }),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
track(mount(Wrapper, { attachTo: document.body }));
|
||||
await flush();
|
||||
onUpdateActiveSnapPoint.mockClear();
|
||||
|
||||
$('[data-drawer-handle]')!.click();
|
||||
showHandle.value = false; // unmount inside the 120ms window
|
||||
await flush();
|
||||
await sleep(250);
|
||||
|
||||
expect(onUpdateActiveSnapPoint).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Drawer / drag gesture', () => {
|
||||
it('closes on a swipe past the close threshold with a swipe reason', async () => {
|
||||
const onUpdateOpen = vi.fn();
|
||||
const onRelease = vi.fn();
|
||||
mountDrawer({ defaultOpen: true, onUpdateOpen, onRelease });
|
||||
await openSettled();
|
||||
|
||||
const content = $content()!;
|
||||
await slowDrag(content, [[100, 300], [100, 330], [100, 360], [100, 390]]);
|
||||
pointer(content, 'pointerup', 100, 390);
|
||||
await flush();
|
||||
|
||||
expect(onUpdateOpen).toHaveBeenCalledWith(false, { reason: 'swipe' });
|
||||
expect(onRelease).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('marks the content and overlay with data-swiping while dragging', async () => {
|
||||
mountDrawer({ defaultOpen: true });
|
||||
await openSettled();
|
||||
|
||||
const content = $content()!;
|
||||
// Small drag + pause: stays under both the distance and velocity
|
||||
// thresholds, so the drawer remains open after release.
|
||||
await slowDrag(content, [[100, 300], [100, 315], [100, 330]]);
|
||||
|
||||
expect(content.hasAttribute('data-swiping')).toBe(true);
|
||||
expect(content.classList.contains('drawer-dragging')).toBe(true);
|
||||
expect($('[data-drawer-overlay]')!.hasAttribute('data-swiping')).toBe(true);
|
||||
|
||||
pointer(content, 'pointerup', 100, 330);
|
||||
await flush();
|
||||
expect(content.getAttribute('data-state')).toBe('open');
|
||||
expect(content.hasAttribute('data-swiping')).toBe(false);
|
||||
});
|
||||
|
||||
it('settles back below the threshold when released without momentum', async () => {
|
||||
const onUpdateOpen = vi.fn();
|
||||
const onRelease = vi.fn();
|
||||
mountDrawer({ defaultOpen: true, onUpdateOpen, onRelease });
|
||||
await openSettled();
|
||||
|
||||
const content = $content()!;
|
||||
// 30px of a 200px drawer — under the 25% threshold; pause kills momentum.
|
||||
await slowDrag(content, [[100, 300], [100, 315], [100, 330]]);
|
||||
pointer(content, 'pointerup', 100, 330);
|
||||
await flush();
|
||||
|
||||
expect(onUpdateOpen).not.toHaveBeenCalled();
|
||||
expect(onRelease).toHaveBeenCalledWith(true);
|
||||
expect(content.style.transform).toBe('translate3d(0px, 0px, 0px)');
|
||||
});
|
||||
|
||||
it('does not close after the user reverses past the cancel threshold', async () => {
|
||||
// "Changed my mind": drag well past the close threshold, pull back, hold,
|
||||
// release — the drawer must stay open even though the release point alone
|
||||
// clears the distance threshold.
|
||||
const onUpdateOpen = vi.fn();
|
||||
mountDrawer({ defaultOpen: true, onUpdateOpen });
|
||||
await openSettled();
|
||||
|
||||
const content = $content()!;
|
||||
await slowDrag(content, [[100, 300], [100, 360], [100, 420], [100, 360]]);
|
||||
pointer(content, 'pointerup', 100, 360);
|
||||
await flush();
|
||||
|
||||
expect(onUpdateOpen).not.toHaveBeenCalled();
|
||||
expect(content.getAttribute('data-state')).toBe('open');
|
||||
});
|
||||
|
||||
it('recovers cleanly from pointercancel mid-drag', async () => {
|
||||
const onUpdateOpen = vi.fn();
|
||||
mountDrawer({ defaultOpen: true, onUpdateOpen });
|
||||
await openSettled();
|
||||
|
||||
const content = $content()!;
|
||||
await fastDrag(content, [[100, 300], [100, 330], [100, 360]]);
|
||||
expect(content.classList.contains('drawer-dragging')).toBe(true);
|
||||
|
||||
pointer(content, 'pointercancel', 100, 360);
|
||||
await flush();
|
||||
|
||||
expect(content.classList.contains('drawer-dragging')).toBe(false);
|
||||
expect(content.hasAttribute('data-swiping')).toBe(false);
|
||||
expect(onUpdateOpen).not.toHaveBeenCalled();
|
||||
expect(content.style.transform).toBe('translate3d(0px, 0px, 0px)');
|
||||
|
||||
// The next gesture still works.
|
||||
await slowDrag(content, [[100, 300], [100, 340], [100, 380], [100, 420]]);
|
||||
pointer(content, 'pointerup', 100, 420);
|
||||
await flush();
|
||||
expect(onUpdateOpen).toHaveBeenCalledWith(false, { reason: 'swipe' });
|
||||
});
|
||||
|
||||
it('ignores a cross-axis gesture (axis lock)', async () => {
|
||||
const onUpdateOpen = vi.fn();
|
||||
mountDrawer({ defaultOpen: true, onUpdateOpen });
|
||||
await openSettled();
|
||||
|
||||
const content = $content()!;
|
||||
// Mostly-horizontal movement on a bottom drawer must never latch a drag.
|
||||
await fastDrag(content, [[100, 300], [140, 305], [180, 310], [220, 315]]);
|
||||
pointer(content, 'pointerup', 220, 315);
|
||||
await flush();
|
||||
|
||||
expect(content.classList.contains('drawer-dragging')).toBe(false);
|
||||
expect(onUpdateOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scales the close-out animation with the fling velocity', async () => {
|
||||
const onUpdateOpen = vi.fn();
|
||||
const onAnimationEnd = vi.fn();
|
||||
mountDrawer({ defaultOpen: true, onUpdateOpen, onAnimationEnd });
|
||||
await openSettled();
|
||||
|
||||
const content = $content()!;
|
||||
// Rapid successive moves keep the instantaneous velocity high.
|
||||
await fastDrag(content, [[100, 300], [100, 330], [100, 360], [100, 390]]);
|
||||
pointer(content, 'pointerup', 100, 390);
|
||||
await flush();
|
||||
|
||||
expect(onUpdateOpen).toHaveBeenCalledWith(false, { reason: 'swipe' });
|
||||
// The inline duration overrides the stylesheet's 0.5s for this close only.
|
||||
expect(content.style.animationDuration).not.toBe('');
|
||||
expect(Number.parseFloat(content.style.animationDuration)).toBeLessThan(0.5);
|
||||
|
||||
// animationEnd follows the (scaled) animation, via the real animationend.
|
||||
await vi.waitFor(() => expect(onAnimationEnd).toHaveBeenCalledWith(false), { timeout: 1000 });
|
||||
});
|
||||
|
||||
it('keeps the default close duration for a slow release past the threshold', async () => {
|
||||
const onUpdateOpen = vi.fn();
|
||||
mountDrawer({ defaultOpen: true, onUpdateOpen });
|
||||
await openSettled();
|
||||
|
||||
const content = $content()!;
|
||||
await slowDrag(content, [[100, 300], [100, 330], [100, 360], [100, 390]]);
|
||||
pointer(content, 'pointerup', 100, 390);
|
||||
await flush();
|
||||
|
||||
expect(onUpdateOpen).toHaveBeenCalledWith(false, { reason: 'swipe' });
|
||||
expect(content.style.animationDuration).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Drawer / pointer capture', () => {
|
||||
it('captures the pointer on the pressed element, not the drawer content', async () => {
|
||||
mountDrawer({
|
||||
defaultOpen: true,
|
||||
extraContent: () => h('button', { 'data-testid': 'inner' }, 'Inner'),
|
||||
});
|
||||
await flush();
|
||||
|
||||
const content = $content()!;
|
||||
const button = $<HTMLButtonElement>('[data-testid="inner"]')!;
|
||||
const captured: Element[] = [];
|
||||
|
||||
for (const el of [content, button])
|
||||
(el as any).setPointerCapture = () => captured.push(el);
|
||||
|
||||
pointer(button, 'pointerdown', 100, 300);
|
||||
|
||||
// Capturing on the drawer would retarget the compat mouse events, so the
|
||||
// button would never receive `click` — the capture must land on the button.
|
||||
expect(captured).toEqual([button]);
|
||||
|
||||
pointer(button, 'pointerup', 100, 300);
|
||||
await flush();
|
||||
expect(content.getAttribute('data-state')).toBe('open');
|
||||
});
|
||||
|
||||
it('captures on the handle for handleOnly gestures', async () => {
|
||||
mountDrawer({ defaultOpen: true, handleOnly: true });
|
||||
await flush();
|
||||
|
||||
const content = $content()!;
|
||||
const handle = $('[data-drawer-handle]')!;
|
||||
const hitarea = $('[data-drawer-handle-hitarea]')!;
|
||||
const captured: Element[] = [];
|
||||
|
||||
for (const el of [content, handle, hitarea])
|
||||
(el as any).setPointerCapture = () => captured.push(el);
|
||||
|
||||
pointer(hitarea, 'pointerdown', 100, 300);
|
||||
|
||||
expect(captured).toEqual([handle]);
|
||||
|
||||
pointer(hitarea, 'pointerup', 100, 300);
|
||||
await flush();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Drawer / lifecycle machine', () => {
|
||||
it('resets the active snap point only when a close actually settles', async () => {
|
||||
const open = ref(true);
|
||||
const active = ref<number | string | null | undefined>(0.9);
|
||||
|
||||
const Wrapper = defineComponent({
|
||||
setup() {
|
||||
return () => h(
|
||||
DrawerRoot,
|
||||
{
|
||||
open: open.value,
|
||||
snapPoints: [0.4, 0.9],
|
||||
activeSnapPoint: active.value,
|
||||
'onUpdate:open': (v: boolean) => { open.value = v; },
|
||||
'onUpdate:activeSnapPoint': (v: number | string) => { active.value = v; },
|
||||
},
|
||||
{
|
||||
default: () => h(DrawerPortal, null, {
|
||||
default: () => h(DrawerContent, { style: { height: '200px' } }, {
|
||||
default: () => [
|
||||
h(DrawerTitle, null, { default: () => 'Title' }),
|
||||
h(DrawerDescription, null, { default: () => 'Desc' }),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
track(mount(Wrapper, { attachTo: document.body }));
|
||||
await flush();
|
||||
|
||||
// Close, then reopen before the exit settles: the pending close cleanup
|
||||
// must NOT fire on the now-live drawer (the old fixed 500ms timeout did).
|
||||
open.value = false;
|
||||
await flush();
|
||||
await sleep(60);
|
||||
open.value = true;
|
||||
await flush();
|
||||
await sleep(700);
|
||||
|
||||
expect(active.value).toBe(0.9);
|
||||
|
||||
// A close that actually settles still resets to the first snap point.
|
||||
open.value = false;
|
||||
await flush();
|
||||
await sleep(700);
|
||||
|
||||
expect(active.value).toBe(0.4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Drawer / scroll containers', () => {
|
||||
function scrollerContent(direction: 'vertical' | 'horizontal') {
|
||||
return () => h(
|
||||
'div',
|
||||
{
|
||||
'data-testid': 'scroller',
|
||||
style: direction === 'vertical'
|
||||
? 'height: 100px; overflow-y: auto;'
|
||||
: 'width: 100px; overflow-x: auto;',
|
||||
},
|
||||
[h('div', {
|
||||
style: direction === 'vertical' ? 'height: 400px;' : 'width: 400px; height: 20px;',
|
||||
}, [h('span', { 'data-testid': 'leaf' }, 'content')])],
|
||||
);
|
||||
}
|
||||
|
||||
it('lets a mid-scroll container own the gesture (vertical)', async () => {
|
||||
const onUpdateOpen = vi.fn();
|
||||
mountDrawer({ defaultOpen: true, onUpdateOpen, extraContent: scrollerContent('vertical') });
|
||||
await openSettled();
|
||||
|
||||
const content = $content()!;
|
||||
const scroller = $('[data-testid="scroller"]')!;
|
||||
const leaf = $('[data-testid="leaf"]')!;
|
||||
|
||||
scroller.scrollTop = 50;
|
||||
await slowDrag(leaf, [[100, 300], [100, 340], [100, 380], [100, 420]]);
|
||||
pointer(leaf, 'pointerup', 100, 420);
|
||||
await flush();
|
||||
expect(content.classList.contains('drawer-dragging')).toBe(false);
|
||||
expect(onUpdateOpen).not.toHaveBeenCalled();
|
||||
|
||||
// At the top edge the same gesture is a dismiss.
|
||||
scroller.scrollTop = 0;
|
||||
await sleep(150); // clear the scroll-lock timeout
|
||||
await slowDrag(leaf, [[100, 300], [100, 340], [100, 380], [100, 420]]);
|
||||
pointer(leaf, 'pointerup', 100, 420);
|
||||
await flush();
|
||||
expect(onUpdateOpen).toHaveBeenCalledWith(false, { reason: 'swipe' });
|
||||
});
|
||||
|
||||
it('respects horizontal scroll containers in side drawers', async () => {
|
||||
// Regression: left/right drawers used to skip every shouldDrag check.
|
||||
const onUpdateOpen = vi.fn();
|
||||
mountDrawer({
|
||||
defaultOpen: true,
|
||||
direction: 'right',
|
||||
onUpdateOpen,
|
||||
extraContent: scrollerContent('horizontal'),
|
||||
});
|
||||
await openSettled();
|
||||
|
||||
const content = $content()!;
|
||||
const scroller = $('[data-testid="scroller"]')!;
|
||||
const leaf = $('[data-testid="leaf"]')!;
|
||||
|
||||
scroller.scrollLeft = 50;
|
||||
await slowDrag(leaf, [[100, 300], [140, 300], [180, 300], [220, 300]]);
|
||||
pointer(leaf, 'pointerup', 220, 300);
|
||||
await flush();
|
||||
expect(content.classList.contains('drawer-dragging')).toBe(false);
|
||||
expect(onUpdateOpen).not.toHaveBeenCalled();
|
||||
|
||||
scroller.scrollLeft = 0;
|
||||
await sleep(150);
|
||||
await slowDrag(leaf, [[100, 300], [140, 300], [180, 300], [220, 300]]);
|
||||
pointer(leaf, 'pointerup', 220, 300);
|
||||
await flush();
|
||||
expect(onUpdateOpen).toHaveBeenCalledWith(false, { reason: 'swipe' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Drawer / snap points', () => {
|
||||
it('positions the drawer at the first snap point and exposes the offsets', async () => {
|
||||
mountDrawer({ defaultOpen: true, snapPoints: [0.5, 1] });
|
||||
await flush();
|
||||
|
||||
const content = $content()!;
|
||||
expect(content.getAttribute('data-drawer-snap-points')).toBe('true');
|
||||
|
||||
const expected = Math.round(window.innerHeight - window.innerHeight * 0.5);
|
||||
await vi.waitFor(() => {
|
||||
expect(content.style.transform).toBe(`translate3d(0px, ${expected}px, 0px)`);
|
||||
});
|
||||
expect(content.style.getPropertyValue('--snap-point-height')).toBe(`${expected}px`);
|
||||
});
|
||||
|
||||
it('resolves px and rem snap points', async () => {
|
||||
mountDrawer({ defaultOpen: true, snapPoints: ['10rem', '500px'] });
|
||||
await flush();
|
||||
|
||||
const content = $content()!;
|
||||
const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||
const expected = Math.round(window.innerHeight - 10 * rem);
|
||||
await vi.waitFor(() => {
|
||||
expect(content.style.transform).toBe(`translate3d(0px, ${expected}px, 0px)`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,74 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import axe from 'axe-core';
|
||||
import { defineComponent, h, nextTick } from 'vue';
|
||||
import {
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHandle,
|
||||
DrawerOverlay,
|
||||
DrawerPortal,
|
||||
DrawerRoot,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from '../index';
|
||||
|
||||
async function violations(element: Element) {
|
||||
const results = await axe.run(element);
|
||||
return results.violations;
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
function drawerFixture(defaultOpen: boolean) {
|
||||
return defineComponent({
|
||||
setup() {
|
||||
return () => h(DrawerRoot, { defaultOpen }, {
|
||||
default: () => [
|
||||
h(DrawerTrigger, null, { default: () => 'Open drawer' }),
|
||||
h(DrawerPortal, null, {
|
||||
default: () => [
|
||||
h(DrawerOverlay),
|
||||
h(DrawerContent, null, {
|
||||
default: () => [
|
||||
h(DrawerHandle),
|
||||
h(DrawerTitle, null, { default: () => 'Drawer title' }),
|
||||
h(DrawerDescription, null, { default: () => 'Drawer description' }),
|
||||
h(DrawerClose, null, { default: () => 'Close' }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('Drawer a11y', () => {
|
||||
let wrapper: ReturnType<typeof mount> | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
wrapper = undefined;
|
||||
document.body.innerHTML = '';
|
||||
document.body.removeAttribute('style');
|
||||
});
|
||||
|
||||
it('has no axe violations when closed', async () => {
|
||||
wrapper = mount(drawerFixture(false), { attachTo: document.body });
|
||||
await flush();
|
||||
expect(await violations(document.body)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('has no axe violations when open', async () => {
|
||||
wrapper = mount(drawerFixture(true), { attachTo: document.body });
|
||||
await flush();
|
||||
expect(await violations(document.body)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
computeSettleDuration,
|
||||
createReverseCancelTracker,
|
||||
createVelocityTracker,
|
||||
findScrollableAncestor,
|
||||
isAtScrollEdge,
|
||||
} from '../gesture';
|
||||
import { MAX_VELOCITY_AGE, MIN_SETTLE_DURATION, MIN_VELOCITY_DT, TRANSITIONS } from '../constants';
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('createVelocityTracker', () => {
|
||||
it('computes instantaneous velocity from the trailing pair of samples', () => {
|
||||
const tracker = createVelocityTracker();
|
||||
|
||||
tracker.add(0, 0);
|
||||
tracker.add(10, 20); // 0.5 px/ms — but superseded below
|
||||
tracker.add(50, 40); // (50-10)/20 = 2 px/ms
|
||||
|
||||
expect(tracker.read(45)).toBe(2);
|
||||
});
|
||||
|
||||
it('reads 0 when the pointer paused before release', () => {
|
||||
const tracker = createVelocityTracker();
|
||||
|
||||
tracker.add(0, 0);
|
||||
tracker.add(100, 20);
|
||||
|
||||
expect(tracker.read(20 + MAX_VELOCITY_AGE + 1)).toBe(0);
|
||||
});
|
||||
|
||||
it('clamps tiny sample intervals so same-frame bursts do not spike', () => {
|
||||
const tracker = createVelocityTracker();
|
||||
|
||||
tracker.add(0, 0);
|
||||
tracker.add(32, 1); // dt clamped 1 → MIN_VELOCITY_DT
|
||||
|
||||
expect(tracker.read(2)).toBe(32 / MIN_VELOCITY_DT);
|
||||
});
|
||||
|
||||
it('reads 0 before two samples exist', () => {
|
||||
const tracker = createVelocityTracker();
|
||||
|
||||
expect(tracker.read(0)).toBe(0);
|
||||
tracker.add(10, 0);
|
||||
expect(tracker.read(1)).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores out-of-order samples', () => {
|
||||
const tracker = createVelocityTracker();
|
||||
|
||||
tracker.add(0, 100);
|
||||
tracker.add(50, 120);
|
||||
tracker.add(999, 90); // stale timestamp — must not produce a velocity
|
||||
|
||||
expect(tracker.read(121)).toBe(50 / 20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createReverseCancelTracker', () => {
|
||||
it('cancels once an armed gesture pulls back past the threshold', () => {
|
||||
const tracker = createReverseCancelTracker();
|
||||
|
||||
tracker.update(60); // armed (> 20)
|
||||
expect(tracker.cancelled).toBe(false);
|
||||
|
||||
tracker.update(45); // pulled back 15 > 10
|
||||
expect(tracker.cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it('does not cancel before the arm distance', () => {
|
||||
const tracker = createReverseCancelTracker();
|
||||
|
||||
tracker.update(15);
|
||||
tracker.update(0); // pulled back 15, but max never armed
|
||||
|
||||
expect(tracker.cancelled).toBe(false);
|
||||
});
|
||||
|
||||
it('tolerates jitter below the reverse threshold', () => {
|
||||
const tracker = createReverseCancelTracker();
|
||||
|
||||
tracker.update(80);
|
||||
tracker.update(72); // only 8 back
|
||||
|
||||
expect(tracker.cancelled).toBe(false);
|
||||
});
|
||||
|
||||
it('re-arms when the drag surpasses its previous furthest point', () => {
|
||||
const tracker = createReverseCancelTracker();
|
||||
|
||||
tracker.update(60);
|
||||
tracker.update(40);
|
||||
expect(tracker.cancelled).toBe(true);
|
||||
|
||||
tracker.update(70); // renewed intent
|
||||
expect(tracker.cancelled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeSettleDuration', () => {
|
||||
it('keeps the default duration for slow releases', () => {
|
||||
expect(computeSettleDuration(300, 0.1)).toBe(TRANSITIONS.DURATION);
|
||||
expect(computeSettleDuration(300, 0)).toBe(TRANSITIONS.DURATION);
|
||||
});
|
||||
|
||||
it('scales the duration down with a hard flick', () => {
|
||||
// 100px left at 2px/ms → 50ms, clamped up to the minimum.
|
||||
expect(computeSettleDuration(100, 2)).toBe(MIN_SETTLE_DURATION / 1000);
|
||||
// 400px left at 1px/ms → 400ms.
|
||||
expect(computeSettleDuration(400, 1)).toBe(0.4);
|
||||
});
|
||||
|
||||
it('never exceeds the default duration', () => {
|
||||
expect(computeSettleDuration(10_000, 0.5)).toBe(TRANSITIONS.DURATION);
|
||||
});
|
||||
|
||||
it('falls back on degenerate distances', () => {
|
||||
expect(computeSettleDuration(0, 3)).toBe(TRANSITIONS.DURATION);
|
||||
expect(computeSettleDuration(Number.NaN, 3)).toBe(TRANSITIONS.DURATION);
|
||||
});
|
||||
});
|
||||
|
||||
function scrollableFixture() {
|
||||
document.body.innerHTML = `
|
||||
<div id="drawer" style="height: 200px;">
|
||||
<div id="scroller" style="height: 100px; width: 100px; overflow: auto;">
|
||||
<div id="inner" style="height: 400px; width: 400px;">
|
||||
<span id="leaf">content</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return {
|
||||
drawer: document.getElementById('drawer')! as HTMLElement,
|
||||
scroller: document.getElementById('scroller')! as HTMLElement,
|
||||
leaf: document.getElementById('leaf')! as HTMLElement,
|
||||
};
|
||||
}
|
||||
|
||||
describe('findScrollableAncestor', () => {
|
||||
it('finds the nearest scrollable ancestor along the axis', () => {
|
||||
const { drawer, scroller, leaf } = scrollableFixture();
|
||||
|
||||
expect(findScrollableAncestor(leaf, drawer, 'y')).toBe(scroller);
|
||||
expect(findScrollableAncestor(leaf, drawer, 'x')).toBe(scroller);
|
||||
});
|
||||
|
||||
it('returns null when nothing scrolls', () => {
|
||||
const { drawer } = scrollableFixture();
|
||||
|
||||
expect(findScrollableAncestor(drawer, drawer, 'y')).toBeNull();
|
||||
});
|
||||
|
||||
it('stops at the boundary', () => {
|
||||
const { scroller, leaf } = scrollableFixture();
|
||||
const inner = document.getElementById('inner')! as HTMLElement;
|
||||
|
||||
// Boundary below the scroller — the walk must not escape it.
|
||||
expect(findScrollableAncestor(leaf, inner, 'y')).toBeNull();
|
||||
void scroller;
|
||||
});
|
||||
|
||||
it('ignores overflow visible/hidden containers', () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="drawer">
|
||||
<div id="clipped" style="height: 50px; overflow: hidden;">
|
||||
<div style="height: 300px;"><span id="leaf">x</span></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const drawer = document.getElementById('drawer')! as HTMLElement;
|
||||
const leaf = document.getElementById('leaf')! as HTMLElement;
|
||||
|
||||
expect(findScrollableAncestor(leaf, drawer, 'y')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAtScrollEdge', () => {
|
||||
it('bottom drawer requires the scroller at its top', () => {
|
||||
const { scroller } = scrollableFixture();
|
||||
|
||||
scroller.scrollTop = 0;
|
||||
expect(isAtScrollEdge(scroller, 'bottom')).toBe(true);
|
||||
|
||||
scroller.scrollTop = 50;
|
||||
expect(isAtScrollEdge(scroller, 'bottom')).toBe(false);
|
||||
});
|
||||
|
||||
it('top drawer requires the scroller at its bottom', () => {
|
||||
const { scroller } = scrollableFixture();
|
||||
|
||||
scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight;
|
||||
expect(isAtScrollEdge(scroller, 'top')).toBe(true);
|
||||
|
||||
scroller.scrollTop = 0;
|
||||
expect(isAtScrollEdge(scroller, 'top')).toBe(false);
|
||||
});
|
||||
|
||||
it('right drawer requires the scroller at its left edge', () => {
|
||||
const { scroller } = scrollableFixture();
|
||||
|
||||
scroller.scrollLeft = 0;
|
||||
expect(isAtScrollEdge(scroller, 'right')).toBe(true);
|
||||
|
||||
scroller.scrollLeft = 40;
|
||||
expect(isAtScrollEdge(scroller, 'right')).toBe(false);
|
||||
});
|
||||
|
||||
it('left drawer requires the scroller at its right edge', () => {
|
||||
const { scroller } = scrollableFixture();
|
||||
|
||||
scroller.scrollLeft = scroller.scrollWidth - scroller.clientWidth;
|
||||
expect(isAtScrollEdge(scroller, 'left')).toBe(true);
|
||||
|
||||
scroller.scrollLeft = 0;
|
||||
expect(isAtScrollEdge(scroller, 'left')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
findSnapPointIndex,
|
||||
projectSnapRelease,
|
||||
resolveSnapPointOffset,
|
||||
resolveSnapPointSize,
|
||||
} from '../snapping';
|
||||
|
||||
const WINDOW = 800;
|
||||
const REM = 16;
|
||||
|
||||
describe('resolveSnapPointSize', () => {
|
||||
it('treats numbers in (0, 1] as window fractions', () => {
|
||||
expect(resolveSnapPointSize(0.5, WINDOW, REM)).toBe(400);
|
||||
expect(resolveSnapPointSize(1, WINDOW, REM)).toBe(WINDOW);
|
||||
});
|
||||
|
||||
it('treats numbers above 1 as pixels', () => {
|
||||
expect(resolveSnapPointSize(620, WINDOW, REM)).toBe(620);
|
||||
});
|
||||
|
||||
it('parses px strings', () => {
|
||||
expect(resolveSnapPointSize('148px', WINDOW, REM)).toBe(148);
|
||||
expect(resolveSnapPointSize('148.6px', WINDOW, REM)).toBe(149);
|
||||
});
|
||||
|
||||
it('parses rem strings against the root font size', () => {
|
||||
expect(resolveSnapPointSize('30rem', WINDOW, REM)).toBe(480);
|
||||
expect(resolveSnapPointSize('30rem', WINDOW, 20)).toBe(600);
|
||||
});
|
||||
|
||||
it('rejects unknown units and degenerate values', () => {
|
||||
expect(resolveSnapPointSize('50%', WINDOW, REM)).toBeNull();
|
||||
expect(resolveSnapPointSize('10vh', WINDOW, REM)).toBeNull();
|
||||
expect(resolveSnapPointSize('abc', WINDOW, REM)).toBeNull();
|
||||
expect(resolveSnapPointSize('-10px', WINDOW, REM)).toBeNull();
|
||||
expect(resolveSnapPointSize(0, WINDOW, REM)).toBeNull();
|
||||
expect(resolveSnapPointSize(-0.5, WINDOW, REM)).toBeNull();
|
||||
expect(resolveSnapPointSize(Number.NaN, WINDOW, REM)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSnapPointOffset', () => {
|
||||
it('signs the translate toward the anchored edge', () => {
|
||||
expect(resolveSnapPointOffset(0.25, 'bottom', WINDOW, REM)).toBe(600);
|
||||
expect(resolveSnapPointOffset(0.25, 'right', WINDOW, REM)).toBe(600);
|
||||
expect(resolveSnapPointOffset(0.25, 'top', WINDOW, REM)).toBe(-600);
|
||||
expect(resolveSnapPointOffset(0.25, 'left', WINDOW, REM)).toBe(-600);
|
||||
});
|
||||
|
||||
it('clamps oversized snap points at fully open', () => {
|
||||
expect(resolveSnapPointOffset(1200, 'bottom', WINDOW, REM)).toBe(0);
|
||||
});
|
||||
|
||||
it('maps invalid points to NaN', () => {
|
||||
expect(resolveSnapPointOffset('50%', 'bottom', WINDOW, REM)).toBeNaN();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSnapPointIndex', () => {
|
||||
const points = [0.25, '400px', '30rem'];
|
||||
|
||||
it('matches by identity first', () => {
|
||||
expect(findSnapPointIndex(points, '400px', WINDOW, REM)).toBe(1);
|
||||
});
|
||||
|
||||
it('matches equivalent representations by resolved size', () => {
|
||||
expect(findSnapPointIndex(points, 0.5, WINDOW, REM)).toBe(1); // 0.5 * 800 = 400px
|
||||
expect(findSnapPointIndex(points, 480, WINDOW, REM)).toBe(2); // 30rem = 480px
|
||||
});
|
||||
|
||||
it('returns null when nothing matches', () => {
|
||||
expect(findSnapPointIndex(points, 0.9, WINDOW, REM)).toBeNull();
|
||||
expect(findSnapPointIndex(points, null, WINDOW, REM)).toBeNull();
|
||||
expect(findSnapPointIndex(points, undefined, WINDOW, REM)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('projectSnapRelease', () => {
|
||||
// Dismiss-positive space on an 800px-tall drawer: fully open = 0, closed = 800.
|
||||
const base = {
|
||||
offsets: [600, 400, 0], // least → most visible
|
||||
drawerSize: 800,
|
||||
dismissible: true,
|
||||
sequential: false,
|
||||
};
|
||||
|
||||
it('snaps to the point nearest the drag target when slow', () => {
|
||||
// From 600, dragged 180 toward open → 420 → nearest is 400.
|
||||
expect(projectSnapRelease({
|
||||
...base,
|
||||
activeIndex: 0,
|
||||
draggedDistance: 180,
|
||||
velocity: 0,
|
||||
})).toEqual({ type: 'snap', index: 1 });
|
||||
});
|
||||
|
||||
it('stays on the active point after a tiny slow drag', () => {
|
||||
expect(projectSnapRelease({
|
||||
...base,
|
||||
activeIndex: 0,
|
||||
draggedDistance: 40,
|
||||
velocity: 0,
|
||||
})).toEqual({ type: 'snap', index: 0 });
|
||||
});
|
||||
|
||||
it('projects a fling across points the drag alone would not reach', () => {
|
||||
// From 600, dragged only 40 toward open, but flung at -1.5 px/ms
|
||||
// (toward open) → 560 - 450 = 110 → nearest is 0 (fully open).
|
||||
expect(projectSnapRelease({
|
||||
...base,
|
||||
activeIndex: 0,
|
||||
draggedDistance: 40,
|
||||
velocity: -1.5,
|
||||
})).toEqual({ type: 'snap', index: 2 });
|
||||
});
|
||||
|
||||
it('closes when the projection lands nearer to fully-closed', () => {
|
||||
// From 600, dragged 100 toward dismiss → 700; 100 from closed vs 100 from
|
||||
// 600 — ties stay open; add a dismiss fling to push past.
|
||||
expect(projectSnapRelease({
|
||||
...base,
|
||||
activeIndex: 0,
|
||||
draggedDistance: -100,
|
||||
velocity: 0.6,
|
||||
})).toEqual({ type: 'close' });
|
||||
});
|
||||
|
||||
it('never closes a non-dismissible drawer', () => {
|
||||
expect(projectSnapRelease({
|
||||
...base,
|
||||
dismissible: false,
|
||||
activeIndex: 0,
|
||||
draggedDistance: -150,
|
||||
velocity: 2,
|
||||
})).toEqual({ type: 'snap', index: 0 });
|
||||
});
|
||||
|
||||
it('clamps the fling velocity', () => {
|
||||
// Absurd velocity toward open must land on the last point, not overshoot
|
||||
// into an invalid index.
|
||||
expect(projectSnapRelease({
|
||||
...base,
|
||||
activeIndex: 0,
|
||||
draggedDistance: 0,
|
||||
velocity: -50,
|
||||
})).toEqual({ type: 'snap', index: 2 });
|
||||
});
|
||||
|
||||
it('skips NaN offsets', () => {
|
||||
expect(projectSnapRelease({
|
||||
...base,
|
||||
offsets: [600, Number.NaN, 0],
|
||||
activeIndex: 0,
|
||||
draggedDistance: 250, // → 350, nearest usable is 600? |350-600|=250 vs |350-0|=350
|
||||
velocity: 0,
|
||||
})).toEqual({ type: 'snap', index: 0 });
|
||||
});
|
||||
|
||||
describe('sequential mode', () => {
|
||||
const sequential = { ...base, sequential: true };
|
||||
|
||||
it('advances a single step on a physical crossing', () => {
|
||||
// From 600 dragged far toward open (target 100, crossed 400) — but only
|
||||
// one step is allowed.
|
||||
expect(projectSnapRelease({
|
||||
...sequential,
|
||||
activeIndex: 0,
|
||||
draggedDistance: 500,
|
||||
velocity: 0,
|
||||
})).toEqual({ type: 'snap', index: 1 });
|
||||
});
|
||||
|
||||
it('advances on a fast fling without a crossing', () => {
|
||||
expect(projectSnapRelease({
|
||||
...sequential,
|
||||
activeIndex: 1,
|
||||
draggedDistance: 60,
|
||||
velocity: -0.8,
|
||||
})).toEqual({ type: 'snap', index: 2 });
|
||||
});
|
||||
|
||||
it('stays put on a slow drag without a crossing', () => {
|
||||
expect(projectSnapRelease({
|
||||
...sequential,
|
||||
activeIndex: 1,
|
||||
draggedDistance: 60,
|
||||
velocity: 0,
|
||||
})).toEqual({ type: 'snap', index: 1 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,3 +24,33 @@ export const WINDOW_TOP_OFFSET = 26;
|
||||
|
||||
/** Class applied to the drawer element while a drag is in progress. */
|
||||
export const DRAG_CLASS = 'drawer-dragging';
|
||||
|
||||
/** Smallest dt (ms) a velocity sample may span — clamps out same-frame event spikes. */
|
||||
export const MIN_VELOCITY_DT = 16;
|
||||
|
||||
/** A velocity sample older than this (ms) at release means the pointer stopped — velocity is 0. */
|
||||
export const MAX_VELOCITY_AGE = 80;
|
||||
|
||||
/** Dismiss displacement (px) a gesture must reach before the reverse-cancel detector arms. */
|
||||
export const REVERSE_CANCEL_ARM_DISTANCE = 20;
|
||||
|
||||
/** Pulling back this many px from the gesture's furthest point cancels the dismiss. */
|
||||
export const REVERSE_CANCEL_THRESHOLD = 10;
|
||||
|
||||
/** Pointer movement (px) needed before the gesture locks onto an axis. */
|
||||
export const AXIS_LOCK_DISTANCE = 2;
|
||||
|
||||
/** Snap release: velocity (px/ms) below which the fling projection is skipped. */
|
||||
export const SNAP_VELOCITY_THRESHOLD = 0.5;
|
||||
|
||||
/** Snap release: ms worth of travel a fling projects the release target ahead. */
|
||||
export const SNAP_VELOCITY_MULTIPLIER = 300;
|
||||
|
||||
/** Snap release: velocity clamp (px/ms) for the fling projection. */
|
||||
export const MAX_SNAP_VELOCITY = 4;
|
||||
|
||||
/** Release velocity (px/ms) below which the settle keeps the default duration. */
|
||||
export const SETTLE_VELOCITY_THRESHOLD = 0.2;
|
||||
|
||||
/** Fastest settle transition (ms) a hard flick can produce. */
|
||||
export const MIN_SETTLE_DURATION = 80;
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import type { Ref } from 'vue';
|
||||
import type { Ref, ShallowRef } from 'vue';
|
||||
import { useContextFactory } from '@robonen/vue';
|
||||
import type { MaybeElementRef } from '@robonen/vue';
|
||||
import type { DrawerDirection } from './types';
|
||||
import type { DrawerDirection, DrawerOpenChangeReason, DrawerPhase } from './types';
|
||||
|
||||
export interface DrawerRootContext {
|
||||
/** Source-of-truth open state (also bound to the underlying Dialog). */
|
||||
open: Ref<boolean>;
|
||||
/** Alias of {@link open}; kept for parity with consumers reading `isOpen`. */
|
||||
isOpen: Ref<boolean>;
|
||||
/**
|
||||
* Lifecycle phase of the drawer — unlike {@link open}, the enter/exit
|
||||
* transitions are explicit states (`opening`/`closing`).
|
||||
*/
|
||||
phase: Readonly<ShallowRef<DrawerPhase>>;
|
||||
/**
|
||||
* Signal that the open/close animation settled. Called by DrawerRoot when the
|
||||
* drawer element's transition/animation ends (or its fallback timeout fires);
|
||||
* advances {@link phase} out of `opening`/`closing`.
|
||||
*/
|
||||
notifySettled: () => void;
|
||||
/** Whether the drawer blocks the rest of the page (focus trap, scroll lock). */
|
||||
modal: Ref<boolean>;
|
||||
/** Becomes `true` the first time the drawer opens; gates Safari position fixes. */
|
||||
@@ -20,11 +31,11 @@ export interface DrawerRootContext {
|
||||
handleRef: MaybeElementRef<HTMLElement | undefined>;
|
||||
/** Whether a pointer drag is currently in progress. */
|
||||
isDragging: Ref<boolean>;
|
||||
/** Timestamp the active drag started, for velocity calculations. */
|
||||
dragStartTime: Ref<Date | null>;
|
||||
/** `event.timeStamp` of the active drag's start (ms, `performance.now()` clock). */
|
||||
dragStartTime: Ref<number | null>;
|
||||
/** Latched once a drag is permitted, so it can't be cancelled mid-gesture. */
|
||||
isAllowedToDrag: Ref<boolean>;
|
||||
/** Configured snap points (fractions of the screen or px strings). */
|
||||
/** Configured snap points (fractions of the screen, px numbers, or px/rem strings). */
|
||||
snapPoints: Ref<Array<number | string> | undefined>;
|
||||
/** Whether any snap points are configured. */
|
||||
hasSnapPoints: Ref<boolean>;
|
||||
@@ -38,18 +49,35 @@ export interface DrawerRootContext {
|
||||
dismissible: Ref<boolean>;
|
||||
/** Measured height of the drawer content in px. */
|
||||
drawerHeightRef: Ref<number>;
|
||||
/** Pixel offset of each snap point along the drag axis. */
|
||||
/** Pixel offset of each snap point along the drag axis (`NaN` for invalid points). */
|
||||
snapPointsOffset: Ref<number[]>;
|
||||
/** The edge the drawer is anchored to. */
|
||||
direction: Ref<DrawerDirection>;
|
||||
/** Begin a drag gesture. */
|
||||
onPress: (event: PointerEvent) => void;
|
||||
/**
|
||||
* Begin a drag gesture. `captureTarget` is the element that receives pointer
|
||||
* capture (defaults to the pressed element — capturing any higher, e.g. on
|
||||
* the drawer itself, would retarget `click` away from controls inside; the
|
||||
* handle passes itself so `handleOnly` gestures keep receiving moves).
|
||||
*/
|
||||
onPress: (event: PointerEvent, captureTarget?: HTMLElement) => void;
|
||||
/** Update the drawer position during a drag. */
|
||||
onDrag: (event: PointerEvent) => void;
|
||||
/** Settle the drawer (snap, close, or reset) when the pointer is released. */
|
||||
onRelease: (event: PointerEvent) => void;
|
||||
/** Programmatically close the drawer. */
|
||||
closeDrawer: () => void;
|
||||
/**
|
||||
* Abort the active drag without a user release (`pointercancel`, lost
|
||||
* capture): resets the drag state and settles the drawer back in place.
|
||||
*/
|
||||
onCancel: (event: PointerEvent) => void;
|
||||
/** Programmatically close the drawer, optionally tagging what caused it. */
|
||||
closeDrawer: (reason?: DrawerOpenChangeReason) => void;
|
||||
/**
|
||||
* Tag the next open-state flip with a reason. Consumed (and cleared) by
|
||||
* DrawerRoot's `update:open` emitter; auto-expires when no flip follows.
|
||||
*/
|
||||
armReason: (reason: DrawerOpenChangeReason) => void;
|
||||
/** Reason armed for the next open-state flip, if any. */
|
||||
pendingReason: { current: DrawerOpenChangeReason | undefined };
|
||||
/** Whether the overlay should fade with the drag at the current snap point. */
|
||||
shouldFade: Ref<boolean>;
|
||||
/** Snap point index from which the overlay starts fading. */
|
||||
|
||||
@@ -2,21 +2,31 @@ import type { Ref } from 'vue';
|
||||
import { computed, ref, shallowRef, watch, watchEffect } from 'vue';
|
||||
import { isClient } from '@robonen/platform/multi';
|
||||
import { getTranslate, resetStyle, setStyle } from '@robonen/platform/browsers';
|
||||
import { dampenValue, getDrawerWrapper, isVertical } from './helpers';
|
||||
import { BORDER_RADIUS, DRAG_CLASS, NESTED_DISPLACEMENT, TRANSITIONS, VELOCITY_THRESHOLD, WINDOW_TOP_OFFSET } from './constants';
|
||||
import { useStateMachine, useTextSelection, useWindowSize } from '@robonen/vue';
|
||||
import { dampenValue, getDrawerWrapper, getScaleFactor, isVertical, translate3d, translateAxis, writeTransform } from './helpers';
|
||||
import {
|
||||
AXIS_LOCK_DISTANCE,
|
||||
BORDER_RADIUS,
|
||||
DRAG_CLASS,
|
||||
NESTED_DISPLACEMENT,
|
||||
TRANSITIONS,
|
||||
VELOCITY_THRESHOLD,
|
||||
} from './constants';
|
||||
import type { GestureAxis, ReverseCancelTracker, VelocityTracker } from './gesture';
|
||||
import { computeSettleDuration, createReverseCancelTracker, createVelocityTracker, findScrollableAncestor, isAtScrollEdge } from './gesture';
|
||||
import { useSnapPoints } from './useSnapPoints';
|
||||
import { usePositionFixed } from './usePositionFixed';
|
||||
import type { DrawerRootContext } from './context';
|
||||
import type { DrawerDirection } from './types';
|
||||
import type { DrawerDirection, DrawerOpenChangeDetails, DrawerOpenChangeReason } from './types';
|
||||
|
||||
/** Shared, never-mutated — avoids allocating `{ transition: 'none' }` per drag frame. */
|
||||
const STYLE_NO_TRANSITION = { transition: 'none' };
|
||||
|
||||
export interface WithoutFadeFromProps {
|
||||
/**
|
||||
* Fractions (0–1) of the screen each snap point occupies, ordered from least
|
||||
* to most visible — e.g. `[0.2, 0.5, 0.8]`. Px strings (e.g. `'200px'`) are
|
||||
* also accepted and ignore screen height.
|
||||
* Snap points ordered from least to most visible: fractions (0–1) of the
|
||||
* screen, raw pixel numbers (> 1), or `'Npx'`/`'Nrem'` strings — e.g.
|
||||
* `[0.2, '148px', 0.8]`.
|
||||
*/
|
||||
snapPoints?: Array<number | string>;
|
||||
/** Index of the snap point from which the overlay fade begins. Defaults to the last. */
|
||||
@@ -82,6 +92,12 @@ export type DrawerRootProps = {
|
||||
handleOnly?: boolean;
|
||||
/** Don't restore scroll position when the drawer closes after a navigation. */
|
||||
preventScrollRestoration?: boolean;
|
||||
/**
|
||||
* Settle on the snap point adjacent to the active one (one step per gesture)
|
||||
* instead of the nearest to where the drag ended.
|
||||
* @default false
|
||||
*/
|
||||
snapToSequentialPoints?: boolean;
|
||||
} & WithoutFadeFromProps;
|
||||
|
||||
export interface UseDrawerProps {
|
||||
@@ -101,6 +117,7 @@ export interface UseDrawerProps {
|
||||
noBodyStyles: Ref<boolean>;
|
||||
preventScrollRestoration: Ref<boolean>;
|
||||
handleOnly: Ref<boolean>;
|
||||
snapToSequentialPoints: Ref<boolean>;
|
||||
}
|
||||
|
||||
export interface DrawerRootEmits {
|
||||
@@ -110,8 +127,8 @@ export interface DrawerRootEmits {
|
||||
(e: 'release', open: boolean): void;
|
||||
/** Fired when the drawer begins closing. */
|
||||
(e: 'close'): void;
|
||||
/** Two-way binding for the open state. */
|
||||
(e: 'update:open', open: boolean): void;
|
||||
/** Two-way binding for the open state. `details.reason` says what flipped it. */
|
||||
(e: 'update:open', open: boolean, details?: DrawerOpenChangeDetails): void;
|
||||
/** Two-way binding for the active snap point. */
|
||||
(e: 'update:activeSnapPoint', val: string | number): void;
|
||||
/** Fired after the open/close animation ends, with the open state at that time. */
|
||||
@@ -129,6 +146,47 @@ export interface DrawerHandleProps {
|
||||
preventCycle?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the drag hot path needs, snapshotted once at `onPress` so no
|
||||
* pointer-move ever reads layout (`getBoundingClientRect`/`getComputedStyle`),
|
||||
* queries the document, or allocates. Discarded on release/cancel.
|
||||
*/
|
||||
interface GestureState {
|
||||
pointerId: number;
|
||||
captureTarget: Element;
|
||||
vertical: boolean;
|
||||
/** +1 when the dismiss direction increases the client coordinate (bottom/right). */
|
||||
multiplier: 1 | -1;
|
||||
startX: number;
|
||||
startY: number;
|
||||
/** Drawer size (px) along the drag axis, measured once at press. */
|
||||
size: number;
|
||||
/** Window dimension (px) along the drag axis. */
|
||||
windowSize: number;
|
||||
/** Background-scale factor, cached so drag frames don't read `window.innerWidth`. */
|
||||
scale: number;
|
||||
/**
|
||||
* Inline translate currently applied to the drawer (px, signed). Seeded from
|
||||
* the computed style once at press (so a mid-animation grab starts from the
|
||||
* on-screen position) and mirrored on every write afterwards — the drag path
|
||||
* never reads computed styles.
|
||||
*/
|
||||
translate: number;
|
||||
wrapper: HTMLElement | null;
|
||||
/** Nearest scrollable ancestor under the pointer along the drag axis. */
|
||||
scroller: HTMLElement | null;
|
||||
/** Whether the first significant movement has locked the gesture's axis. */
|
||||
axisLocked: boolean;
|
||||
/** The gesture locked onto the cross axis — never a drawer drag. */
|
||||
blocked: boolean;
|
||||
velocity: VelocityTracker;
|
||||
reverse: ReverseCancelTracker;
|
||||
/** Last written overlay opacity (`''` = none yet) — skips redundant writes. */
|
||||
lastOverlayOpacity: string;
|
||||
/** Last wrapper-scale progress written (`-1` = none yet) — skips redundant writes. */
|
||||
lastWrapperProgress: number;
|
||||
}
|
||||
|
||||
function usePropOrDefaultRef<T>(prop: Ref<T | undefined> | undefined, defaultRef: Ref<T>): Ref<T> {
|
||||
return prop && !!prop.value ? (prop as Ref<T>) : defaultRef;
|
||||
}
|
||||
@@ -157,19 +215,19 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
|
||||
noBodyStyles,
|
||||
handleOnly,
|
||||
preventScrollRestoration,
|
||||
snapToSequentialPoints,
|
||||
} = props;
|
||||
|
||||
const hasBeenOpened = ref(open.value);
|
||||
const isDragging = ref(false);
|
||||
const justReleased = ref(false);
|
||||
const isAllowedToDrag = ref(false);
|
||||
const dragStartTime = ref<number | null>(null);
|
||||
|
||||
const overlayRef = shallowRef<HTMLElement | undefined>(undefined);
|
||||
|
||||
const openTime = ref<Date | null>(null);
|
||||
const dragStartTime = ref<Date | null>(null);
|
||||
const dragEndTime = ref<Date | null>(null);
|
||||
const lastTimeDragPrevented = ref<Date | null>(null);
|
||||
const isAllowedToDrag = ref(false);
|
||||
// Timestamps on the `performance.now()` clock (same origin as event.timeStamp).
|
||||
let openTime: number | null = null;
|
||||
let lastTimeDragPrevented: number | null = null;
|
||||
|
||||
const nestedOpenChangeTimer = ref<number | null>(null);
|
||||
|
||||
@@ -185,11 +243,31 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
|
||||
|
||||
const handleRef = shallowRef<HTMLElement | undefined>(undefined);
|
||||
|
||||
// Shared reactive window dimensions (0 during SSR) and text selection — one
|
||||
// listener each, reused by the gesture, the scale math, and the snap engine.
|
||||
const { width: windowWidth, height: windowHeight } = useWindowSize({ initialWidth: 0, initialHeight: 0 });
|
||||
const { text: selectedText } = useTextSelection();
|
||||
|
||||
/** Reason armed for the next open-state flip; consumed by DrawerRoot's emitter. */
|
||||
const pendingReason: { current: DrawerOpenChangeReason | undefined } = { current: undefined };
|
||||
|
||||
function armReason(reason: DrawerOpenChangeReason) {
|
||||
pendingReason.current = reason;
|
||||
|
||||
// Auto-expire so a dismiss that ends up prevented can't mislabel a later
|
||||
// programmatic flip. The open watcher (microtask) always wins this timeout.
|
||||
setTimeout(() => {
|
||||
if (pendingReason.current === reason)
|
||||
pendingReason.current = undefined;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
const {
|
||||
activeSnapPointIndex,
|
||||
onRelease: onReleaseSnapPoints,
|
||||
snapPointsOffset,
|
||||
onDrag: onDragSnapPoints,
|
||||
restoreActiveSnapPoint,
|
||||
shouldFade,
|
||||
getPercentageDragged: getSnapPointsPercentageDragged,
|
||||
} = useSnapPoints({
|
||||
@@ -200,13 +278,16 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
|
||||
overlayRef,
|
||||
onSnapPointChange,
|
||||
direction,
|
||||
snapToSequentialPoints,
|
||||
windowWidth,
|
||||
windowHeight,
|
||||
});
|
||||
|
||||
function onSnapPointChange(activeSnapPointIndex: number, snapPointsOffset: number[]) {
|
||||
// Refresh openTime when we reach the last snap point so scrollable content
|
||||
// there isn't immediately draggable.
|
||||
if (snapPoints.value && activeSnapPointIndex === snapPointsOffset.length - 1)
|
||||
openTime.value = new Date();
|
||||
openTime = performance.now();
|
||||
}
|
||||
|
||||
usePositionFixed({
|
||||
@@ -218,203 +299,300 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
|
||||
preventScrollRestoration,
|
||||
});
|
||||
|
||||
function getScale() {
|
||||
return (window.innerWidth - WINDOW_TOP_OFFSET) / window.innerWidth;
|
||||
}
|
||||
// The drawer's lifecycle as explicit phases. `OPEN`/`CLOSE` are driven by the
|
||||
// shared `open` ref below; `SETTLE` arrives from DrawerRoot when the enter/exit
|
||||
// animation actually ends (element event or its fallback timeout). Close-side
|
||||
// cleanup lives on the `closed` entry hook instead of duration-guessing
|
||||
// timeouts: re-opening mid-close moves `closing → opening`, so it can never
|
||||
// fire on a live drawer.
|
||||
const lifecycle = useStateMachine({
|
||||
initial: open.value ? 'open' : 'closed',
|
||||
states: {
|
||||
closed: {
|
||||
entry: () => {
|
||||
if (snapPoints.value)
|
||||
activeSnapPoint.value = snapPoints.value[0];
|
||||
},
|
||||
on: { OPEN: 'opening' },
|
||||
},
|
||||
opening: {
|
||||
entry: () => {
|
||||
openTime = performance.now();
|
||||
hasBeenOpened.value = true;
|
||||
// A fast-flick close writes an inline animation-duration override;
|
||||
// reopening before that exit settles reuses the SAME element
|
||||
// (Presence keeps it alive), so clear the override here or the enter
|
||||
// — and any later gentle exit — replays at flick speed.
|
||||
drawerRef.value?.style.removeProperty('animation-duration');
|
||||
overlayRef.value?.style.removeProperty('animation-duration');
|
||||
},
|
||||
on: { SETTLE: 'open', CLOSE: 'closing' },
|
||||
},
|
||||
open: { on: { CLOSE: 'closing' } },
|
||||
closing: { on: { SETTLE: 'closed', OPEN: 'opening' } },
|
||||
},
|
||||
});
|
||||
|
||||
let gesture: GestureState | null = null;
|
||||
|
||||
function shouldDrag(el: EventTarget | null, isDraggingInDirection: boolean, now: number): boolean {
|
||||
const g = gesture!;
|
||||
|
||||
function shouldDrag(el: EventTarget | null, isDraggingInDirection: boolean) {
|
||||
if (!el)
|
||||
return false;
|
||||
let element = el as HTMLElement;
|
||||
const highlightedText = globalThis.getSelection()?.toString();
|
||||
const swipeAmount = drawerRef.value ? getTranslate(drawerRef.value, isVertical(direction.value) ? 'y' : 'x') : null;
|
||||
const date = new Date();
|
||||
|
||||
if (element.hasAttribute('data-drawer-no-drag') || element.closest('[data-drawer-no-drag]'))
|
||||
const element = el as HTMLElement;
|
||||
|
||||
if (element.closest?.('[data-drawer-no-drag]'))
|
||||
return false;
|
||||
|
||||
if (direction.value === 'right' || direction.value === 'left')
|
||||
return true;
|
||||
|
||||
// Allow scrolling during the open animation.
|
||||
if (openTime.value && date.getTime() - openTime.value.getTime() < 500)
|
||||
if (openTime !== null && now - openTime < 500)
|
||||
return false;
|
||||
|
||||
if (swipeAmount !== null) {
|
||||
if (direction.value === 'bottom' ? swipeAmount > 0 : swipeAmount < 0)
|
||||
return true;
|
||||
}
|
||||
// Partially hidden (a snap point below fully open, or a mid-animation
|
||||
// grab) — the drawer is always draggable.
|
||||
const swipeAmount = g.translate;
|
||||
|
||||
// Don't drag when text is selected.
|
||||
if (highlightedText && highlightedText.length > 0)
|
||||
if (g.multiplier === 1 ? swipeAmount > 0 : swipeAmount < 0)
|
||||
return true;
|
||||
|
||||
// Don't drag when text is selected (reactive — no per-move getSelection).
|
||||
if (selectedText.value.length > 0)
|
||||
return false;
|
||||
|
||||
// Don't drag right after scrolling inside the drawer.
|
||||
if (
|
||||
lastTimeDragPrevented.value
|
||||
&& date.getTime() - lastTimeDragPrevented.value.getTime() < scrollLockTimeout.value
|
||||
lastTimeDragPrevented !== null
|
||||
&& now - lastTimeDragPrevented < scrollLockTimeout.value
|
||||
&& swipeAmount === 0
|
||||
) {
|
||||
lastTimeDragPrevented.value = date;
|
||||
lastTimeDragPrevented = now;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isDraggingInDirection) {
|
||||
lastTimeDragPrevented.value = date;
|
||||
lastTimeDragPrevented = now;
|
||||
// Dragging in the open direction → allow scrolling instead.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Walk up the tree; if a scrollable ancestor isn't at the top, scroll it instead of dragging.
|
||||
while (element) {
|
||||
if (element.scrollHeight > element.clientHeight) {
|
||||
if (element.scrollTop !== 0) {
|
||||
lastTimeDragPrevented.value = new Date();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (element.getAttribute('role') === 'dialog')
|
||||
return true;
|
||||
}
|
||||
|
||||
element = element.parentNode as HTMLElement;
|
||||
// A scroll container under the pointer owns the gesture unless it already
|
||||
// sits at the edge the dismiss direction pulls away from.
|
||||
if (g.scroller && !isAtScrollEdge(g.scroller, direction.value)) {
|
||||
lastTimeDragPrevented = now;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Measured once per gesture in onPress and reused every move — avoids a
|
||||
// per-frame getBoundingClientRect (forced reflow) and document.querySelector.
|
||||
let dragStartHeight = 0;
|
||||
let dragWrapper: HTMLElement | null = null;
|
||||
function onPress(event: PointerEvent, captureTarget?: HTMLElement) {
|
||||
// One gesture at a time; a second touch never steals an active drag. But a
|
||||
// gesture whose capture element left the DOM can never finish (its
|
||||
// lostpointercapture fires at the document, past our listeners) — reclaim
|
||||
// it instead of wedging every future drag.
|
||||
if (gesture) {
|
||||
if (gesture.captureTarget.isConnected)
|
||||
return;
|
||||
|
||||
function onPress(event: PointerEvent) {
|
||||
gesture = null;
|
||||
isAllowedToDrag.value = false;
|
||||
isDragging.value = false;
|
||||
drawerRef.value?.classList.remove(DRAG_CLASS);
|
||||
}
|
||||
if (!dismissible.value && !snapPoints.value)
|
||||
return;
|
||||
if (drawerRef.value && !drawerRef.value.contains(event.target as Node))
|
||||
if (event.button > 0)
|
||||
return;
|
||||
isDragging.value = true;
|
||||
dragStartTime.value = new Date();
|
||||
dragStartHeight = drawerRef.value?.getBoundingClientRect().height || 0;
|
||||
dragWrapper = getDrawerWrapper();
|
||||
|
||||
(event.target as HTMLElement).setPointerCapture(event.pointerId);
|
||||
pointerStart.value = isVertical(direction.value) ? event.clientY : event.clientX;
|
||||
const el = drawerRef.value;
|
||||
|
||||
if (!el || !el.contains(event.target as Node))
|
||||
return;
|
||||
|
||||
const vertical = isVertical(direction.value);
|
||||
const axis: GestureAxis = vertical ? 'y' : 'x';
|
||||
const rect = el.getBoundingClientRect();
|
||||
// Capture on the pressed element, never the drawer: while a capture is
|
||||
// active the compat mouse events retarget to the capturing element, so
|
||||
// capturing on the drawer would swallow `click` for every control inside it.
|
||||
const capture = captureTarget ?? (event.target as Element);
|
||||
|
||||
// Synthetic pointers (tests) and already-released pointers have no active
|
||||
// pointer id to capture — the drag still works, only retargeting is lost.
|
||||
try {
|
||||
capture.setPointerCapture(event.pointerId);
|
||||
}
|
||||
catch {
|
||||
// No active pointer to capture — the drag still works, only retargeting is lost.
|
||||
}
|
||||
|
||||
isDragging.value = true;
|
||||
dragStartTime.value = event.timeStamp;
|
||||
pointerStart.value = vertical ? event.clientY : event.clientX;
|
||||
|
||||
gesture = {
|
||||
pointerId: event.pointerId,
|
||||
captureTarget: capture,
|
||||
vertical,
|
||||
multiplier: direction.value === 'bottom' || direction.value === 'right' ? 1 : -1,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
size: (vertical ? rect.height : rect.width) || 0,
|
||||
windowSize: vertical ? windowHeight.value : windowWidth.value,
|
||||
scale: getScaleFactor(windowWidth.value),
|
||||
// The one intentional computed-style read of the gesture: catches the
|
||||
// drawer mid-animation so the drag continues from the on-screen position.
|
||||
translate: getTranslate(el, axis) ?? 0,
|
||||
wrapper: getDrawerWrapper(),
|
||||
scroller: findScrollableAncestor(event.target as Element, el, axis),
|
||||
axisLocked: false,
|
||||
blocked: false,
|
||||
velocity: createVelocityTracker(),
|
||||
reverse: createReverseCancelTracker(),
|
||||
lastOverlayOpacity: '',
|
||||
lastWrapperProgress: -1,
|
||||
};
|
||||
}
|
||||
|
||||
function onDrag(event: PointerEvent) {
|
||||
if (!drawerRef.value)
|
||||
const g = gesture;
|
||||
|
||||
if (!g || event.pointerId !== g.pointerId || !isDragging.value || g.blocked || !drawerRef.value)
|
||||
return;
|
||||
|
||||
if (isDragging.value) {
|
||||
const directionMultiplier = direction.value === 'bottom' || direction.value === 'right' ? 1 : -1;
|
||||
const draggedDistance
|
||||
= (pointerStart.value - (isVertical(direction.value) ? event.clientY : event.clientX)) * directionMultiplier;
|
||||
const isDraggingInDirection = draggedDistance > 0;
|
||||
const dx = event.clientX - g.startX;
|
||||
const dy = event.clientY - g.startY;
|
||||
|
||||
// Don't allow dragging toward close past the first snap point when not dismissible.
|
||||
const noCloseSnapPointsPreCondition = snapPoints.value && !dismissible.value && !isDraggingInDirection;
|
||||
// Lock onto an axis on the first significant movement. A gesture that
|
||||
// locks onto the cross axis is a scroll/pan — never a drawer drag.
|
||||
if (!g.axisLocked) {
|
||||
const absX = Math.abs(dx);
|
||||
const absY = Math.abs(dy);
|
||||
|
||||
if (noCloseSnapPointsPreCondition && activeSnapPointIndex.value === 0)
|
||||
if (absX < AXIS_LOCK_DISTANCE && absY < AXIS_LOCK_DISTANCE)
|
||||
return;
|
||||
|
||||
const absDraggedDistance = Math.abs(draggedDistance);
|
||||
const wrapper = dragWrapper;
|
||||
g.axisLocked = true;
|
||||
|
||||
// 1 means the closed position. Height cached at drag start (no reflow).
|
||||
let percentageDragged = absDraggedDistance / (dragStartHeight || 1);
|
||||
const snapPointPercentageDragged = getSnapPointsPercentageDragged(absDraggedDistance, isDraggingInDirection);
|
||||
|
||||
if (snapPointPercentageDragged !== null)
|
||||
percentageDragged = snapPointPercentageDragged;
|
||||
|
||||
if (noCloseSnapPointsPreCondition && percentageDragged >= 1)
|
||||
return;
|
||||
|
||||
// Decide-to-drag gate + one-time gesture setup. Once allowed, stay allowed
|
||||
// for the whole gesture, so the class add + transition writes fire ONCE,
|
||||
// not on every move.
|
||||
if (!isAllowedToDrag.value) {
|
||||
if (!shouldDrag(event.target, isDraggingInDirection))
|
||||
return;
|
||||
isAllowedToDrag.value = true;
|
||||
drawerRef.value.classList.add(DRAG_CLASS);
|
||||
setStyle(drawerRef.value, STYLE_NO_TRANSITION);
|
||||
setStyle(overlayRef.value, STYLE_NO_TRANSITION);
|
||||
}
|
||||
|
||||
if (snapPoints.value)
|
||||
onDragSnapPoints({ draggedDistance });
|
||||
|
||||
// Rubber-band past the open position when there are no snap points.
|
||||
if (isDraggingInDirection && !snapPoints.value) {
|
||||
const dampenedDraggedDistance = dampenValue(draggedDistance);
|
||||
|
||||
const translateValue = Math.min(dampenedDraggedDistance * -1, 0) * directionMultiplier;
|
||||
setStyle(drawerRef.value, {
|
||||
transform: isVertical(direction.value)
|
||||
? `translate3d(0, ${translateValue}px, 0)`
|
||||
: `translate3d(${translateValue}px, 0, 0)`,
|
||||
});
|
||||
if ((absX > absY) === g.vertical) {
|
||||
g.blocked = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const opacityValue = 1 - percentageDragged;
|
||||
g.velocity.add(g.vertical ? event.clientY : event.clientX, event.timeStamp);
|
||||
|
||||
if (shouldFade.value || (fadeFromIndex.value && activeSnapPointIndex.value === fadeFromIndex.value - 1)) {
|
||||
emitDrag(percentageDragged);
|
||||
const draggedDistance = (g.vertical ? g.startY - event.clientY : g.startX - event.clientX) * g.multiplier;
|
||||
const isDraggingInDirection = draggedDistance > 0;
|
||||
|
||||
setStyle(overlayRef.value, { opacity: `${opacityValue}`, transition: 'none' }, true);
|
||||
// Dismiss-positive displacement feeds the "changed my mind" detector.
|
||||
g.reverse.update(-draggedDistance);
|
||||
|
||||
// Don't allow dragging toward close past the first snap point when not dismissible.
|
||||
const noCloseSnapPointsPreCondition = snapPoints.value && !dismissible.value && !isDraggingInDirection;
|
||||
|
||||
if (noCloseSnapPointsPreCondition && activeSnapPointIndex.value === 0)
|
||||
return;
|
||||
|
||||
const absDraggedDistance = Math.abs(draggedDistance);
|
||||
|
||||
// 1 means the closed position. Size cached at drag start (no reflow).
|
||||
let percentageDragged = absDraggedDistance / (g.size || 1);
|
||||
const snapPointPercentageDragged = getSnapPointsPercentageDragged(absDraggedDistance, isDraggingInDirection);
|
||||
|
||||
if (snapPointPercentageDragged !== null)
|
||||
percentageDragged = snapPointPercentageDragged;
|
||||
|
||||
if (noCloseSnapPointsPreCondition && percentageDragged >= 1)
|
||||
return;
|
||||
|
||||
// Decide-to-drag gate + one-time gesture setup. Once allowed, stay allowed
|
||||
// for the whole gesture, so the class add + transition writes fire ONCE,
|
||||
// not on every move.
|
||||
if (!isAllowedToDrag.value) {
|
||||
if (!shouldDrag(event.target, isDraggingInDirection, event.timeStamp))
|
||||
return;
|
||||
isAllowedToDrag.value = true;
|
||||
drawerRef.value.classList.add(DRAG_CLASS);
|
||||
setStyle(drawerRef.value, STYLE_NO_TRANSITION);
|
||||
setStyle(overlayRef.value, STYLE_NO_TRANSITION);
|
||||
}
|
||||
|
||||
if (snapPoints.value) {
|
||||
const applied = onDragSnapPoints({ draggedDistance });
|
||||
|
||||
if (applied !== null)
|
||||
g.translate = applied;
|
||||
}
|
||||
|
||||
// Rubber-band past the open position when there are no snap points.
|
||||
if (isDraggingInDirection && !snapPoints.value) {
|
||||
const dampenedDraggedDistance = dampenValue(draggedDistance);
|
||||
const translateValue = Math.min(dampenedDraggedDistance * -1, 0) * g.multiplier;
|
||||
|
||||
writeTransform(drawerRef.value, translateAxis(g.vertical, translateValue));
|
||||
g.translate = translateValue;
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldFade.value || (fadeFromIndex.value && activeSnapPointIndex.value === fadeFromIndex.value - 1)) {
|
||||
emitDrag(percentageDragged);
|
||||
|
||||
const overlay = overlayRef.value;
|
||||
const opacity = `${1 - percentageDragged}`;
|
||||
|
||||
if (overlay && opacity !== g.lastOverlayOpacity) {
|
||||
g.lastOverlayOpacity = opacity;
|
||||
overlay.style.opacity = opacity;
|
||||
overlay.style.transition = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if (wrapper && overlayRef.value && shouldScaleBackground.value) {
|
||||
const scaleValue = Math.min(getScale() + percentageDragged * (1 - getScale()), 1);
|
||||
const borderRadiusValue = 8 - percentageDragged * 8;
|
||||
const translateValue = Math.max(0, 14 - percentageDragged * 14);
|
||||
if (g.wrapper && overlayRef.value && shouldScaleBackground.value && percentageDragged !== g.lastWrapperProgress) {
|
||||
g.lastWrapperProgress = percentageDragged;
|
||||
|
||||
setStyle(
|
||||
wrapper,
|
||||
{
|
||||
borderRadius: `${borderRadiusValue}px`,
|
||||
transform: isVertical(direction.value)
|
||||
? `scale(${scaleValue}) translate3d(0, ${translateValue}px, 0)`
|
||||
: `scale(${scaleValue}) translate3d(${translateValue}px, 0, 0)`,
|
||||
transition: 'none',
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
const scaleValue = Math.min(g.scale + percentageDragged * (1 - g.scale), 1);
|
||||
const borderRadiusValue = 8 - percentageDragged * 8;
|
||||
const translateValue = Math.max(0, 14 - percentageDragged * 14);
|
||||
const style = g.wrapper.style;
|
||||
|
||||
if (!snapPoints.value) {
|
||||
const translateValue = absDraggedDistance * directionMultiplier;
|
||||
style.borderRadius = `${borderRadiusValue}px`;
|
||||
style.transform = g.vertical
|
||||
? `scale(${scaleValue}) translate3d(0, ${translateValue}px, 0)`
|
||||
: `scale(${scaleValue}) translate3d(${translateValue}px, 0, 0)`;
|
||||
style.transition = 'none';
|
||||
}
|
||||
|
||||
setStyle(drawerRef.value, {
|
||||
transform: isVertical(direction.value)
|
||||
? `translate3d(0, ${translateValue}px, 0)`
|
||||
: `translate3d(${translateValue}px, 0, 0)`,
|
||||
});
|
||||
}
|
||||
if (!snapPoints.value) {
|
||||
const translateValue = absDraggedDistance * g.multiplier;
|
||||
|
||||
writeTransform(drawerRef.value, translateAxis(g.vertical, translateValue));
|
||||
g.translate = translateValue;
|
||||
}
|
||||
}
|
||||
|
||||
function resetDrawer() {
|
||||
function resetDrawer(duration: number = TRANSITIONS.DURATION, currentSwipeAmount?: number | null) {
|
||||
if (!drawerRef.value)
|
||||
return;
|
||||
const wrapper = getDrawerWrapper();
|
||||
const currentSwipeAmount = getTranslate(drawerRef.value, isVertical(direction.value) ? 'y' : 'x');
|
||||
const swipeAmount = currentSwipeAmount
|
||||
?? getTranslate(drawerRef.value, isVertical(direction.value) ? 'y' : 'x');
|
||||
const ease = `cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
|
||||
|
||||
setStyle(drawerRef.value, {
|
||||
transform: 'translate3d(0, 0, 0)',
|
||||
transition: `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`,
|
||||
transition: `transform ${duration}s ${ease}`,
|
||||
});
|
||||
|
||||
setStyle(overlayRef.value, {
|
||||
transition: `opacity ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`,
|
||||
transition: `opacity ${duration}s ${ease}`,
|
||||
opacity: '1',
|
||||
});
|
||||
|
||||
// Keep the background scaled if we didn't swipe back down.
|
||||
if (shouldScaleBackground.value && currentSwipeAmount && currentSwipeAmount > 0 && open.value) {
|
||||
if (shouldScaleBackground.value && swipeAmount && swipeAmount > 0 && open.value) {
|
||||
setStyle(
|
||||
wrapper,
|
||||
{
|
||||
@@ -422,11 +600,11 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
|
||||
overflow: 'hidden',
|
||||
...(isVertical(direction.value)
|
||||
? {
|
||||
transform: `scale(${getScale()}) translate3d(0, calc(env(safe-area-inset-top) + 14px), 0)`,
|
||||
transform: `scale(${getScaleFactor(windowWidth.value)}) translate3d(0, calc(env(safe-area-inset-top) + 14px), 0)`,
|
||||
transformOrigin: 'top',
|
||||
}
|
||||
: {
|
||||
transform: `scale(${getScale()}) translate3d(calc(env(safe-area-inset-top) + 14px), 0, 0)`,
|
||||
transform: `scale(${getScaleFactor(windowWidth.value)}) translate3d(calc(env(safe-area-inset-top) + 14px), 0, 0)`,
|
||||
transformOrigin: 'left',
|
||||
}),
|
||||
transitionProperty: 'transform, border-radius',
|
||||
@@ -442,13 +620,136 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
|
||||
// snap-point reset, update:open) is driven off the `open` transition below, so
|
||||
// this stays the single place that closes — whatever the trigger (drag, handle,
|
||||
// dialog dismissal, or a controlled `v-model:open` flip).
|
||||
function closeDrawer() {
|
||||
function closeDrawer(reason?: DrawerOpenChangeReason) {
|
||||
if (!drawerRef.value)
|
||||
return;
|
||||
|
||||
if (reason)
|
||||
armReason(reason);
|
||||
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close via the exit keyframes, scaled to the fling: the inline
|
||||
* `animation-duration` overrides the stylesheet's 0.5s so a hard flick
|
||||
* finishes in as little as 80ms. A reopen before the exit settles reuses the
|
||||
* same element (Presence holds it), so the `opening` entry hook clears the
|
||||
* override before the enter plays.
|
||||
*/
|
||||
function closeWithSettle(remainingDistance: number, velocity: number) {
|
||||
const duration = computeSettleDuration(remainingDistance, velocity);
|
||||
|
||||
if (duration !== TRANSITIONS.DURATION) {
|
||||
const durationValue = `${duration}s`;
|
||||
|
||||
if (drawerRef.value)
|
||||
drawerRef.value.style.animationDuration = durationValue;
|
||||
if (overlayRef.value)
|
||||
overlayRef.value.style.animationDuration = durationValue;
|
||||
}
|
||||
|
||||
closeDrawer('swipe');
|
||||
}
|
||||
|
||||
function endGesture(event: PointerEvent): GestureState | null {
|
||||
const g = gesture;
|
||||
|
||||
if (!g || event.pointerId !== g.pointerId)
|
||||
return null;
|
||||
|
||||
gesture = null;
|
||||
drawerRef.value?.classList.remove(DRAG_CLASS);
|
||||
|
||||
try {
|
||||
g.captureTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
catch {
|
||||
// Capture was never acquired (synthetic pointer) or already released.
|
||||
}
|
||||
|
||||
const wasAllowed = isAllowedToDrag.value;
|
||||
|
||||
isAllowedToDrag.value = false;
|
||||
isDragging.value = false;
|
||||
|
||||
return wasAllowed ? g : null;
|
||||
}
|
||||
|
||||
function onRelease(event: PointerEvent) {
|
||||
if (!isDragging.value || !drawerRef.value) {
|
||||
endGesture(event);
|
||||
return;
|
||||
}
|
||||
|
||||
const g = endGesture(event);
|
||||
|
||||
if (!g)
|
||||
return;
|
||||
|
||||
const swipeAmount = g.translate;
|
||||
const cancelled = g.reverse.cancelled;
|
||||
const rawVelocity = g.velocity.read(event.timeStamp);
|
||||
const velocityToDismiss = cancelled ? 0 : rawVelocity * g.multiplier;
|
||||
const distMoved = g.vertical ? g.startY - event.clientY : g.startX - event.clientX;
|
||||
const draggedDistance = distMoved * g.multiplier;
|
||||
|
||||
if (snapPoints.value) {
|
||||
onReleaseSnapPoints({
|
||||
draggedDistance,
|
||||
closeDrawer: () => closeDrawer('swipe'),
|
||||
velocity: velocityToDismiss,
|
||||
dismissible: dismissible.value,
|
||||
drawerSize: g.size,
|
||||
});
|
||||
emitRelease(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Moved toward open, or pulled back to cancel → settle into place.
|
||||
if (draggedDistance > 0 || cancelled) {
|
||||
resetDrawer(computeSettleDuration(Math.abs(swipeAmount), rawVelocity), swipeAmount);
|
||||
emitRelease(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const dismissTravel = swipeAmount * g.multiplier;
|
||||
const remaining = Math.max(g.size - dismissTravel, 0);
|
||||
|
||||
if (velocityToDismiss > VELOCITY_THRESHOLD) {
|
||||
closeWithSettle(remaining, velocityToDismiss);
|
||||
emitRelease(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleSize = Math.min(g.size || 0, g.windowSize);
|
||||
|
||||
if (dismissTravel >= visibleSize * closeThreshold.value) {
|
||||
closeWithSettle(remaining, velocityToDismiss);
|
||||
emitRelease(false);
|
||||
return;
|
||||
}
|
||||
|
||||
emitRelease(true);
|
||||
resetDrawer(computeSettleDuration(dismissTravel, rawVelocity), swipeAmount);
|
||||
}
|
||||
|
||||
function onCancel(event: PointerEvent) {
|
||||
const g = endGesture(event);
|
||||
|
||||
if (!g)
|
||||
return;
|
||||
|
||||
// A cancelled pointer is not a user decision — settle back where the
|
||||
// drawer was, never close.
|
||||
if (snapPoints.value)
|
||||
restoreActiveSnapPoint();
|
||||
else
|
||||
resetDrawer(TRANSITIONS.DURATION, g.translate);
|
||||
|
||||
emitRelease(true);
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (!open.value && shouldScaleBackground.value && isClient) {
|
||||
// The component is invisible by the time onAnimationEnd would fire, so use a timeout.
|
||||
@@ -462,99 +763,28 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
|
||||
return undefined;
|
||||
});
|
||||
|
||||
function onRelease(event: PointerEvent) {
|
||||
if (!isDragging.value || !drawerRef.value)
|
||||
return;
|
||||
|
||||
drawerRef.value.classList.remove(DRAG_CLASS);
|
||||
isAllowedToDrag.value = false;
|
||||
isDragging.value = false;
|
||||
dragWrapper = null;
|
||||
dragEndTime.value = new Date();
|
||||
const swipeAmount = getTranslate(drawerRef.value, isVertical(direction.value) ? 'y' : 'x');
|
||||
|
||||
if (!shouldDrag(event.target, false) || !swipeAmount || Number.isNaN(swipeAmount))
|
||||
return;
|
||||
|
||||
if (dragStartTime.value === null)
|
||||
return;
|
||||
|
||||
const timeTaken = dragEndTime.value.getTime() - dragStartTime.value.getTime();
|
||||
const distMoved = pointerStart.value - (isVertical(direction.value) ? event.clientY : event.clientX);
|
||||
const velocity = Math.abs(distMoved) / timeTaken;
|
||||
|
||||
if (velocity > 0.05) {
|
||||
// Prevents the drawer from focusing an input as the drag ends.
|
||||
justReleased.value = true;
|
||||
|
||||
globalThis.setTimeout(() => {
|
||||
justReleased.value = false;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
if (snapPoints.value) {
|
||||
const directionMultiplier = direction.value === 'bottom' || direction.value === 'right' ? 1 : -1;
|
||||
|
||||
onReleaseSnapPoints({
|
||||
draggedDistance: distMoved * directionMultiplier,
|
||||
closeDrawer,
|
||||
velocity,
|
||||
dismissible: dismissible.value,
|
||||
});
|
||||
emitRelease(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Moved in the open direction → settle back.
|
||||
if (direction.value === 'bottom' || direction.value === 'right' ? distMoved > 0 : distMoved < 0) {
|
||||
resetDrawer();
|
||||
emitRelease(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (velocity > VELOCITY_THRESHOLD) {
|
||||
closeDrawer();
|
||||
emitRelease(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleDrawerHeight = Math.min(drawerRef.value.getBoundingClientRect().height ?? 0, window.innerHeight);
|
||||
|
||||
if (swipeAmount >= visibleDrawerHeight * closeThreshold.value) {
|
||||
closeDrawer();
|
||||
emitRelease(false);
|
||||
return;
|
||||
}
|
||||
|
||||
emitRelease(true);
|
||||
resetDrawer();
|
||||
}
|
||||
|
||||
// Single owner of open/close side effects. Reacts to every source that writes
|
||||
// the shared `open` ref: the drag/handle paths (closeDrawer), the dialog's
|
||||
// dismissals (DrawerRoot.handleOpenChange), and a controlled `v-model:open`
|
||||
// flip (DrawerRoot's prop watch). `update:open`/`animationEnd` are emitted by
|
||||
// DrawerRoot's own watch on the same ref.
|
||||
// DrawerRoot's own watch on the same ref; everything else rides the lifecycle
|
||||
// machine's entry hooks.
|
||||
watch(open, (o) => {
|
||||
if (o) {
|
||||
openTime.value = new Date();
|
||||
hasBeenOpened.value = true;
|
||||
lifecycle.send('OPEN');
|
||||
}
|
||||
else {
|
||||
emitClose();
|
||||
globalThis.setTimeout(() => {
|
||||
if (snapPoints.value)
|
||||
activeSnapPoint.value = snapPoints.value[0];
|
||||
}, TRANSITIONS.DURATION * 1000);
|
||||
lifecycle.send('CLOSE');
|
||||
}
|
||||
});
|
||||
|
||||
function onNestedOpenChange(o: boolean) {
|
||||
const scale = o ? (window.innerWidth - NESTED_DISPLACEMENT) / window.innerWidth : 1;
|
||||
const scale = o ? (windowWidth.value - NESTED_DISPLACEMENT) / windowWidth.value : 1;
|
||||
const y = o ? -NESTED_DISPLACEMENT : 0;
|
||||
|
||||
if (nestedOpenChangeTimer.value)
|
||||
globalThis.clearTimeout(nestedOpenChangeTimer.value);
|
||||
clearTimeout(nestedOpenChangeTimer.value);
|
||||
|
||||
setStyle(drawerRef.value, {
|
||||
transition: `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`,
|
||||
@@ -562,13 +792,11 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
|
||||
});
|
||||
|
||||
if (!o && drawerRef.value) {
|
||||
nestedOpenChangeTimer.value = globalThis.setTimeout(() => {
|
||||
nestedOpenChangeTimer.value = setTimeout(() => {
|
||||
const translateValue = getTranslate(drawerRef.value!, isVertical(direction.value) ? 'y' : 'x');
|
||||
setStyle(drawerRef.value, {
|
||||
transition: 'none',
|
||||
transform: isVertical(direction.value)
|
||||
? `translate3d(0, ${translateValue}px, 0)`
|
||||
: `translate3d(${translateValue}px, 0, 0)`,
|
||||
transform: translate3d(direction.value, translateValue ?? 0),
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
@@ -578,21 +806,25 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
|
||||
if (percentageDragged < 0)
|
||||
return;
|
||||
|
||||
const initialDim = isVertical(direction.value) ? window.innerHeight : window.innerWidth;
|
||||
const el = drawerRef.value;
|
||||
|
||||
if (!el)
|
||||
return;
|
||||
|
||||
const initialDim = isVertical(direction.value) ? windowHeight.value : windowWidth.value;
|
||||
const initialScale = (initialDim - NESTED_DISPLACEMENT) / initialDim;
|
||||
const newScale = initialScale + percentageDragged * (1 - initialScale);
|
||||
const newTranslate = -NESTED_DISPLACEMENT + percentageDragged * NESTED_DISPLACEMENT;
|
||||
|
||||
setStyle(drawerRef.value, {
|
||||
transform: isVertical(direction.value)
|
||||
? `scale(${newScale}) translate3d(0, ${newTranslate}px, 0)`
|
||||
: `scale(${newScale}) translate3d(${newTranslate}px, 0, 0)`,
|
||||
transition: 'none',
|
||||
});
|
||||
// Per-frame path (driven by the child's drag) — direct writes, no setStyle.
|
||||
el.style.transform = isVertical(direction.value)
|
||||
? `scale(${newScale}) translate3d(0, ${newTranslate}px, 0)`
|
||||
: `scale(${newScale}) translate3d(${newTranslate}px, 0, 0)`;
|
||||
el.style.transition = 'none';
|
||||
}
|
||||
|
||||
function onNestedRelease(o: boolean) {
|
||||
const dim = isVertical(direction.value) ? window.innerHeight : window.innerWidth;
|
||||
const dim = isVertical(direction.value) ? windowHeight.value : windowWidth.value;
|
||||
const scale = o ? (dim - NESTED_DISPLACEMENT) / dim : 1;
|
||||
const translate = o ? -NESTED_DISPLACEMENT : 0;
|
||||
|
||||
@@ -609,6 +841,10 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
|
||||
return {
|
||||
open,
|
||||
isOpen: open,
|
||||
phase: lifecycle.state,
|
||||
notifySettled: () => {
|
||||
lifecycle.send('SETTLE');
|
||||
},
|
||||
modal,
|
||||
keyboardIsOpen,
|
||||
hasBeenOpened,
|
||||
@@ -633,7 +869,10 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
|
||||
onPress,
|
||||
onDrag,
|
||||
onRelease,
|
||||
onCancel,
|
||||
closeDrawer,
|
||||
armReason,
|
||||
pendingReason,
|
||||
onNestedDrag,
|
||||
onNestedRelease,
|
||||
onNestedOpenChange,
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { clamp } from '@robonen/stdlib';
|
||||
import type { DrawerDirection } from './types';
|
||||
import {
|
||||
MAX_VELOCITY_AGE,
|
||||
MIN_SETTLE_DURATION,
|
||||
MIN_VELOCITY_DT,
|
||||
REVERSE_CANCEL_ARM_DISTANCE,
|
||||
REVERSE_CANCEL_THRESHOLD,
|
||||
SETTLE_VELOCITY_THRESHOLD,
|
||||
TRANSITIONS,
|
||||
} from './constants';
|
||||
|
||||
/** The client-coordinate axis a drawer drags along. */
|
||||
export type GestureAxis = 'x' | 'y';
|
||||
|
||||
export interface VelocityTracker {
|
||||
/** Record a pointer sample (client coordinate along the axis + event timeStamp). */
|
||||
add: (position: number, time: number) => void;
|
||||
/**
|
||||
* Instantaneous velocity (px/ms) over the two newest samples. Returns 0 when
|
||||
* the last sample is older than {@link MAX_VELOCITY_AGE} — the pointer paused
|
||||
* before release, so no fling momentum should apply.
|
||||
*/
|
||||
read: (now: number) => number;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantaneous release velocity from the trailing pair of pointer samples,
|
||||
* instead of averaging the whole gesture: "slow pull, then flick" reads as a
|
||||
* flick, and "fast start, stop, release" reads as a stop.
|
||||
*/
|
||||
export function createVelocityTracker(): VelocityTracker {
|
||||
let lastPosition = 0;
|
||||
let lastTime = Number.NaN;
|
||||
let velocity = 0;
|
||||
|
||||
return {
|
||||
add(position, time) {
|
||||
if (!Number.isNaN(lastTime) && time > lastTime) {
|
||||
// Clamp dt so same-frame event bursts don't produce huge spikes.
|
||||
const dt = Math.max(time - lastTime, MIN_VELOCITY_DT);
|
||||
velocity = (position - lastPosition) / dt;
|
||||
}
|
||||
|
||||
lastPosition = position;
|
||||
lastTime = time;
|
||||
},
|
||||
read(now) {
|
||||
if (Number.isNaN(lastTime) || now - lastTime > MAX_VELOCITY_AGE)
|
||||
return 0;
|
||||
|
||||
return velocity;
|
||||
},
|
||||
reset() {
|
||||
lastPosition = 0;
|
||||
lastTime = Number.NaN;
|
||||
velocity = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface ReverseCancelTracker {
|
||||
/** Feed the current dismiss-positive displacement (px). */
|
||||
update: (displacement: number) => void;
|
||||
/** Whether the gesture pulled back far enough to cancel the dismiss. */
|
||||
readonly cancelled: boolean;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the "changed my mind" gesture: once the drawer has been dragged at
|
||||
* least {@link REVERSE_CANCEL_ARM_DISTANCE} toward dismiss, pulling back by
|
||||
* {@link REVERSE_CANCEL_THRESHOLD} from the furthest point cancels the dismiss
|
||||
* even if the release still sits past the close threshold. Dragging past the
|
||||
* previous furthest point re-arms the dismiss (renewed intent).
|
||||
*/
|
||||
export function createReverseCancelTracker(): ReverseCancelTracker {
|
||||
let max = 0;
|
||||
let cancelled = false;
|
||||
|
||||
return {
|
||||
update(displacement) {
|
||||
if (displacement >= max) {
|
||||
max = displacement;
|
||||
cancelled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (max > REVERSE_CANCEL_ARM_DISTANCE && max - displacement > REVERSE_CANCEL_THRESHOLD)
|
||||
cancelled = true;
|
||||
},
|
||||
get cancelled() {
|
||||
return cancelled;
|
||||
},
|
||||
reset() {
|
||||
max = 0;
|
||||
cancelled = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle duration (in seconds) scaled by the release velocity: a hard flick
|
||||
* over a short remaining distance settles in as little as
|
||||
* {@link MIN_SETTLE_DURATION}ms, while a gentle release keeps the default
|
||||
* {@link TRANSITIONS} duration. Never returns a duration longer than the default.
|
||||
*/
|
||||
export function computeSettleDuration(remainingDistance: number, velocity: number): number {
|
||||
const fallback = TRANSITIONS.DURATION;
|
||||
|
||||
if (!Number.isFinite(remainingDistance) || remainingDistance <= 0)
|
||||
return fallback;
|
||||
|
||||
const speed = Math.abs(velocity);
|
||||
|
||||
if (!Number.isFinite(speed) || speed < SETTLE_VELOCITY_THRESHOLD)
|
||||
return fallback;
|
||||
|
||||
return clamp(remainingDistance / speed, MIN_SETTLE_DURATION, fallback * 1000) / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* The nearest ancestor (from `start` up to and including `boundary`) that can
|
||||
* scroll along `axis`. The `getComputedStyle` read runs at most once per
|
||||
* candidate and only at gesture start — never per pointer move.
|
||||
*/
|
||||
export function findScrollableAncestor(
|
||||
start: Element | null,
|
||||
boundary: HTMLElement,
|
||||
axis: GestureAxis,
|
||||
): HTMLElement | null {
|
||||
let element: Element | null = start;
|
||||
|
||||
while (element) {
|
||||
if (element instanceof HTMLElement) {
|
||||
const canScroll = axis === 'y'
|
||||
? element.scrollHeight > element.clientHeight
|
||||
: element.scrollWidth > element.clientWidth;
|
||||
|
||||
if (canScroll) {
|
||||
const overflow = getComputedStyle(element)[axis === 'y' ? 'overflowY' : 'overflowX'];
|
||||
|
||||
if (overflow === 'auto' || overflow === 'scroll')
|
||||
return element;
|
||||
}
|
||||
}
|
||||
|
||||
if (element === boundary)
|
||||
break;
|
||||
|
||||
element = element.parentElement;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a scroll container sits at the edge the dismiss gesture pulls away
|
||||
* from — only then may a drag that starts inside it become a drawer gesture;
|
||||
* otherwise the user is scrolling, not dismissing:
|
||||
* - `bottom` drawer dismisses downward → the scroller must be at its top;
|
||||
* - `top` drawer dismisses upward → at its bottom;
|
||||
* - `right` drawer dismisses rightward → at its left edge;
|
||||
* - `left` drawer dismisses leftward → at its right edge.
|
||||
*/
|
||||
export function isAtScrollEdge(scroller: HTMLElement, direction: DrawerDirection): boolean {
|
||||
switch (direction) {
|
||||
case 'bottom':
|
||||
return scroller.scrollTop <= 0;
|
||||
case 'top':
|
||||
return scroller.scrollTop >= scroller.scrollHeight - scroller.clientHeight;
|
||||
case 'right':
|
||||
return scroller.scrollLeft <= 0;
|
||||
case 'left':
|
||||
return scroller.scrollLeft >= scroller.scrollWidth - scroller.clientWidth;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DrawerDirection } from './types';
|
||||
import { WINDOW_TOP_OFFSET } from './constants';
|
||||
|
||||
/**
|
||||
* Whether a direction runs along the vertical axis (`top`/`bottom`) as opposed
|
||||
@@ -25,3 +26,39 @@ export function dampenValue(v: number): number {
|
||||
export function getDrawerWrapper(): HTMLElement | null {
|
||||
return document.querySelector<HTMLElement>('[data-drawer-wrapper]');
|
||||
}
|
||||
|
||||
/**
|
||||
* The background-scale factor for a given window width (the stacked-card look
|
||||
* leaves {@link WINDOW_TOP_OFFSET}px of the page peeking out).
|
||||
*/
|
||||
export function getScaleFactor(windowWidth: number): number {
|
||||
return (windowWidth - WINDOW_TOP_OFFSET) / windowWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* A GPU-friendly translate along an axis, from a pre-resolved axis flag — the
|
||||
* drag hot path variant: no direction-string comparisons per frame.
|
||||
*/
|
||||
export function translateAxis(vertical: boolean, value: number): string {
|
||||
return vertical
|
||||
? `translate3d(0, ${value}px, 0)`
|
||||
: `translate3d(${value}px, 0, 0)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link translateAxis} keyed by direction, for cold paths that hold the
|
||||
* direction string rather than a gesture snapshot.
|
||||
*/
|
||||
export function translate3d(direction: DrawerDirection, value: number): string {
|
||||
return translateAxis(isVertical(direction), value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-frame single-property transform write for the drag hot path. Unlike
|
||||
* `setStyle` this allocates nothing (no patch object, no `Object.entries`, no
|
||||
* restore snapshot) — restoration is handled wholesale on release.
|
||||
*/
|
||||
export function writeTransform(element: HTMLElement | undefined | null, value: string): void {
|
||||
if (element)
|
||||
element.style.transform = value;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,15 @@ export { default as DrawerRootNested } from './DrawerRootNested.vue';
|
||||
export { default as DrawerContent } from './DrawerContent.vue';
|
||||
export { default as DrawerOverlay } from './DrawerOverlay.vue';
|
||||
export { default as DrawerHandle } from './DrawerHandle.vue';
|
||||
export { default as DrawerTrigger } from './DrawerTrigger.vue';
|
||||
export { default as DrawerClose } from './DrawerClose.vue';
|
||||
|
||||
export type { DrawerRootEmits, DrawerRootProps, DrawerHandleProps } from './controls';
|
||||
export type { DrawerContentEmits, DrawerContentProps } from './DrawerContent.vue';
|
||||
export type { DrawerOverlayProps } from './DrawerOverlay.vue';
|
||||
export type { DrawerDirection, SnapPoint } from './types';
|
||||
export type { DrawerTriggerProps } from './DrawerTrigger.vue';
|
||||
export type { DrawerCloseProps } from './DrawerClose.vue';
|
||||
export type { DrawerDirection, DrawerOpenChangeDetails, DrawerOpenChangeReason } from './types';
|
||||
|
||||
export { injectDrawerRootContext, provideDrawerRootContext } from './context';
|
||||
export type { DrawerRootContext } from './context';
|
||||
@@ -15,17 +19,13 @@ export type { DrawerRootContext } from './context';
|
||||
// Parts with no drawer-specific behaviour reuse Dialog directly, re-exported
|
||||
// under Drawer names so consumers stay within one namespace.
|
||||
export {
|
||||
DialogClose as DrawerClose,
|
||||
DialogDescription as DrawerDescription,
|
||||
DialogPortal as DrawerPortal,
|
||||
DialogTitle as DrawerTitle,
|
||||
DialogTrigger as DrawerTrigger,
|
||||
} from '../dialog';
|
||||
|
||||
export type {
|
||||
DialogCloseProps as DrawerCloseProps,
|
||||
DialogDescriptionProps as DrawerDescriptionProps,
|
||||
DialogPortalProps as DrawerPortalProps,
|
||||
DialogTitleProps as DrawerTitleProps,
|
||||
DialogTriggerProps as DrawerTriggerProps,
|
||||
} from '../dialog';
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { clamp } from '@robonen/stdlib';
|
||||
import type { DrawerDirection } from './types';
|
||||
import { MAX_SNAP_VELOCITY, SNAP_VELOCITY_MULTIPLIER, SNAP_VELOCITY_THRESHOLD } from './constants';
|
||||
|
||||
const PX_RE = /^-?(?:\d+(?:\.\d+)?|\.\d+)px$/;
|
||||
const REM_RE = /^-?(?:\d+(?:\.\d+)?|\.\d+)rem$/;
|
||||
|
||||
/**
|
||||
* Resolve a snap point to the visible size (px) it gives the drawer along the
|
||||
* drag axis:
|
||||
* - a number in (0, 1] is a fraction of the window dimension;
|
||||
* - a number above 1 is pixels;
|
||||
* - `'Npx'` / `'Nrem'` strings are pixels (rem scaled by the root font size).
|
||||
*
|
||||
* Unknown units (`'50%'`, `'10vh'`) and non-finite/non-positive results are
|
||||
* unsupported and resolve to `null` so they never reach the geometry as `NaN`.
|
||||
*/
|
||||
export function resolveSnapPointSize(
|
||||
point: number | string,
|
||||
windowSize: number,
|
||||
rootFontSize: number,
|
||||
): number | null {
|
||||
let size: number | null = null;
|
||||
|
||||
if (typeof point === 'number')
|
||||
size = point > 1 ? point : point * windowSize;
|
||||
else if (PX_RE.test(point))
|
||||
size = Number.parseFloat(point);
|
||||
else if (REM_RE.test(point))
|
||||
size = Number.parseFloat(point) * rootFontSize;
|
||||
|
||||
if (size === null || !Number.isFinite(size) || size <= 0)
|
||||
return null;
|
||||
|
||||
return Math.round(size);
|
||||
}
|
||||
|
||||
/**
|
||||
* The inline translate (px, signed the way the drawer's transform is written)
|
||||
* that shows exactly `point` worth of the drawer: positive toward the
|
||||
* bottom/right edge, negative toward the top/left edge, clamped so a snap point
|
||||
* larger than the window rests at fully open. Unresolvable points map to `NaN`
|
||||
* — callers must `Number.isFinite`-guard before using an offset.
|
||||
*/
|
||||
export function resolveSnapPointOffset(
|
||||
point: number | string,
|
||||
direction: DrawerDirection,
|
||||
windowSize: number,
|
||||
rootFontSize: number,
|
||||
): number {
|
||||
const size = resolveSnapPointSize(point, windowSize, rootFontSize);
|
||||
|
||||
if (size === null)
|
||||
return Number.NaN;
|
||||
|
||||
const distance = Math.max(Math.round(windowSize - size), 0);
|
||||
|
||||
return direction === 'bottom' || direction === 'right' ? distance : -distance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the active snap point: matched by identity first, then by resolved
|
||||
* size within a 1px tolerance, so a controlled drawer may use interchangeable
|
||||
* representations (`0.5` vs `'360px'` on a 720px window). Returns `null` when
|
||||
* nothing matches.
|
||||
*/
|
||||
export function findSnapPointIndex(
|
||||
snapPoints: Array<number | string>,
|
||||
active: number | string | null | undefined,
|
||||
windowSize: number,
|
||||
rootFontSize: number,
|
||||
): number | null {
|
||||
if (active === null || active === undefined)
|
||||
return null;
|
||||
|
||||
const byIdentity = snapPoints.indexOf(active);
|
||||
|
||||
if (byIdentity !== -1)
|
||||
return byIdentity;
|
||||
|
||||
const activeSize = resolveSnapPointSize(active, windowSize, rootFontSize);
|
||||
|
||||
if (activeSize === null)
|
||||
return null;
|
||||
|
||||
const bySize = snapPoints.findIndex((point) => {
|
||||
const size = resolveSnapPointSize(point, windowSize, rootFontSize);
|
||||
return size !== null && Math.abs(size - activeSize) <= 1;
|
||||
});
|
||||
|
||||
return bySize === -1 ? null : bySize;
|
||||
}
|
||||
|
||||
export interface SnapReleaseInput {
|
||||
/**
|
||||
* Snap offsets in dismiss-positive space: 0 is fully open, larger is more
|
||||
* hidden. `NaN` entries (unresolvable points) are skipped.
|
||||
*/
|
||||
offsets: number[];
|
||||
activeIndex: number | null;
|
||||
/** Drag distance since press, positive toward open/expand. */
|
||||
draggedDistance: number;
|
||||
/** Instantaneous release velocity, positive toward dismiss (px/ms). */
|
||||
velocity: number;
|
||||
/** Drawer size (px) along the drag axis — the fully-closed offset. */
|
||||
drawerSize: number;
|
||||
dismissible: boolean;
|
||||
/** Step at most one snap point per gesture instead of jumping to the nearest. */
|
||||
sequential: boolean;
|
||||
}
|
||||
|
||||
export type SnapReleaseResult = { type: 'close' } | { type: 'snap'; index: number };
|
||||
|
||||
/**
|
||||
* Where a snap-point drawer settles on release: the drag target is projected
|
||||
* ahead along the release velocity (a fling crosses points a slow drag would
|
||||
* not), then the nearest snap point wins — or the drawer closes when the
|
||||
* projection lands strictly closer to fully-closed and the drawer is
|
||||
* dismissible. In `sequential` mode the result is clamped to the snap point
|
||||
* adjacent to the active one.
|
||||
*/
|
||||
export function projectSnapRelease(input: SnapReleaseInput): SnapReleaseResult {
|
||||
const { offsets, activeIndex, draggedDistance, velocity, drawerSize, dismissible, sequential } = input;
|
||||
|
||||
const active = activeIndex !== null && Number.isFinite(offsets[activeIndex])
|
||||
? offsets[activeIndex]
|
||||
: 0;
|
||||
|
||||
// Where the drag alone left the drawer, clamped to its travel range.
|
||||
const dragTarget = clamp(active - draggedDistance, 0, drawerSize);
|
||||
|
||||
let target = dragTarget;
|
||||
|
||||
if (Math.abs(velocity) >= SNAP_VELOCITY_THRESHOLD)
|
||||
target = dragTarget + clamp(velocity, -MAX_SNAP_VELOCITY, MAX_SNAP_VELOCITY) * SNAP_VELOCITY_MULTIPLIER;
|
||||
|
||||
let closestIndex = -1;
|
||||
let closestDistance = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const [index, offset] of offsets.entries()) {
|
||||
if (!Number.isFinite(offset))
|
||||
continue;
|
||||
|
||||
const distance = Math.abs(target - offset);
|
||||
|
||||
if (distance < closestDistance) {
|
||||
closestIndex = index;
|
||||
closestDistance = distance;
|
||||
}
|
||||
}
|
||||
|
||||
if (closestIndex === -1)
|
||||
return { type: 'snap', index: activeIndex ?? 0 };
|
||||
|
||||
if (dismissible && Math.abs(target - drawerSize) < closestDistance)
|
||||
return { type: 'close' };
|
||||
|
||||
if (!sequential || activeIndex === null)
|
||||
return { type: 'snap', index: closestIndex };
|
||||
|
||||
// Sequential mode: rank the usable points by offset and move at most one
|
||||
// step toward the drag; a fast fling or a physical crossing of the adjacent
|
||||
// point advances, anything else stays.
|
||||
const stepDirection = Math.sign(dragTarget - active);
|
||||
|
||||
if (stepDirection === 0)
|
||||
return { type: 'snap', index: activeIndex };
|
||||
|
||||
const order = offsets
|
||||
.map((offset, index) => ({ offset, index }))
|
||||
.filter(entry => Number.isFinite(entry.offset))
|
||||
.sort((a, b) => a.offset - b.offset);
|
||||
|
||||
const rank = order.findIndex(entry => entry.index === activeIndex);
|
||||
|
||||
if (rank === -1)
|
||||
return { type: 'snap', index: closestIndex };
|
||||
|
||||
const adjacent = order[clamp(rank + stepDirection, 0, order.length - 1)];
|
||||
const flungPast = Math.sign(velocity) === stepDirection && Math.abs(velocity) >= SNAP_VELOCITY_THRESHOLD;
|
||||
const crossed = stepDirection > 0 ? target > adjacent.offset : target < adjacent.offset;
|
||||
|
||||
return { type: 'snap', index: flungPast || crossed ? adjacent.index : activeIndex };
|
||||
}
|
||||
@@ -8,7 +8,35 @@
|
||||
* The selectors here mirror the `data-drawer-*` attributes set in the component
|
||||
* templates and {@link ./controls} — keep them in sync.
|
||||
*/
|
||||
export const DRAWER_STYLE_ID = 'robonen-drawer';
|
||||
export const DRAWER_STYLE_ID = 'drawer';
|
||||
|
||||
let cssPropertiesRegistered = false;
|
||||
|
||||
/**
|
||||
* Registers the drawer's animated custom properties with `inherits: false`, so
|
||||
* a per-frame write of `--snap-point-height` on the content invalidates only
|
||||
* that element instead of cascading a var recompute over its whole subtree.
|
||||
* `--initial-transform` is deliberately NOT registered: consumers may set it on
|
||||
* an ancestor and rely on inheritance. No-op where the API is missing.
|
||||
*/
|
||||
export function registerDrawerCssProperties(): void {
|
||||
if (cssPropertiesRegistered || typeof CSS === 'undefined' || !CSS.registerProperty)
|
||||
return;
|
||||
|
||||
cssPropertiesRegistered = true;
|
||||
|
||||
try {
|
||||
CSS.registerProperty({
|
||||
name: '--snap-point-height',
|
||||
syntax: '<length>',
|
||||
inherits: false,
|
||||
initialValue: '0px',
|
||||
});
|
||||
}
|
||||
catch {
|
||||
// Older engines without @property support simply keep var inheritance.
|
||||
}
|
||||
}
|
||||
|
||||
export const DRAWER_STYLES = `
|
||||
[data-drawer] {
|
||||
|
||||
@@ -4,10 +4,25 @@
|
||||
export type DrawerDirection = 'top' | 'bottom' | 'left' | 'right';
|
||||
|
||||
/**
|
||||
* A resolved snap point: the original `fraction` (0–1 of the screen, or a raw
|
||||
* px value) paired with its computed pixel `height`.
|
||||
* Lifecycle phase of the drawer. `opening`/`closing` last for the duration of
|
||||
* the enter/exit animation; the settle signal (animation end or its fallback
|
||||
* timeout) advances them to `open`/`closed`.
|
||||
*/
|
||||
export interface SnapPoint {
|
||||
fraction: number;
|
||||
height: number;
|
||||
export type DrawerPhase = 'closed' | 'opening' | 'open' | 'closing';
|
||||
|
||||
/**
|
||||
* What flipped the drawer's open state. Absent details mean a programmatic
|
||||
* change (a controlled `v-model:open` write).
|
||||
*/
|
||||
export type DrawerOpenChangeReason
|
||||
= | 'swipe'
|
||||
| 'escape-key'
|
||||
| 'outside-press'
|
||||
| 'trigger-press'
|
||||
| 'close-press'
|
||||
| 'handle-press';
|
||||
|
||||
/** Extra context attached to `update:open`. */
|
||||
export interface DrawerOpenChangeDetails {
|
||||
reason?: DrawerOpenChangeReason;
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ export function usePositionFixed(options: PositionFixedOptions) {
|
||||
|
||||
Object.assign(document.body.style, previousBodyPosition);
|
||||
|
||||
globalThis.requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (preventScrollRestoration.value && activeUrl.value !== globalThis.location.href) {
|
||||
activeUrl.value = globalThis.location.href;
|
||||
return;
|
||||
|
||||
@@ -2,8 +2,8 @@ import { onWatcherCleanup, ref, watchEffect } from 'vue';
|
||||
import { isClient } from '@robonen/platform/multi';
|
||||
import { assignStyle } from '@robonen/platform/browsers';
|
||||
import { injectDrawerRootContext } from './context';
|
||||
import { getDrawerWrapper, isVertical } from './helpers';
|
||||
import { BORDER_RADIUS, TRANSITIONS, WINDOW_TOP_OFFSET } from './constants';
|
||||
import { getDrawerWrapper, getScaleFactor, isVertical } from './helpers';
|
||||
import { BORDER_RADIUS, TRANSITIONS } from './constants';
|
||||
|
||||
/**
|
||||
* Scales the page background down behind the drawer (the stacked-card effect),
|
||||
@@ -16,10 +16,6 @@ export function useScaleBackground() {
|
||||
const timeoutIdRef = ref<number | null>(null);
|
||||
const initialBackgroundColor = ref(typeof document !== 'undefined' ? document.body.style.backgroundColor : '');
|
||||
|
||||
function getScale() {
|
||||
return (window.innerWidth - WINDOW_TOP_OFFSET) / window.innerWidth;
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
// `flush: 'pre'` watchers run during SSR; this effect touches document/window,
|
||||
// so it must stay client-only.
|
||||
@@ -42,17 +38,18 @@ export function useScaleBackground() {
|
||||
transitionTimingFunction: `cubic-bezier(${TRANSITIONS.EASE.join(',')})`,
|
||||
});
|
||||
|
||||
const scale = getScaleFactor(window.innerWidth);
|
||||
const wrapperStylesCleanup = assignStyle(wrapper, {
|
||||
borderRadius: `${BORDER_RADIUS}px`,
|
||||
overflow: 'hidden',
|
||||
...(isVertical(direction.value)
|
||||
? { transform: `scale(${getScale()}) translate3d(0, calc(env(safe-area-inset-top) + 14px), 0)` }
|
||||
: { transform: `scale(${getScale()}) translate3d(calc(env(safe-area-inset-top) + 14px), 0, 0)` }),
|
||||
? { transform: `scale(${scale}) translate3d(0, calc(env(safe-area-inset-top) + 14px), 0)` }
|
||||
: { transform: `scale(${scale}) translate3d(calc(env(safe-area-inset-top) + 14px), 0, 0)` }),
|
||||
});
|
||||
|
||||
onWatcherCleanup(() => {
|
||||
wrapperStylesCleanup();
|
||||
timeoutIdRef.value = globalThis.setTimeout(() => {
|
||||
timeoutIdRef.value = setTimeout(() => {
|
||||
if (initialBackgroundColor.value)
|
||||
document.body.style.background = initialBackgroundColor.value;
|
||||
else
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Ref } from 'vue';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { computed, nextTick, watch } from 'vue';
|
||||
import { setStyle } from '@robonen/platform/browsers';
|
||||
import { useEventListener } from '@robonen/vue';
|
||||
import { isVertical } from './helpers';
|
||||
import { TRANSITIONS, VELOCITY_THRESHOLD } from './constants';
|
||||
import { isVertical, translateAxis, writeTransform } from './helpers';
|
||||
import { TRANSITIONS } from './constants';
|
||||
import { computeSettleDuration } from './gesture';
|
||||
import { findSnapPointIndex, projectSnapRelease, resolveSnapPointOffset } from './snapping';
|
||||
import type { DrawerDirection } from './types';
|
||||
|
||||
interface UseSnapPointsProps {
|
||||
@@ -14,16 +15,23 @@ interface UseSnapPointsProps {
|
||||
overlayRef: Ref<HTMLElement | undefined>;
|
||||
onSnapPointChange: (activeSnapPointIndex: number, snapPointsOffset: number[]) => void;
|
||||
direction: Ref<DrawerDirection>;
|
||||
snapToSequentialPoints: Ref<boolean>;
|
||||
/** Shared reactive window dimensions (from the engine's `useWindowSize`). */
|
||||
windowWidth: Ref<number>;
|
||||
windowHeight: Ref<number>;
|
||||
}
|
||||
|
||||
const transition = (property: 'transform' | 'opacity') =>
|
||||
`${property} ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
|
||||
const transition = (property: 'transform' | 'opacity', duration: number = TRANSITIONS.DURATION) =>
|
||||
`${property} ${duration}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
|
||||
|
||||
function readRootFontSize(): number {
|
||||
return Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag/release maths for drawers configured with snap points: resolves each
|
||||
* snap point to a pixel offset, animates the drawer between them, and decides
|
||||
* which point to settle on (or whether to close) based on drag distance and
|
||||
* velocity.
|
||||
* snap point to a pixel offset, animates the drawer between them, and settles
|
||||
* on release by projecting the drag target along the fling velocity.
|
||||
*/
|
||||
export function useSnapPoints({
|
||||
activeSnapPoint,
|
||||
@@ -33,26 +41,64 @@ export function useSnapPoints({
|
||||
fadeFromIndex,
|
||||
onSnapPointChange,
|
||||
direction,
|
||||
snapToSequentialPoints,
|
||||
windowWidth,
|
||||
windowHeight,
|
||||
}: UseSnapPointsProps) {
|
||||
const windowDimensions = ref(globalThis.window !== undefined
|
||||
? { innerWidth: window.innerWidth, innerHeight: window.innerHeight }
|
||||
: undefined);
|
||||
// Direction resolved once per change instead of string-comparing per move.
|
||||
const verticalAxis = computed(() => isVertical(direction.value));
|
||||
const dismissMultiplier = computed<1 | -1>(() =>
|
||||
direction.value === 'bottom' || direction.value === 'right' ? 1 : -1,
|
||||
);
|
||||
|
||||
function onResize() {
|
||||
const innerWidth = window.innerWidth;
|
||||
const innerHeight = window.innerHeight;
|
||||
const cur = windowDimensions.value;
|
||||
// Skip the ref write (and the snapPointsOffset recompute it would trigger)
|
||||
// when dimensions are unchanged — some resize events report identical sizes.
|
||||
if (!cur || cur.innerWidth !== innerWidth || cur.innerHeight !== innerHeight)
|
||||
windowDimensions.value = { innerWidth, innerHeight };
|
||||
function windowSizeFor(dir: DrawerDirection): number {
|
||||
return isVertical(dir) ? windowHeight.value : windowWidth.value;
|
||||
}
|
||||
|
||||
// Defaults to `defaultWindow` (SSR-safe) and auto-removes on scope dispose.
|
||||
useEventListener('resize', onResize);
|
||||
let warnedInvalid = false;
|
||||
|
||||
/**
|
||||
* Inline-translate offsets, index-aligned with `snapPoints` (identity such as
|
||||
* `fadeFromIndex` is preserved). Unresolvable points map to `NaN` and are
|
||||
* excluded from every settle decision.
|
||||
*/
|
||||
const snapPointsOffset = computed<number[]>(() => {
|
||||
const points = snapPoints.value;
|
||||
|
||||
if (!points)
|
||||
return [];
|
||||
|
||||
const windowSize = windowSizeFor(direction.value);
|
||||
const rootFontSize = globalThis.document !== undefined ? readRootFontSize() : 16;
|
||||
const offsets = points.map(point => resolveSnapPointOffset(point, direction.value, windowSize, rootFontSize));
|
||||
|
||||
if (!warnedInvalid && offsets.some(offset => !Number.isFinite(offset))) {
|
||||
warnedInvalid = true;
|
||||
console.warn(
|
||||
'[Drawer] Unsupported snap point value. Use a fraction (0-1), a px number, or a px/rem string:',
|
||||
points.filter((_, index) => !Number.isFinite(offsets[index])),
|
||||
);
|
||||
}
|
||||
|
||||
return offsets;
|
||||
});
|
||||
|
||||
const activeSnapPointIndex = computed<number | null>(() => {
|
||||
const points = snapPoints.value;
|
||||
|
||||
if (!points)
|
||||
return null;
|
||||
|
||||
const windowSize = windowSizeFor(direction.value);
|
||||
const rootFontSize = globalThis.document !== undefined ? readRootFontSize() : 16;
|
||||
|
||||
// Identity first, then resolved-size equivalence (`0.5` vs `'360px'`) so a
|
||||
// controlled active point in a different representation still matches.
|
||||
return findSnapPointIndex(points, activeSnapPoint.value, windowSize, rootFontSize);
|
||||
});
|
||||
|
||||
const isLastSnapPoint = computed(
|
||||
() => (snapPoints.value && activeSnapPoint.value === snapPoints.value[snapPoints.value.length - 1]) ?? null,
|
||||
() => (snapPoints.value && activeSnapPointIndex.value === snapPoints.value.length - 1) ?? null,
|
||||
);
|
||||
|
||||
const shouldFade = computed(
|
||||
@@ -65,58 +111,25 @@ export function useSnapPoints({
|
||||
|| !snapPoints.value,
|
||||
);
|
||||
|
||||
const activeSnapPointIndex = computed(
|
||||
() => snapPoints.value?.indexOf(activeSnapPoint.value) ?? null,
|
||||
);
|
||||
|
||||
const snapPointsOffset = computed(
|
||||
() =>
|
||||
snapPoints.value?.map((snapPoint) => {
|
||||
const isPx = typeof snapPoint === 'string';
|
||||
let snapPointAsNumber = 0;
|
||||
|
||||
if (isPx)
|
||||
snapPointAsNumber = Number.parseInt(snapPoint, 10);
|
||||
|
||||
if (isVertical(direction.value)) {
|
||||
const height = isPx
|
||||
? snapPointAsNumber
|
||||
: windowDimensions.value
|
||||
? (snapPoint as number) * windowDimensions.value.innerHeight
|
||||
: 0;
|
||||
|
||||
if (windowDimensions.value)
|
||||
return direction.value === 'bottom' ? windowDimensions.value.innerHeight - height : -windowDimensions.value.innerHeight + height;
|
||||
|
||||
return height;
|
||||
}
|
||||
|
||||
const width = isPx
|
||||
? snapPointAsNumber
|
||||
: windowDimensions.value
|
||||
? (snapPoint as number) * windowDimensions.value.innerWidth
|
||||
: 0;
|
||||
|
||||
if (windowDimensions.value)
|
||||
return direction.value === 'right' ? windowDimensions.value.innerWidth - width : -windowDimensions.value.innerWidth + width;
|
||||
|
||||
return width;
|
||||
}) ?? [],
|
||||
);
|
||||
|
||||
const activeSnapPointOffset = computed(() =>
|
||||
activeSnapPointIndex.value !== null ? snapPointsOffset.value?.[activeSnapPointIndex.value] : null,
|
||||
);
|
||||
|
||||
function snapToPoint(dimension: number) {
|
||||
function snapToPoint(dimension: number, options?: { velocity?: number; from?: number | null }) {
|
||||
if (!Number.isFinite(dimension))
|
||||
return;
|
||||
|
||||
const newSnapPointIndex = snapPointsOffset.value?.indexOf(dimension) ?? null;
|
||||
const from = options?.from;
|
||||
const remaining = typeof from === 'number' ? Math.abs(dimension - from) : Number.NaN;
|
||||
const duration = computeSettleDuration(remaining, options?.velocity ?? 0);
|
||||
|
||||
// Wait for the element to be mounted before transforming it.
|
||||
nextTick(() => {
|
||||
onSnapPointChange(newSnapPointIndex, snapPointsOffset.value);
|
||||
setStyle(drawerRef.value, {
|
||||
transition: transition('transform'),
|
||||
transform: isVertical(direction.value) ? `translate3d(0, ${dimension}px, 0)` : `translate3d(${dimension}px, 0, 0)`,
|
||||
transition: transition('transform', duration),
|
||||
transform: translateAxis(verticalAxis.value, dimension),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,22 +138,30 @@ export function useSnapPoints({
|
||||
&& newSnapPointIndex !== snapPointsOffset.value.length - 1
|
||||
&& newSnapPointIndex !== fadeFromIndex?.value
|
||||
) {
|
||||
setStyle(overlayRef.value, { transition: transition('opacity'), opacity: '0' });
|
||||
setStyle(overlayRef.value, { transition: transition('opacity', duration), opacity: '0' });
|
||||
}
|
||||
else {
|
||||
setStyle(overlayRef.value, { transition: transition('opacity'), opacity: '1' });
|
||||
setStyle(overlayRef.value, { transition: transition('opacity', duration), opacity: '1' });
|
||||
}
|
||||
|
||||
activeSnapPoint.value = newSnapPointIndex !== null ? snapPoints.value?.[newSnapPointIndex] ?? null : null;
|
||||
}
|
||||
|
||||
/** Settle back onto the active snap point (used when a gesture is aborted). */
|
||||
function restoreActiveSnapPoint() {
|
||||
const offset = activeSnapPointOffset.value;
|
||||
|
||||
if (typeof offset === 'number' && Number.isFinite(offset))
|
||||
snapToPoint(offset);
|
||||
}
|
||||
|
||||
watch(
|
||||
[activeSnapPoint, snapPointsOffset, snapPoints],
|
||||
() => {
|
||||
if (activeSnapPoint.value) {
|
||||
const newIndex = snapPoints.value?.indexOf(activeSnapPoint.value) ?? -1;
|
||||
const newIndex = activeSnapPointIndex.value ?? -1;
|
||||
|
||||
if (snapPointsOffset.value && newIndex !== -1 && typeof snapPointsOffset.value[newIndex] === 'number')
|
||||
if (snapPointsOffset.value && newIndex !== -1 && Number.isFinite(snapPointsOffset.value[newIndex]))
|
||||
snapToPoint(snapPointsOffset.value[newIndex]);
|
||||
}
|
||||
},
|
||||
@@ -152,89 +173,66 @@ export function useSnapPoints({
|
||||
closeDrawer,
|
||||
velocity,
|
||||
dismissible,
|
||||
drawerSize,
|
||||
}: {
|
||||
/** Drag distance since press, positive toward open/expand. */
|
||||
draggedDistance: number;
|
||||
closeDrawer: () => void;
|
||||
/** Instantaneous release velocity, positive toward dismiss (px/ms). */
|
||||
velocity: number;
|
||||
dismissible: boolean;
|
||||
/** Drawer size (px) along the drag axis. */
|
||||
drawerSize: number;
|
||||
}) {
|
||||
if (fadeFromIndex.value === undefined)
|
||||
return;
|
||||
|
||||
const currentPosition
|
||||
= direction.value === 'bottom' || direction.value === 'right'
|
||||
? (activeSnapPointOffset.value ?? 0) - draggedDistance
|
||||
: (activeSnapPointOffset.value ?? 0) + draggedDistance;
|
||||
const multiplier = dismissMultiplier.value;
|
||||
const offsets = snapPointsOffset.value.map(offset => offset * multiplier);
|
||||
const isOverlaySnapPoint = activeSnapPointIndex.value === fadeFromIndex.value - 1;
|
||||
const isFirst = activeSnapPointIndex.value === 0;
|
||||
const hasDraggedUp = draggedDistance > 0;
|
||||
|
||||
if (isOverlaySnapPoint)
|
||||
setStyle(overlayRef.value, { transition: transition('opacity') });
|
||||
|
||||
if (velocity > 2 && !hasDraggedUp) {
|
||||
if (dismissible)
|
||||
closeDrawer();
|
||||
else
|
||||
snapToPoint(snapPointsOffset.value[0]); // snap to initial point
|
||||
return;
|
||||
}
|
||||
|
||||
if (velocity > 2 && hasDraggedUp && snapPointsOffset.value && snapPoints.value) {
|
||||
snapToPoint(snapPointsOffset.value[snapPoints.value.length - 1]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Settle on the snap point closest to where the drag ended.
|
||||
const closestSnapPoint = snapPointsOffset.value?.reduce((prev, curr) => {
|
||||
if (typeof prev !== 'number' || typeof curr !== 'number')
|
||||
return prev;
|
||||
|
||||
return Math.abs(curr - currentPosition) < Math.abs(prev - currentPosition) ? curr : prev;
|
||||
const result = projectSnapRelease({
|
||||
offsets,
|
||||
activeIndex: activeSnapPointIndex.value,
|
||||
draggedDistance,
|
||||
velocity,
|
||||
drawerSize,
|
||||
dismissible,
|
||||
sequential: snapToSequentialPoints.value,
|
||||
});
|
||||
|
||||
const dim = isVertical(direction.value) ? window.innerHeight : window.innerWidth;
|
||||
if (velocity > VELOCITY_THRESHOLD && Math.abs(draggedDistance) < dim * 0.4) {
|
||||
const dragDirection = hasDraggedUp ? 1 : -1; // 1 = up, -1 = down
|
||||
|
||||
// Ignore an upward flick while already on the last snap point.
|
||||
if (dragDirection > 0 && isLastSnapPoint.value) {
|
||||
snapToPoint(snapPointsOffset.value[(snapPoints.value?.length ?? 0) - 1]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFirst && dragDirection < 0 && dismissible)
|
||||
closeDrawer();
|
||||
|
||||
if (activeSnapPointIndex.value === null)
|
||||
return;
|
||||
|
||||
snapToPoint(snapPointsOffset.value[activeSnapPointIndex.value + dragDirection]);
|
||||
if (result.type === 'close') {
|
||||
closeDrawer();
|
||||
return;
|
||||
}
|
||||
|
||||
snapToPoint(closestSnapPoint);
|
||||
const target = snapPointsOffset.value[result.index];
|
||||
const from = (activeSnapPointOffset.value ?? 0) - draggedDistance * multiplier;
|
||||
|
||||
snapToPoint(target, { velocity, from });
|
||||
}
|
||||
|
||||
function onDrag({ draggedDistance }: { draggedDistance: number }) {
|
||||
if (activeSnapPointOffset.value === null)
|
||||
return;
|
||||
function onDrag({ draggedDistance }: { draggedDistance: number }): number | null {
|
||||
const activeOffset = activeSnapPointOffset.value;
|
||||
|
||||
const newValue
|
||||
= direction.value === 'bottom' || direction.value === 'right'
|
||||
? (activeSnapPointOffset.value ?? 0) - draggedDistance
|
||||
: (activeSnapPointOffset.value ?? 0) + draggedDistance;
|
||||
if (activeOffset === null || activeOffset === undefined || !Number.isFinite(activeOffset))
|
||||
return null;
|
||||
|
||||
const positive = dismissMultiplier.value === 1;
|
||||
const newValue = positive ? activeOffset - draggedDistance : activeOffset + draggedDistance;
|
||||
const offsets = snapPointsOffset.value;
|
||||
const lastOffset = offsets[offsets.length - 1];
|
||||
|
||||
// Don't drag past the last (largest) snap point.
|
||||
if ((direction.value === 'bottom' || direction.value === 'right') && newValue < snapPointsOffset.value[snapPointsOffset.value.length - 1])
|
||||
return;
|
||||
if (Number.isFinite(lastOffset) && (positive ? newValue < lastOffset : newValue > lastOffset))
|
||||
return null;
|
||||
|
||||
if ((direction.value === 'top' || direction.value === 'left') && newValue > snapPointsOffset.value[snapPointsOffset.value.length - 1])
|
||||
return;
|
||||
writeTransform(drawerRef.value, translateAxis(verticalAxis.value, newValue));
|
||||
|
||||
setStyle(drawerRef.value, {
|
||||
transform: isVertical(direction.value) ? `translate3d(0, ${newValue}px, 0)` : `translate3d(${newValue}px, 0, 0)`,
|
||||
});
|
||||
return newValue;
|
||||
}
|
||||
|
||||
function getPercentageDragged(absDraggedDistance: number, isDraggingDown: boolean) {
|
||||
@@ -278,6 +276,7 @@ export function useSnapPoints({
|
||||
activeSnapPointIndex,
|
||||
onRelease,
|
||||
onDrag,
|
||||
restoreActiveSnapPoint,
|
||||
snapPointsOffset,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@robonen/vue",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Collection of powerful tools for Vue",
|
||||
"keywords": [
|
||||
|
||||
@@ -4,6 +4,7 @@ import { computed } from 'vue';
|
||||
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||
import { unrefElement } from '@/composables/component/unrefElement';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
const DEFAULT_DELAY = 500;
|
||||
const DEFAULT_THRESHOLD = 10;
|
||||
@@ -220,6 +221,11 @@ export function onLongPress(
|
||||
useEventListener(elementRef, ['pointerup', 'pointerleave'], onRelease, listenerOptions),
|
||||
];
|
||||
|
||||
// The listeners above self-dispose with the scope, but a delay timer armed
|
||||
// by a press that never released would outlive the component and fire the
|
||||
// handler against a dead scope.
|
||||
tryOnScopeDispose(clear);
|
||||
|
||||
return (): void => {
|
||||
clear();
|
||||
cleanups.forEach(stop => stop());
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { createApp, inject, onUnmounted, reactive, ref } from 'vue';
|
||||
import type { InjectionKey } from 'vue';
|
||||
import { runWithApp } from './index';
|
||||
|
||||
interface Settings {
|
||||
volume: number;
|
||||
}
|
||||
|
||||
const SettingsKey: InjectionKey<Settings> = Symbol('DemoSettings');
|
||||
|
||||
// Imagine this is your main.ts: the app provides DI values and is registered
|
||||
// once with `app.use(activeAppPlugin)`. The demo keeps a standalone app and
|
||||
// passes it explicitly so it does not touch the docs application.
|
||||
const app = createApp({ render: () => null });
|
||||
const settings = reactive<Settings>({ volume: 50 });
|
||||
app.provide(SettingsKey, settings);
|
||||
|
||||
onUnmounted(() => app.unmount());
|
||||
|
||||
// A plain module-level function — no setup, no injection context. With
|
||||
// `runWithApp` it can still resolve `inject()` against the app.
|
||||
function readVolumeFromOutside() {
|
||||
return runWithApp(() => inject(SettingsKey)!.volume, app);
|
||||
}
|
||||
|
||||
const snapshot = ref<number>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="demo-stack max-w-sm">
|
||||
<div class="demo-card p-4 flex flex-col gap-3">
|
||||
<span class="demo-label">App-provided state</span>
|
||||
|
||||
<label class="flex items-center gap-3 text-sm text-fg">
|
||||
<span class="text-xs text-fg-muted w-14">Volume</span>
|
||||
<input
|
||||
v-model.number="settings.volume"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
class="flex-1 accent-accent cursor-pointer"
|
||||
>
|
||||
<span class="font-mono text-xs tabular-nums text-fg-muted w-8 text-right">{{ settings.volume }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="demo-card p-4 flex flex-col gap-3">
|
||||
<span class="demo-label">Plain function, outside any component</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-transparent bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition hover:bg-accent-hover active:scale-[0.98] cursor-pointer"
|
||||
@click="snapshot = readVolumeFromOutside()"
|
||||
>
|
||||
runWithApp(() => inject(SettingsKey))
|
||||
</button>
|
||||
|
||||
<p class="font-mono text-xs tabular-nums text-fg-muted">
|
||||
{{ snapshot === undefined ? 'not read yet' : `injected volume: ${snapshot}` }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-fg-subtle">
|
||||
The function reading the value has no injection context of its own —
|
||||
<span class="font-mono text-fg-muted">runWithApp</span> wraps it in
|
||||
<span class="font-mono text-fg-muted">app.runWithContext</span> so
|
||||
<span class="font-mono text-fg-muted">inject()</span> resolves app-level provides.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,176 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createApp, defineComponent, h, inject, provide } from 'vue';
|
||||
import type { App, InjectionKey } from 'vue';
|
||||
import { activeAppPlugin, getActiveApp, injectWithApp, runWithApp, setActiveApp } from '.';
|
||||
import { VueToolsError } from '@/utils';
|
||||
|
||||
const key: InjectionKey<string> = Symbol('TestKey');
|
||||
|
||||
function makeApp(setup?: () => void) {
|
||||
return createApp(defineComponent({
|
||||
setup() {
|
||||
setup?.();
|
||||
return () => h('div');
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function mountApp(app: App) {
|
||||
app.mount(document.createElement('div'));
|
||||
return app;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActiveApp(undefined);
|
||||
});
|
||||
|
||||
describe(setActiveApp, () => {
|
||||
it('registers the app and returns it for chaining', () => {
|
||||
const app = makeApp();
|
||||
|
||||
expect(getActiveApp()).toBeUndefined();
|
||||
expect(setActiveApp(app)).toBe(app);
|
||||
expect(getActiveApp()).toBe(app);
|
||||
});
|
||||
|
||||
it('clears the registration with undefined', () => {
|
||||
setActiveApp(makeApp());
|
||||
setActiveApp(undefined);
|
||||
|
||||
expect(getActiveApp()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe(getActiveApp, () => {
|
||||
it('prefers the current instance app over the registered one', () => {
|
||||
const other = makeApp();
|
||||
setActiveApp(other);
|
||||
|
||||
let captured: App | undefined;
|
||||
const app = mountApp(makeApp(() => {
|
||||
captured = getActiveApp();
|
||||
}));
|
||||
|
||||
expect(captured).toBe(app);
|
||||
expect(captured).not.toBe(other);
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe(runWithApp, () => {
|
||||
it('resolves app-level provides through the active app', () => {
|
||||
const app = makeApp();
|
||||
app.provide(key, 'from app');
|
||||
setActiveApp(app);
|
||||
|
||||
expect(runWithApp(() => inject(key))).toBe('from app');
|
||||
});
|
||||
|
||||
it('uses an explicitly passed app over the active one', () => {
|
||||
const active = makeApp();
|
||||
active.provide(key, 'active');
|
||||
setActiveApp(active);
|
||||
|
||||
const explicit = makeApp();
|
||||
explicit.provide(key, 'explicit');
|
||||
|
||||
expect(runWithApp(() => inject(key), explicit)).toBe('explicit');
|
||||
});
|
||||
|
||||
it('returns the function result', () => {
|
||||
setActiveApp(makeApp());
|
||||
|
||||
expect(runWithApp(() => 42)).toBe(42);
|
||||
});
|
||||
|
||||
it('throws when no app is available', () => {
|
||||
expect(() => runWithApp(() => inject(key))).toThrow(VueToolsError);
|
||||
});
|
||||
});
|
||||
|
||||
describe(injectWithApp, () => {
|
||||
it('resolves app-level provides outside of setup', () => {
|
||||
const app = makeApp();
|
||||
app.provide(key, 'from app');
|
||||
setActiveApp(app);
|
||||
|
||||
expect(injectWithApp(key)).toBe('from app');
|
||||
});
|
||||
|
||||
it('behaves like inject inside setup, component provides win', () => {
|
||||
const app = makeApp();
|
||||
app.provide(key, 'app level');
|
||||
setActiveApp(app);
|
||||
|
||||
let fromParent: string | undefined;
|
||||
const Child = defineComponent({
|
||||
setup() {
|
||||
fromParent = injectWithApp(key);
|
||||
return () => h('div');
|
||||
},
|
||||
});
|
||||
|
||||
const host = createApp(defineComponent({
|
||||
setup() {
|
||||
provide(key, 'component level');
|
||||
return () => h(Child);
|
||||
},
|
||||
}));
|
||||
host.provide(key, 'host app level');
|
||||
mountApp(host);
|
||||
|
||||
expect(fromParent).toBe('component level');
|
||||
|
||||
host.unmount();
|
||||
});
|
||||
|
||||
it('falls back to the default value when the key is not provided', () => {
|
||||
setActiveApp(makeApp());
|
||||
|
||||
expect(injectWithApp(key, 'fallback')).toBe('fallback');
|
||||
expect(injectWithApp(key, () => 'factory', true)).toBe('factory');
|
||||
});
|
||||
|
||||
it('returns the default value when no app is available at all', () => {
|
||||
expect(injectWithApp(key, 'fallback')).toBe('fallback');
|
||||
expect(injectWithApp(key, () => 'factory', true)).toBe('factory');
|
||||
});
|
||||
|
||||
it('does not call a function default unless treated as factory', () => {
|
||||
const fn = vi.fn(() => 'value');
|
||||
const injected = injectWithApp<() => string>(Symbol('FnKey'), fn);
|
||||
|
||||
expect(injected).toBe(fn);
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when there is no context, no app and no default', () => {
|
||||
expect(() => injectWithApp(key)).toThrow(VueToolsError);
|
||||
});
|
||||
});
|
||||
|
||||
describe(activeAppPlugin, () => {
|
||||
it('registers the app on install', () => {
|
||||
const app = makeApp().use(activeAppPlugin);
|
||||
|
||||
expect(getActiveApp()).toBe(app);
|
||||
});
|
||||
|
||||
it('clears the registration when the app unmounts', () => {
|
||||
const app = mountApp(makeApp().use(activeAppPlugin));
|
||||
|
||||
app.unmount();
|
||||
|
||||
expect(getActiveApp()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the registration when a stale app unmounts', () => {
|
||||
const first = mountApp(makeApp().use(activeAppPlugin));
|
||||
const second = makeApp().use(activeAppPlugin);
|
||||
|
||||
first.unmount();
|
||||
|
||||
expect(getActiveApp()).toBe(second);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { getCurrentInstance, hasInjectionContext, inject } from 'vue';
|
||||
import type { App, InjectionKey, Plugin } from 'vue';
|
||||
import { VueToolsError } from '@/utils';
|
||||
|
||||
type InjectDefaults<Value> = [defaultValue?: Value | (() => Value), treatDefaultAsFactory?: boolean];
|
||||
|
||||
let activeApp: App | undefined;
|
||||
|
||||
/**
|
||||
* @name setActiveApp
|
||||
* @category State
|
||||
* @description Registers the Vue app instance used by `getActiveApp`, `runWithApp` and `injectWithApp`
|
||||
* outside of component context. Pass `undefined` to clear the registration.
|
||||
*
|
||||
* The registration is module-global (one slot per JS realm). On the client this is exactly
|
||||
* what you want; on the server create one app per request and prefer passing the app
|
||||
* explicitly to `runWithApp` instead of relying on the global slot, otherwise concurrent
|
||||
* requests may observe each other's app.
|
||||
*
|
||||
* @param {App | undefined} app The app to register, or `undefined` to clear
|
||||
* @returns {App | undefined} The same app, for chaining
|
||||
*
|
||||
* @example
|
||||
* // main.ts
|
||||
* const app = createApp(App);
|
||||
* setActiveApp(app);
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
export function setActiveApp(app: App | undefined) {
|
||||
activeApp = app;
|
||||
return app;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name getActiveApp
|
||||
* @category State
|
||||
* @description Returns the closest Vue app instance: the current component's app when called
|
||||
* during setup (or anywhere `getCurrentInstance` works), otherwise the app registered via
|
||||
* `setActiveApp` / `activeAppPlugin`.
|
||||
*
|
||||
* @returns {App | undefined} The resolved app, or `undefined` when none is available
|
||||
*
|
||||
* @example
|
||||
* const app = getActiveApp();
|
||||
* app?.config.globalProperties;
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
export function getActiveApp(): App | undefined {
|
||||
return getCurrentInstance()?.appContext.app ?? activeApp;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name runWithApp
|
||||
* @category State
|
||||
* @description Runs a function inside `app.runWithContext`, so `inject` (and everything built
|
||||
* on it) resolves app-level provides even outside of component setup — in router guards,
|
||||
* store actions, event handlers or timers.
|
||||
*
|
||||
* The app defaults to `getActiveApp()`; pass one explicitly to target a specific app
|
||||
* (recommended for SSR, where apps are created per request).
|
||||
*
|
||||
* @param {Function} fn The function to run with the app as injection context
|
||||
* @param {App} [app] The app to use instead of the active one
|
||||
* @returns The return value of `fn`
|
||||
* @throws {VueToolsError} when no app is registered and none is passed
|
||||
*
|
||||
* @example
|
||||
* router.beforeEach(() => {
|
||||
* const auth = runWithApp(() => inject(AuthKey));
|
||||
* });
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
export function runWithApp<Result>(fn: () => Result, app: App | undefined = getActiveApp()): Result {
|
||||
if (!app)
|
||||
throw new VueToolsError('runWithApp: no active Vue app, install activeAppPlugin or call setActiveApp first');
|
||||
|
||||
return app.runWithContext(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name injectWithApp
|
||||
* @category State
|
||||
* @description Drop-in replacement for `inject` that also works outside of component setup.
|
||||
* Inside an injection context it behaves exactly like `inject` (component-level provides
|
||||
* win); outside it resolves app-level provides through the active app. When no app is
|
||||
* available it falls back to the provided default value, or throws if there is none.
|
||||
*
|
||||
* @param {InjectionKey | string} key The injection key
|
||||
* @param {any} [defaultValue] The value (or factory) to fall back to when the key is not provided
|
||||
* @param {boolean} [treatDefaultAsFactory] Call `defaultValue` as a factory, like `inject`
|
||||
* @returns The injected value
|
||||
* @throws {VueToolsError} when called with no injection context, no active app and no default value
|
||||
*
|
||||
* @example
|
||||
* const theme = injectWithApp(ThemeKey, 'light');
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
export function injectWithApp<Value>(key: InjectionKey<Value> | string): Value | undefined;
|
||||
export function injectWithApp<Value>(key: InjectionKey<Value> | string, defaultValue: Value, treatDefaultAsFactory?: false): Value;
|
||||
export function injectWithApp<Value>(key: InjectionKey<Value> | string, defaultValue: Value | (() => Value), treatDefaultAsFactory: true): Value;
|
||||
export function injectWithApp<Value>(key: InjectionKey<Value> | string, ...defaults: InjectDefaults<Value>): Value | undefined {
|
||||
// spread `defaults` as-is: `inject` distinguishes a missing default from an
|
||||
// explicit `undefined` one via `arguments.length`
|
||||
const doInject = () => (inject as (...args: [typeof key, ...InjectDefaults<Value>]) => Value | undefined)(key, ...defaults);
|
||||
|
||||
if (hasInjectionContext())
|
||||
return doInject();
|
||||
|
||||
const app = getActiveApp();
|
||||
|
||||
if (app)
|
||||
return app.runWithContext(doInject);
|
||||
|
||||
if (defaults.length > 0) {
|
||||
const [defaultValue, treatDefaultAsFactory] = defaults;
|
||||
|
||||
return treatDefaultAsFactory && typeof defaultValue === 'function'
|
||||
? (defaultValue as () => Value)()
|
||||
: defaultValue as Value;
|
||||
}
|
||||
|
||||
throw new VueToolsError('injectWithApp: no injection context and no active Vue app, install activeAppPlugin or call setActiveApp first');
|
||||
}
|
||||
|
||||
/**
|
||||
* @name activeAppPlugin
|
||||
* @category State
|
||||
* @description Vue plugin that registers the app as the active one and clears the
|
||||
* registration when the app unmounts (unless another app took over in the meantime).
|
||||
*
|
||||
* @example
|
||||
* // main.ts
|
||||
* createApp(App).use(activeAppPlugin).mount('#app');
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
export const activeAppPlugin: Plugin = {
|
||||
install(app) {
|
||||
setActiveApp(app);
|
||||
|
||||
app.onUnmount(() => {
|
||||
if (activeApp === app)
|
||||
setActiveApp(undefined);
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './activeApp';
|
||||
export * from './createSharedComposable';
|
||||
export * from './useAppSharedState';
|
||||
export * from './useAsyncState';
|
||||
@@ -11,6 +12,7 @@ export * from './useLastChanged';
|
||||
export * from './useManualRefHistory';
|
||||
export * from './useOffsetPagination';
|
||||
export * from './useRefHistory';
|
||||
export * from './useStateMachine';
|
||||
export * from './useStepper';
|
||||
export * from './useThrottledRefHistory';
|
||||
export * from './useToggle';
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { useStateMachine } from './index';
|
||||
|
||||
// A media-player transport: the machine makes the button matrix declarative —
|
||||
// what each control does (and whether it's enabled) follows from the state.
|
||||
const { state, send, can, matches } = useStateMachine({
|
||||
initial: 'stopped',
|
||||
states: {
|
||||
stopped: { on: { PLAY: 'playing' } },
|
||||
playing: { on: { PAUSE: 'paused', STOP: 'stopped' } },
|
||||
paused: { on: { PLAY: 'playing', STOP: 'stopped' } },
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="demo-stack max-w-sm">
|
||||
<div class="demo-card p-4">
|
||||
<p class="demo-label">
|
||||
Media transport
|
||||
</p>
|
||||
|
||||
<div class="mt-3 flex items-center gap-3">
|
||||
<span
|
||||
class="demo-badge"
|
||||
:class="matches('playing') ? 'text-emerald-600 dark:text-emerald-400' : ''"
|
||||
>
|
||||
{{ matches('playing') ? '▶' : matches('paused') ? '⏸' : '⏹' }} {{ state }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="demo-btn-primary flex-1 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
|
||||
:disabled="!can('PLAY')"
|
||||
@click="send('PLAY')"
|
||||
>
|
||||
Play
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="demo-btn flex-1 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
|
||||
:disabled="!can('PAUSE')"
|
||||
@click="send('PAUSE')"
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="demo-btn flex-1 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
|
||||
:disabled="!can('STOP')"
|
||||
@click="send('STOP')"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { computed } from 'vue';
|
||||
import { useStateMachine } from './index';
|
||||
|
||||
function trafficLight() {
|
||||
return useStateMachine({
|
||||
initial: 'red',
|
||||
states: {
|
||||
red: { on: { NEXT: 'green' } },
|
||||
green: { on: { NEXT: 'yellow' } },
|
||||
yellow: { on: { NEXT: 'red' } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe(useStateMachine, () => {
|
||||
it('starts in the initial state', () => {
|
||||
const { state, matches } = trafficLight();
|
||||
|
||||
expect(state.value).toBe('red');
|
||||
expect(matches('red')).toBeTruthy();
|
||||
expect(matches('green')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('transitions on send and mirrors the state into the ref', () => {
|
||||
const { state, send } = trafficLight();
|
||||
|
||||
expect(send('NEXT')).toBe('green');
|
||||
expect(state.value).toBe('green');
|
||||
|
||||
send('NEXT');
|
||||
expect(state.value).toBe('yellow');
|
||||
});
|
||||
|
||||
it('ignores events without a matching transition', () => {
|
||||
const { state, send } = useStateMachine({
|
||||
initial: 'idle',
|
||||
states: {
|
||||
idle: { on: { START: 'running' } },
|
||||
running: {},
|
||||
},
|
||||
});
|
||||
|
||||
send('START');
|
||||
expect(send('START')).toBe('running');
|
||||
expect(state.value).toBe('running');
|
||||
});
|
||||
|
||||
it('is reactive: computeds tracking state/matches/can re-evaluate', () => {
|
||||
const { send, matches, can, state } = trafficLight();
|
||||
|
||||
const isRed = computed(() => matches('red'));
|
||||
const label = computed(() => state.value.toUpperCase());
|
||||
const canAdvance = computed(() => can('NEXT'));
|
||||
|
||||
expect(isRed.value).toBeTruthy();
|
||||
expect(label.value).toBe('RED');
|
||||
expect(canAdvance.value).toBeTruthy();
|
||||
|
||||
send('NEXT');
|
||||
|
||||
expect(isRed.value).toBeFalsy();
|
||||
expect(label.value).toBe('GREEN');
|
||||
});
|
||||
|
||||
it('respects guards and exposes them through can()', () => {
|
||||
const { state, send, can } = useStateMachine({
|
||||
initial: 'locked',
|
||||
context: { coins: 0 },
|
||||
states: {
|
||||
locked: {
|
||||
on: {
|
||||
PUSH: { target: 'open', guard: ctx => ctx.coins > 0 },
|
||||
COIN: { target: 'locked', action: (ctx) => { ctx.coins++; } },
|
||||
},
|
||||
},
|
||||
open: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(can('PUSH')).toBeFalsy();
|
||||
send('PUSH');
|
||||
expect(state.value).toBe('locked');
|
||||
|
||||
send('COIN');
|
||||
expect(can('PUSH')).toBeTruthy();
|
||||
send('PUSH');
|
||||
expect(state.value).toBe('open');
|
||||
});
|
||||
|
||||
it('runs action, exit, and entry hooks in order', () => {
|
||||
const order: string[] = [];
|
||||
|
||||
const { send } = useStateMachine({
|
||||
initial: 'a',
|
||||
states: {
|
||||
a: {
|
||||
exit: () => order.push('exit:a'),
|
||||
on: { GO: { target: 'b', action: () => order.push('action') } },
|
||||
},
|
||||
b: {
|
||||
entry: () => order.push('entry:b'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
send('GO');
|
||||
expect(order).toEqual(['action', 'exit:a', 'entry:b']);
|
||||
});
|
||||
|
||||
it('settles on the final state when hooks send follow-up events', () => {
|
||||
const machine = useStateMachine({
|
||||
initial: 'idle',
|
||||
states: {
|
||||
idle: { on: { START: 'transient' } },
|
||||
transient: {
|
||||
entry: () => machine.send('CONTINUE'),
|
||||
on: { CONTINUE: 'done' },
|
||||
},
|
||||
done: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(machine.send('START')).toBe('done');
|
||||
expect(machine.state.value).toBe('done');
|
||||
});
|
||||
|
||||
it('keeps can() reactive across context-mutating self-transitions', () => {
|
||||
const { send, can } = useStateMachine({
|
||||
initial: 'locked',
|
||||
context: { coins: 0 },
|
||||
states: {
|
||||
locked: {
|
||||
on: {
|
||||
PUSH: { target: 'open', guard: ctx => ctx.coins > 0 },
|
||||
COIN: { target: 'locked', action: (ctx) => { ctx.coins++; } },
|
||||
},
|
||||
},
|
||||
open: {},
|
||||
},
|
||||
});
|
||||
|
||||
const canPush = computed(() => can('PUSH'));
|
||||
|
||||
expect(canPush.value).toBeFalsy();
|
||||
|
||||
// Self-transition: the state string does not change, only the context.
|
||||
send('COIN');
|
||||
|
||||
expect(canPush.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps the state ref in sync when a hook throws', () => {
|
||||
const { state, send } = useStateMachine({
|
||||
initial: 'a',
|
||||
states: {
|
||||
a: { on: { GO: 'b' } },
|
||||
b: { entry: () => { throw new Error('boom'); } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => send('GO')).toThrow('boom');
|
||||
expect(state.value).toBe('b');
|
||||
});
|
||||
|
||||
it('exposes the raw machine with its context', () => {
|
||||
const onEnter = vi.fn();
|
||||
const { machine, send } = useStateMachine({
|
||||
initial: 'off',
|
||||
context: { toggles: 0 },
|
||||
states: {
|
||||
off: { on: { TOGGLE: { target: 'on', action: (ctx) => { ctx.toggles++; } } } },
|
||||
on: { entry: onEnter },
|
||||
},
|
||||
});
|
||||
|
||||
send('TOGGLE');
|
||||
|
||||
expect(machine.context.toggles).toBe(1);
|
||||
expect(machine.current).toBe('on');
|
||||
expect(machine.matches('on')).toBeTruthy();
|
||||
expect(onEnter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { shallowRef } from 'vue';
|
||||
import { StateMachine } from '@robonen/stdlib';
|
||||
import type { ExtractEvents, ExtractStates, SyncStateNodeConfig } from '@robonen/stdlib';
|
||||
import type { ShallowRef } from 'vue';
|
||||
|
||||
export interface UseStateMachineReturn<
|
||||
States extends string,
|
||||
Events extends string,
|
||||
Context,
|
||||
> {
|
||||
/** Reactive current state of the machine. */
|
||||
state: Readonly<ShallowRef<States>>;
|
||||
|
||||
/**
|
||||
* Send an event to the machine, potentially causing a transition.
|
||||
* Returns the state the machine settled on (entry/exit hooks may themselves
|
||||
* send events; the returned state is the final one).
|
||||
*/
|
||||
send: (event: Events) => States;
|
||||
|
||||
/** Reactive check: is the machine currently in `state`? */
|
||||
matches: (state: States) => boolean;
|
||||
|
||||
/** Reactive check: can `event` cause a transition from the current state? */
|
||||
can: (event: Events) => boolean;
|
||||
|
||||
/** The underlying stdlib machine (context access, non-reactive escape hatch). */
|
||||
machine: StateMachine<States, Events, Context>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useStateMachine
|
||||
* @category State
|
||||
* @description Reactive wrapper around the stdlib `StateMachine`: a type-safe
|
||||
* finite state machine whose current state is exposed as a shallow ref, so
|
||||
* templates and computeds can branch on `state`/`matches`/`can`.
|
||||
*
|
||||
* States, events, guards, and entry/exit hooks follow the stdlib
|
||||
* `createMachine` config verbatim — this composable only adds reactivity.
|
||||
*
|
||||
* @param {object} config Machine config: `initial`, optional `context`, and `states`
|
||||
* @returns {UseStateMachineReturn} Reactive state plus `send`/`matches`/`can` and the raw machine
|
||||
*
|
||||
* @example
|
||||
* const { state, send, can } = useStateMachine({
|
||||
* initial: 'idle',
|
||||
* states: {
|
||||
* idle: { on: { FETCH: 'loading' } },
|
||||
* loading: { on: { RESOLVE: 'idle', REJECT: 'failed' } },
|
||||
* failed: { on: { RETRY: 'loading' } },
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* send('FETCH'); // state.value === 'loading'
|
||||
* can('RETRY'); // false — reactive, usable in computeds/templates
|
||||
*
|
||||
* @since 0.2.0
|
||||
*/
|
||||
export function useStateMachine<
|
||||
const States extends Record<string, SyncStateNodeConfig<Context>>,
|
||||
Context,
|
||||
>(config: {
|
||||
initial: NoInfer<ExtractStates<States>>;
|
||||
context: Context;
|
||||
states: States;
|
||||
}): UseStateMachineReturn<ExtractStates<States>, ExtractEvents<States>, Context>;
|
||||
|
||||
export function useStateMachine<
|
||||
const States extends Record<string, SyncStateNodeConfig<undefined>>,
|
||||
>(config: {
|
||||
initial: NoInfer<ExtractStates<States>>;
|
||||
states: States;
|
||||
}): UseStateMachineReturn<ExtractStates<States>, ExtractEvents<States>, undefined>;
|
||||
|
||||
export function useStateMachine(config: {
|
||||
initial: string;
|
||||
context?: unknown;
|
||||
// Overload-implementation signature (mirrors stdlib `createMachine`): `any`
|
||||
// accepts every concrete `SyncStateNodeConfig<C>` — contravariant in `C` —
|
||||
// and `Context = undefined` keeps the invariant `StateMachine<..., Context>`
|
||||
// comparable with both public overloads.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
states: Record<string, SyncStateNodeConfig<any>>;
|
||||
}): UseStateMachineReturn<string, string, undefined> {
|
||||
const machine = new StateMachine(config.initial, config.states, config.context as undefined);
|
||||
|
||||
const state = shallowRef(machine.current);
|
||||
|
||||
// Bumped on EVERY send: a self-transition leaves the state string unchanged
|
||||
// (so `state` doesn't trigger) yet its action may mutate the context that
|
||||
// `can()` guards read.
|
||||
const epoch = shallowRef(0);
|
||||
|
||||
function send(event: string): string {
|
||||
// Mirror the settled state (not send's return value — entry/exit hooks may
|
||||
// send follow-up events) even when a hook throws: the machine has already
|
||||
// advanced by the time hooks run.
|
||||
try {
|
||||
machine.send(event);
|
||||
}
|
||||
finally {
|
||||
state.value = machine.current;
|
||||
epoch.value++;
|
||||
}
|
||||
|
||||
return machine.current;
|
||||
}
|
||||
|
||||
function matches(value: string): boolean {
|
||||
return state.value === value;
|
||||
}
|
||||
|
||||
function can(event: string): boolean {
|
||||
// Track the send epoch (it covers state changes too) so callers re-evaluate
|
||||
// after every transition, including context-mutating self-transitions.
|
||||
void epoch.value;
|
||||
|
||||
return machine.can(event);
|
||||
}
|
||||
|
||||
return { state, send, matches, can, machine };
|
||||
}
|
||||
Reference in New Issue
Block a user