feat(primitives): media-editor components, category reorg, perf + type cleanup

Reorganize components into category folders (forms/canvas/overlays/etc.); add the
media-editor headless family (timeline, curve-editor, waveform, crop, color
picker, etc.); apply perf fixes (O(1) collection lookups, plain-object drag
state, gesture-leak teardown, shallowRef color state, rect caching) and replace
source `any` with proper types.
This commit is contained in:
2026-06-15 16:54:29 +07:00
parent 661a55719e
commit eefd7abf83
1029 changed files with 65815 additions and 9449 deletions
@@ -0,0 +1,120 @@
<script lang="ts">
import type { DialogContentEmits, DialogContentProps } from '../dialog';
/**
* The draggable drawer panel. Wraps Dialog's Content (so it keeps focus
* trapping, scroll locking, and dismissal) and adds the pointer-drag gesture,
* snap-point positioning, and the `data-drawer-*` hooks the CSS animates.
* Bring your own size/colour/padding via `class`/`style`.
*/
export interface DrawerContentProps extends DialogContentProps {}
export type DrawerContentEmits = DialogContentEmits;
</script>
<script setup lang="ts">
import { computed, ref, watch, watchEffect } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { DialogContent } from '../dialog';
import { injectDrawerRootContext } from './context';
import { useScaleBackground } from './useScaleBackground';
defineProps<DrawerContentProps>();
const emit = defineEmits<DrawerContentEmits>();
const {
isOpen,
snapPointsOffset,
hasSnapPoints,
drawerRef,
onPress,
onDrag,
onRelease,
modal,
dismissible,
keyboardIsOpen,
direction,
handleOnly,
} = injectDrawerRootContext();
const { forwardRef, currentElement } = useForwardExpose();
// Track the content element for the drag math (undefined while closed/unmounted).
watch(currentElement, (el) => {
drawerRef.value = el as HTMLElement | undefined;
}, { immediate: true, flush: 'post' });
useScaleBackground();
const delayedSnapPoints = ref(false);
const snapPointHeight = computed(() => {
if (snapPointsOffset.value && snapPointsOffset.value.length > 0)
return `${snapPointsOffset.value[0]}px`;
return '0';
});
function handlePointerDownOutside(event: Event) {
if (!modal.value || event.defaultPrevented) {
event.preventDefault();
return;
}
if (keyboardIsOpen.value)
keyboardIsOpen.value = false;
// Let the underlying DismissableLayer close a dismissible modal drawer;
// otherwise hold it open.
if (!dismissible.value)
event.preventDefault();
}
function handleEscapeKeyDown(event: KeyboardEvent) {
if (!dismissible.value)
event.preventDefault();
}
function handlePointerDown(event: PointerEvent) {
if (handleOnly.value)
return;
onPress(event);
}
function handlePointerMove(event: PointerEvent) {
if (handleOnly.value)
return;
onDrag(event);
}
watchEffect(() => {
if (hasSnapPoints.value) {
globalThis.requestAnimationFrame(() => {
delayedSnapPoints.value = true;
});
}
});
</script>
<template>
<DialogContent
:ref="forwardRef"
data-drawer
:data-drawer-direction="direction"
:data-drawer-delayed-snap-points="delayedSnapPoints ? 'true' : 'false'"
:data-drawer-snap-points="isOpen && hasSnapPoints ? 'true' : 'false'"
:style="{ '--snap-point-height': snapPointHeight }"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="onRelease"
@open-auto-focus.prevent
@pointer-down-outside="handlePointerDownOutside"
@escape-key-down="handleEscapeKeyDown"
@close-auto-focus="emit('closeAutoFocus', $event)"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
@dismiss="emit('dismiss')"
>
<slot />
</DialogContent>
</template>
@@ -0,0 +1,122 @@
<script lang="ts">
import type { DrawerHandleProps } from './controls';
export type { DrawerHandleProps } from './controls';
/**
* The grab handle at the edge of the drawer. Dragging it always moves the
* drawer (even when the root is `handleOnly`), and a tap cycles through snap
* points — or closes a dismissible drawer once past the last one.
*/
</script>
<script setup lang="ts">
import { ref, useTemplateRef, watchPostEffect } from 'vue';
import { injectDrawerRootContext } from './context';
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 }
= injectDrawerRootContext();
// Mirror the element into the shared context ref. A local template ref + watch
// is used instead of an inline function `:ref` because the inline form can't
// reliably close over the destructured context binding under `<script setup>`.
const handleElement = useTemplateRef('handleElement');
watchPostEffect(() => {
handleRef.value = handleElement.value;
});
const closeTimeoutId = ref<number | null>(null);
const shouldCancelInteraction = ref(false);
function handleStartCycle() {
// Ignore the second tap of a double-tap.
if (shouldCancelInteraction.value) {
handleCancelInteraction();
return;
}
globalThis.setTimeout(() => {
handleCycleSnapPoints();
}, DOUBLE_TAP_TIMEOUT);
}
function handleCycleSnapPoints() {
// Don't treat an accidental tap during a resize as a cycle.
if (isDragging.value || preventCycle || shouldCancelInteraction.value) {
handleCancelInteraction();
return;
}
handleCancelInteraction();
if (!snapPoints.value || snapPoints.value.length === 0) {
if (!dismissible.value)
closeDrawer();
return;
}
const isLastSnapPoint = activeSnapPoint.value === snapPoints.value[snapPoints.value.length - 1];
if (isLastSnapPoint && dismissible.value) {
closeDrawer();
return;
}
const currentSnapIndex = snapPoints.value.indexOf(activeSnapPoint.value);
if (currentSnapIndex === -1)
return; // activeSnapPoint not in snapPoints
const nextSnapPointIndex = isLastSnapPoint ? 0 : currentSnapIndex + 1;
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 handlePointerDown(event: PointerEvent) {
if (handleOnly.value)
onPress(event);
handleStartInteraction();
}
function handlePointerMove(event: PointerEvent) {
if (handleOnly.value)
onDrag(event);
}
</script>
<template>
<div
ref="handleElement"
:data-drawer-visible="isOpen ? 'true' : 'false'"
data-drawer-handle
aria-hidden="true"
@click="handleStartCycle"
@pointercancel="handleCancelInteraction"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
>
<span data-drawer-handle-hitarea aria-hidden="true">
<slot />
</span>
</div>
</template>
@@ -0,0 +1,37 @@
<script lang="ts">
import type { DialogOverlayProps } from '../dialog';
/**
* The dimming layer behind the drawer. Wraps Dialog's Overlay and exposes the
* `data-drawer-overlay` hooks so its opacity can fade in step with the drag and
* with snap points. Only renders for modal drawers.
*/
export interface DrawerOverlayProps extends DialogOverlayProps {}
</script>
<script setup lang="ts">
import { watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { DialogOverlay } from '../dialog';
import { injectDrawerRootContext } from './context';
defineProps<DrawerOverlayProps>();
const { overlayRef, hasSnapPoints, isOpen, shouldFade } = injectDrawerRootContext();
const { forwardRef, currentElement } = useForwardExpose();
watch(currentElement, (el) => {
overlayRef.value = el as HTMLElement | undefined;
}, { immediate: true, flush: 'post' });
</script>
<template>
<DialogOverlay
:ref="forwardRef"
data-drawer-overlay
:data-drawer-snap-points="isOpen && hasSnapPoints ? 'true' : 'false'"
:data-drawer-snap-points-overlay="isOpen && shouldFade ? 'true' : 'false'"
>
<slot />
</DialogOverlay>
</template>
@@ -0,0 +1,115 @@
<script lang="ts">
import type { DrawerRootEmits, DrawerRootProps } from './controls';
export type { DrawerRootEmits, DrawerRootProps } from './controls';
/**
* A panel that slides in from an edge of the screen and can be dragged to
* dismiss — the Vaul-style drawer, rebuilt on top of this library's Dialog so it
* inherits focus trapping, scroll locking, and dismissal behaviour. Compose it
* from a Trigger, a Portal, an Overlay, and Content (optionally with a Handle,
* Title, Description, and Close).
*
* Bind `v-model:open` to control it, or rely on the Trigger/Close for
* uncontrolled use. Supports snap points (`v-model:active-snap-point`), four
* `direction`s, an optional scaled background, and nesting via DrawerRootNested.
*/
</script>
<script setup lang="ts">
import { computed, ref, toRefs, watch } from 'vue';
import { useStyleTag } from '@robonen/vue';
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';
defineOptions({ inheritAttrs: false });
const props = withDefaults(defineProps<DrawerRootProps>(), {
open: undefined,
defaultOpen: false,
fixed: undefined,
dismissible: true,
activeSnapPoint: undefined,
snapPoints: undefined,
shouldScaleBackground: undefined,
setBackgroundColorOnScale: true,
closeThreshold: CLOSE_THRESHOLD,
fadeFromIndex: undefined,
nested: false,
modal: true,
scrollLockTimeout: SCROLL_LOCK_TIMEOUT,
direction: 'bottom',
noBodyStyles: false,
handleOnly: false,
preventScrollRestoration: false,
});
const emit = defineEmits<DrawerRootEmits>();
// Inject the critical drawer CSS once (reference-counted across every drawer).
useStyleTag(DRAWER_STYLES, { id: DRAWER_STYLE_ID });
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
// controlled `open` prop (or `defaultOpen`), kept in sync with the prop while
// controlled, and is the ref the engine and the underlying Dialog both read.
const isOpen = ref<boolean>(props.open ?? props.defaultOpen);
watch(() => props.open, (value) => {
if (value !== undefined)
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,
);
const activeSnapPoint = computed<number | string | null | undefined>({
get: () => (props.activeSnapPoint !== undefined ? props.activeSnapPoint : localActiveSnapPoint.value),
set: (value) => {
if (props.activeSnapPoint === undefined)
localActiveSnapPoint.value = value;
if (value !== null && value !== undefined)
emit('update:activeSnapPoint', value);
},
});
const emitHandlers = {
emitDrag: (percentageDragged: number) => emit('drag', percentageDragged),
emitRelease: (o: boolean) => emit('release', o),
emitClose: () => emit('close'),
};
const { modal } = provideDrawerRootContext(
useDrawer({
...emitHandlers,
...toRefs(props),
activeSnapPoint,
fadeFromIndex,
open: isOpen,
}),
);
// 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) {
isOpen.value = o;
}
</script>
<template>
<DialogRoot :open="isOpen" :modal="modal" @update:open="handleOpenChange">
<slot :open="isOpen" />
</DialogRoot>
</template>
@@ -0,0 +1,54 @@
<script lang="ts">
/**
* A drawer nested inside another drawer. Behaves like DrawerRoot but reports its
* drag/open state to the parent so the parent visibly recedes as this one opens.
* Place it inside a parent DrawerRoot's content.
*/
</script>
<script setup lang="ts">
import DrawerRoot from './DrawerRoot.vue';
import type { DrawerRootEmits, DrawerRootProps } from './controls';
import { injectDrawerRootContext } from './context';
const props = defineProps<DrawerRootProps>();
const emit = defineEmits<DrawerRootEmits>();
const { onNestedDrag, onNestedOpenChange, onNestedRelease } = injectDrawerRootContext();
function onClose() {
onNestedOpenChange(false);
emit('close');
}
function onDrag(percentageDragged: number) {
onNestedDrag(percentageDragged);
emit('drag', percentageDragged);
}
function onRelease(open: boolean) {
onNestedRelease(open);
emit('release', open);
}
function onOpenChange(open: boolean) {
if (open)
onNestedOpenChange(open);
emit('update:open', open);
}
</script>
<template>
<DrawerRoot
v-bind="props"
nested
@close="onClose"
@drag="onDrag"
@release="onRelease"
@update:open="onOpenChange"
@update:active-snap-point="emit('update:activeSnapPoint', $event)"
@animation-end="emit('animationEnd', $event)"
>
<slot />
</DrawerRoot>
</template>
@@ -0,0 +1,221 @@
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 {
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHandle,
DrawerOverlay,
DrawerPortal,
DrawerRoot,
DrawerTitle,
DrawerTrigger,
} from '../index';
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
document.body.removeAttribute('style');
document.getElementById('robonen-drawer')?.remove();
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
/** Drains Vue's scheduler (including `flush: 'post'` watchers). */
async function flush(): Promise<void> {
await nextTick();
await nextTick();
await nextTick();
}
function $<T extends Element = HTMLElement>(selector: string): T | null {
return document.querySelector<T>(selector);
}
function $content(): HTMLElement | null {
return $('[data-drawer]');
}
function $trigger(): HTMLElement {
return $<HTMLElement>('[aria-haspopup="dialog"]')!;
}
function $close(): HTMLButtonElement | undefined {
return [...document.querySelectorAll('button')].find(b => b.textContent === 'Close');
}
interface MountOptions {
open?: boolean;
defaultOpen?: boolean;
modal?: boolean;
dismissible?: boolean;
direction?: 'top' | 'bottom' | 'left' | 'right';
withHandle?: boolean;
onUpdateOpen?: (v: boolean) => void;
onClose?: () => void;
}
function mountDrawer(options: MountOptions = {}) {
const { withHandle = true } = options;
const Wrapper = defineComponent({
setup() {
return () => h(
DrawerRoot,
{
open: options.open,
defaultOpen: options.defaultOpen,
modal: options.modal ?? true,
dismissible: options.dismissible ?? true,
direction: options.direction ?? 'bottom',
'onUpdate:open': options.onUpdateOpen,
onClose: options.onClose,
},
{
default: () => [
h(DrawerTrigger, null, { default: () => 'Open' }),
h(DrawerPortal, null, {
default: () => [
h(DrawerOverlay, { 'data-testid': 'overlay' }),
h(DrawerContent, null, {
default: () => [
withHandle ? h(DrawerHandle) : null,
h(DrawerTitle, null, { default: () => 'Title' }),
h(DrawerDescription, null, { default: () => 'Desc' }),
h(DrawerClose, null, { default: () => 'Close' }),
],
}),
],
}),
],
},
);
},
});
return track(mount(Wrapper, { attachTo: document.body }));
}
describe('Drawer / markup', () => {
it('renders closed by default', () => {
mountDrawer();
expect($trigger().getAttribute('data-state')).toBe('closed');
expect($content()).toBeNull();
});
it('injects the critical drawer stylesheet once', async () => {
mountDrawer({ defaultOpen: true });
await flush();
const tags = document.querySelectorAll('#robonen-drawer');
expect(tags.length).toBe(1);
expect(tags[0]!.textContent).toContain('@keyframes slideFromBottom');
});
it('exposes drawer data attributes on the content when open', async () => {
mountDrawer({ defaultOpen: true, direction: 'right' });
await flush();
const content = $content()!;
expect(content.getAttribute('data-state')).toBe('open');
expect(content.getAttribute('data-drawer-direction')).toBe('right');
expect(content.hasAttribute('data-drawer')).toBe(true);
});
it('renders the handle without throwing (handleRef wiring)', async () => {
mountDrawer({ defaultOpen: true, withHandle: true });
await flush();
expect($('[data-drawer-handle]')).toBeTruthy();
expect($('[data-drawer-handle-hitarea]')).toBeTruthy();
});
});
describe('Drawer / open state', () => {
it('opens when the trigger is clicked (uncontrolled)', async () => {
mountDrawer();
$trigger().click();
await flush();
expect($content()).toBeTruthy();
});
it('closes when DrawerClose is clicked', async () => {
mountDrawer({ defaultOpen: true });
await flush();
$close()!.click();
await flush();
// Presence keeps the node for the exit animation; data-state flips to closed.
expect($content()?.getAttribute('data-state') ?? 'closed').toBe('closed');
});
it('emits update:open when the trigger is clicked (controlled)', async () => {
const onUpdateOpen = vi.fn();
mountDrawer({ open: false, onUpdateOpen });
$trigger().click();
await flush();
expect(onUpdateOpen).toHaveBeenCalledWith(true);
});
it('emits close exactly once when dismissed via DrawerClose', async () => {
const onClose = vi.fn();
mountDrawer({ defaultOpen: true, onClose });
await flush();
$close()!.click();
await flush();
expect(onClose).toHaveBeenCalledTimes(1);
});
it('emits close when a controlled drawer is closed by flipping v-model:open', async () => {
// 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 state = ref(true);
const Wrapper = defineComponent({
setup() {
return () => h(
DrawerRoot,
{
open: state.value,
'onUpdate:open': (v: boolean) => { state.value = v; },
onClose,
},
{
default: () => h(DrawerPortal, null, {
default: () => h(DrawerContent, null, {
default: () => h(DrawerTitle, null, { default: () => 'Title' }),
}),
}),
},
);
},
});
track(mount(Wrapper, { attachTo: document.body }));
await flush();
expect($content()).toBeTruthy();
state.value = false;
await flush();
expect(onClose).toHaveBeenCalledTimes(1);
});
});
describe('Drawer / overlay', () => {
it('renders an overlay for modal drawers', async () => {
mountDrawer({ defaultOpen: true, modal: true });
await flush();
expect($('[data-drawer-overlay]')).toBeTruthy();
});
it('omits the overlay for non-modal drawers', async () => {
mountDrawer({ defaultOpen: true, modal: false });
await flush();
expect($('[data-drawer-overlay]')).toBeNull();
});
});
@@ -0,0 +1,26 @@
/** Open/close animation timing, shared by the CSS keyframes and the JS transitions. */
export const TRANSITIONS = {
DURATION: 0.5,
EASE: [0.32, 0.72, 0, 1],
} as const;
/** Drag speed (px/ms) above which a flick closes the drawer regardless of distance. */
export const VELOCITY_THRESHOLD = 0.4;
/** Default fraction of the drawer that must be swiped away before it closes. */
export const CLOSE_THRESHOLD = 0.25;
/** How long (ms) dragging stays disabled after scrolling content inside the drawer. */
export const SCROLL_LOCK_TIMEOUT = 100;
/** Corner radius (px) applied to the scaled background wrapper while open. */
export const BORDER_RADIUS = 8;
/** Pixels a parent drawer is displaced when a nested drawer opens. */
export const NESTED_DISPLACEMENT = 16;
/** Top inset (px) used when scaling the background, mimicking a stacked-card look. */
export const WINDOW_TOP_OFFSET = 26;
/** Class applied to the drawer element while a drag is in progress. */
export const DRAG_CLASS = 'drawer-dragging';
@@ -0,0 +1,84 @@
import type { Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
import type { MaybeElementRef } from '@robonen/vue';
import type { DrawerDirection } 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>;
/** 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. */
hasBeenOpened: Ref<boolean>;
/** DOM node of the drawer content (DialogContent), tracked for drag math. */
drawerRef: MaybeElementRef<HTMLElement | undefined>;
/** DOM node of the overlay, faded in sync with the drag. */
overlayRef: MaybeElementRef<HTMLElement | undefined>;
/** DOM node of the drag handle. */
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>;
/** 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). */
snapPoints: Ref<Array<number | string> | undefined>;
/** Whether any snap points are configured. */
hasSnapPoints: Ref<boolean>;
/** Whether the on-screen keyboard is currently considered open. */
keyboardIsOpen: Ref<boolean>;
/** The currently active snap point value (px string, fraction, or null). */
activeSnapPoint: Ref<number | string | null | undefined>;
/** Pointer coordinate (clientX/clientY) captured at drag start. */
pointerStart: Ref<number>;
/** Whether dragging/clicking outside/escape closes the drawer. */
dismissible: Ref<boolean>;
/** Measured height of the drawer content in px. */
drawerHeightRef: Ref<number>;
/** Pixel offset of each snap point along the drag axis. */
snapPointsOffset: Ref<number[]>;
/** The edge the drawer is anchored to. */
direction: Ref<DrawerDirection>;
/** Begin a drag gesture. */
onPress: (event: PointerEvent) => 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;
/** 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. */
fadeFromIndex: Ref<number | undefined>;
/** Whether the page background scales back while the drawer is open. */
shouldScaleBackground: Ref<boolean | undefined>;
/** Whether to set the body background to black during the scale effect. */
setBackgroundColorOnScale: Ref<boolean | undefined>;
/** Drive the parent drawer's transform as a nested child drawer is dragged. */
onNestedDrag: (percentageDragged: number) => void;
/** Settle the parent drawer when a nested child drawer is released. */
onNestedRelease: (o: boolean) => void;
/** React to a nested child drawer opening/closing. */
onNestedOpenChange: (o: boolean) => void;
/** Emit the root's `close` event. */
emitClose: () => void;
/** Emit the root's `drag` event with the drag percentage. */
emitDrag: (percentageDragged: number) => void;
/** Emit the root's `release` event. */
emitRelease: (open: boolean) => void;
/** Whether this drawer is nested inside another drawer. */
nested: Ref<boolean>;
/** Whether dragging is only permitted from the handle. */
handleOnly: Ref<boolean>;
/** Whether to skip Vaul-style body styling entirely. */
noBodyStyles: Ref<boolean>;
}
const ctx = useContextFactory<DrawerRootContext>('DrawerRootContext');
export const provideDrawerRootContext = ctx.provide;
export const injectDrawerRootContext = ctx.inject;
@@ -0,0 +1,647 @@
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 { useSnapPoints } from './useSnapPoints';
import { usePositionFixed } from './usePositionFixed';
import type { DrawerRootContext } from './context';
import type { DrawerDirection } from './types';
/** Shared, never-mutated — avoids allocating `{ transition: 'none' }` per drag frame. */
const STYLE_NO_TRANSITION = { transition: 'none' };
export interface WithoutFadeFromProps {
/**
* Fractions (01) 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.
*/
snapPoints?: Array<number | string>;
/** Index of the snap point from which the overlay fade begins. Defaults to the last. */
fadeFromIndex?: never;
}
export type DrawerRootProps = {
/** The active snap point (two-way bindable via `v-model:active-snap-point`). */
activeSnapPoint?: number | string | null;
/**
* Fraction (01) of the drawer that must be swiped away before it closes.
* @default 0.25
*/
closeThreshold?: number;
/** Scale the page background down while the drawer is open (stacked-card look). */
shouldScaleBackground?: boolean;
/**
* Set the body background to black during the scale effect.
* @default true
*/
setBackgroundColorOnScale?: boolean;
/**
* How long (ms) dragging is disabled after scrolling content inside the drawer.
* @default 100
*/
scrollLockTimeout?: number;
/**
* Keep the drawer in place when the keyboard opens, resizing it to stay
* scrollable instead of translating it upward.
*/
fixed?: boolean;
/**
* When `false`, dragging, clicking outside, and pressing escape will not close
* the drawer. Pair with `v-model:open` so you can still control it.
* @default true
*/
dismissible?: boolean;
/**
* When `false`, the rest of the page stays interactive while the drawer is open.
* @default true
*/
modal?: boolean;
/** Controlled open state (`v-model:open`). */
open?: boolean;
/**
* Start opened, skipping the initial enter animation. Still reacts to `open`.
* @default false
*/
defaultOpen?: boolean;
/** Marks this drawer as nested inside another (set automatically by DrawerRootNested). */
nested?: boolean;
/**
* The edge the drawer is anchored to.
* @default 'bottom'
*/
direction?: DrawerDirection;
/** Skip all body styling that the drawer would otherwise apply. */
noBodyStyles?: boolean;
/**
* Only allow dragging via the DrawerHandle.
* @default false
*/
handleOnly?: boolean;
/** Don't restore scroll position when the drawer closes after a navigation. */
preventScrollRestoration?: boolean;
} & WithoutFadeFromProps;
export interface UseDrawerProps {
open: Ref<boolean>;
snapPoints: Ref<Array<number | string> | undefined>;
dismissible: Ref<boolean>;
nested: Ref<boolean>;
fixed: Ref<boolean | undefined>;
modal: Ref<boolean>;
shouldScaleBackground: Ref<boolean | undefined>;
setBackgroundColorOnScale: Ref<boolean | undefined>;
activeSnapPoint: Ref<number | string | null | undefined>;
fadeFromIndex: Ref<number | undefined>;
closeThreshold: Ref<number>;
scrollLockTimeout: Ref<number>;
direction: Ref<DrawerDirection>;
noBodyStyles: Ref<boolean>;
preventScrollRestoration: Ref<boolean>;
handleOnly: Ref<boolean>;
}
export interface DrawerRootEmits {
/** Fired continuously during a drag with the fraction (01) dragged. */
(e: 'drag', percentageDragged: number): void;
/** Fired when the pointer is released, with whether the drawer stays open. */
(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 active snap point. */
(e: 'update:activeSnapPoint', val: string | number): void;
/** Fired after the open/close animation ends, with the open state at that time. */
(e: 'animationEnd', open: boolean): void;
}
export interface DialogEmitHandlers {
emitDrag: (percentageDragged: number) => void;
emitRelease: (open: boolean) => void;
emitClose: () => void;
}
export interface DrawerHandleProps {
/** Prevent the handle tap from cycling through snap points. */
preventCycle?: boolean;
}
function usePropOrDefaultRef<T>(prop: Ref<T | undefined> | undefined, defaultRef: Ref<T>): Ref<T> {
return prop && !!prop.value ? (prop as Ref<T>) : defaultRef;
}
/**
* The drawer engine: owns the drag gesture, snap-point settling, background
* scaling, and nested-drawer coordination. Returns the value provided as
* {@link DrawerRootContext} to the drawer parts.
*/
export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRootContext {
const {
emitDrag,
emitRelease,
emitClose,
open,
dismissible,
nested,
modal,
shouldScaleBackground,
setBackgroundColorOnScale,
scrollLockTimeout,
closeThreshold,
activeSnapPoint,
fadeFromIndex,
direction,
noBodyStyles,
handleOnly,
preventScrollRestoration,
} = props;
const hasBeenOpened = ref(open.value);
const isDragging = ref(false);
const justReleased = ref(false);
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);
const nestedOpenChangeTimer = ref<number | null>(null);
const pointerStart = ref(0);
const keyboardIsOpen = ref(false);
const drawerRef = shallowRef<HTMLElement | undefined>(undefined);
const drawerHeightRef = computed(() => drawerRef.value?.getBoundingClientRect().height || 0);
const snapPoints = usePropOrDefaultRef(props.snapPoints, ref<Array<number | string> | undefined>(undefined));
const hasSnapPoints = computed(() => !!(snapPoints.value?.length ?? 0));
const handleRef = shallowRef<HTMLElement | undefined>(undefined);
const {
activeSnapPointIndex,
onRelease: onReleaseSnapPoints,
snapPointsOffset,
onDrag: onDragSnapPoints,
shouldFade,
getPercentageDragged: getSnapPointsPercentageDragged,
} = useSnapPoints({
snapPoints,
activeSnapPoint,
drawerRef,
fadeFromIndex,
overlayRef,
onSnapPointChange,
direction,
});
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();
}
usePositionFixed({
isOpen: open,
modal,
nested,
hasBeenOpened,
noBodyStyles,
preventScrollRestoration,
});
function getScale() {
return (window.innerWidth - WINDOW_TOP_OFFSET) / window.innerWidth;
}
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]'))
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)
return false;
if (swipeAmount !== null) {
if (direction.value === 'bottom' ? swipeAmount > 0 : swipeAmount < 0)
return true;
}
// Don't drag when text is selected.
if (highlightedText && highlightedText.length > 0)
return false;
// Don't drag right after scrolling inside the drawer.
if (
lastTimeDragPrevented.value
&& date.getTime() - lastTimeDragPrevented.value.getTime() < scrollLockTimeout.value
&& swipeAmount === 0
) {
lastTimeDragPrevented.value = date;
return false;
}
if (isDraggingInDirection) {
lastTimeDragPrevented.value = date;
// 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;
}
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) {
if (!dismissible.value && !snapPoints.value)
return;
if (drawerRef.value && !drawerRef.value.contains(event.target as Node))
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;
}
function onDrag(event: PointerEvent) {
if (!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;
// 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);
const wrapper = dragWrapper;
// 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)`,
});
return;
}
const opacityValue = 1 - percentageDragged;
if (shouldFade.value || (fadeFromIndex.value && activeSnapPointIndex.value === fadeFromIndex.value - 1)) {
emitDrag(percentageDragged);
setStyle(overlayRef.value, { opacity: `${opacityValue}`, transition: 'none' }, true);
}
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);
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,
);
}
if (!snapPoints.value) {
const translateValue = absDraggedDistance * directionMultiplier;
setStyle(drawerRef.value, {
transform: isVertical(direction.value)
? `translate3d(0, ${translateValue}px, 0)`
: `translate3d(${translateValue}px, 0, 0)`,
});
}
}
}
function resetDrawer() {
if (!drawerRef.value)
return;
const wrapper = getDrawerWrapper();
const currentSwipeAmount = getTranslate(drawerRef.value, isVertical(direction.value) ? 'y' : 'x');
setStyle(drawerRef.value, {
transform: 'translate3d(0, 0, 0)',
transition: `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`,
});
setStyle(overlayRef.value, {
transition: `opacity ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`,
opacity: '1',
});
// Keep the background scaled if we didn't swipe back down.
if (shouldScaleBackground.value && currentSwipeAmount && currentSwipeAmount > 0 && open.value) {
setStyle(
wrapper,
{
borderRadius: `${BORDER_RADIUS}px`,
overflow: 'hidden',
...(isVertical(direction.value)
? {
transform: `scale(${getScale()}) 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)`,
transformOrigin: 'left',
}),
transitionProperty: 'transform, border-radius',
transitionDuration: `${TRANSITIONS.DURATION}s`,
transitionTimingFunction: `cubic-bezier(${TRANSITIONS.EASE.join(',')})`,
},
true,
);
}
}
// Flip the shared open state to false; every close side effect (emitClose, the
// 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() {
if (!drawerRef.value)
return;
open.value = false;
}
watchEffect(() => {
if (!open.value && shouldScaleBackground.value && isClient) {
// The component is invisible by the time onAnimationEnd would fire, so use a timeout.
const id = setTimeout(() => {
resetStyle(document.body);
}, 200);
return () => clearTimeout(id);
}
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.
watch(open, (o) => {
if (o) {
openTime.value = new Date();
hasBeenOpened.value = true;
}
else {
emitClose();
globalThis.setTimeout(() => {
if (snapPoints.value)
activeSnapPoint.value = snapPoints.value[0];
}, TRANSITIONS.DURATION * 1000);
}
});
function onNestedOpenChange(o: boolean) {
const scale = o ? (window.innerWidth - NESTED_DISPLACEMENT) / window.innerWidth : 1;
const y = o ? -NESTED_DISPLACEMENT : 0;
if (nestedOpenChangeTimer.value)
globalThis.clearTimeout(nestedOpenChangeTimer.value);
setStyle(drawerRef.value, {
transition: `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`,
transform: `scale(${scale}) translate3d(0, ${y}px, 0)`,
});
if (!o && drawerRef.value) {
nestedOpenChangeTimer.value = globalThis.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)`,
});
}, 500);
}
}
function onNestedDrag(percentageDragged: number) {
if (percentageDragged < 0)
return;
const initialDim = isVertical(direction.value) ? window.innerHeight : window.innerWidth;
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',
});
}
function onNestedRelease(o: boolean) {
const dim = isVertical(direction.value) ? window.innerHeight : window.innerWidth;
const scale = o ? (dim - NESTED_DISPLACEMENT) / dim : 1;
const translate = o ? -NESTED_DISPLACEMENT : 0;
if (o) {
setStyle(drawerRef.value, {
transition: `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`,
transform: isVertical(direction.value)
? `scale(${scale}) translate3d(0, ${translate}px, 0)`
: `scale(${scale}) translate3d(${translate}px, 0, 0)`,
});
}
}
return {
open,
isOpen: open,
modal,
keyboardIsOpen,
hasBeenOpened,
drawerRef,
drawerHeightRef,
overlayRef,
handleRef,
isDragging,
dragStartTime,
isAllowedToDrag,
snapPoints,
activeSnapPoint,
hasSnapPoints,
pointerStart,
dismissible,
snapPointsOffset,
direction,
shouldFade,
fadeFromIndex,
shouldScaleBackground,
setBackgroundColorOnScale,
onPress,
onDrag,
onRelease,
closeDrawer,
onNestedDrag,
onNestedRelease,
onNestedOpenChange,
emitClose,
emitDrag,
emitRelease,
nested,
handleOnly,
noBodyStyles,
};
}
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHandle,
DrawerOverlay,
DrawerPortal,
DrawerRoot,
DrawerTitle,
DrawerTrigger,
} from '@robonen/primitives';
const open = ref(false);
const goals = [
{ id: 'focus', label: 'Deep focus', detail: '90 min, no notifications' },
{ id: 'move', label: 'Move', detail: 'A short walk after lunch' },
{ id: 'read', label: 'Read', detail: '20 pages before bed' },
];
const selected = ref(goals[0]!.id);
</script>
<template>
<div class="flex flex-col items-start gap-3 text-fg">
<DrawerRoot v-model:open="open">
<DrawerTrigger
class="inline-flex items-center rounded-md border border-border bg-bg px-3 py-1.5 text-sm font-medium text-fg transition-colors hover:bg-bg-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Set today's goal
</DrawerTrigger>
<DrawerPortal>
<DrawerOverlay class="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm" />
<DrawerContent
class="fixed inset-x-0 bottom-0 z-50 mt-24 flex flex-col rounded-t-2xl border-t border-border bg-bg-elevated outline-none"
>
<DrawerHandle class="mt-3 mb-1" />
<div class="mx-auto w-full max-w-md px-5 pb-8 pt-2">
<DrawerTitle class="text-base font-semibold text-fg">
Set today's goal
</DrawerTitle>
<DrawerDescription class="mt-1 text-sm text-fg-muted">
Pick one thing to focus on. Drag the handle down to dismiss.
</DrawerDescription>
<div class="mt-4 flex flex-col gap-2">
<button
v-for="goal in goals"
:key="goal.id"
type="button"
class="flex items-center justify-between rounded-lg border px-3 py-2.5 text-left text-sm transition-colors"
:class="selected === goal.id
? 'border-accent bg-accent/10 text-fg'
: 'border-border bg-bg text-fg hover:bg-bg-subtle'"
@click="selected = goal.id"
>
<span>
<span class="font-medium">{{ goal.label }}</span>
<span class="block text-xs text-fg-muted">{{ goal.detail }}</span>
</span>
<span
v-if="selected === goal.id"
class="inline-flex size-5 items-center justify-center rounded-full bg-accent text-accent-fg"
>
<svg class="size-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>
</span>
</button>
</div>
<div class="mt-5 flex justify-end gap-2">
<DrawerClose
class="inline-flex items-center rounded-md border border-border bg-bg px-3 py-1.5 text-sm font-medium text-fg transition-colors hover:bg-bg-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Cancel
</DrawerClose>
<button
type="button"
class="inline-flex items-center rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition-colors hover:bg-accent-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@click="open = false"
>
Save goal
</button>
</div>
</div>
</DrawerContent>
</DrawerPortal>
</DrawerRoot>
</div>
</template>
@@ -0,0 +1,27 @@
import type { DrawerDirection } from './types';
/**
* Whether a direction runs along the vertical axis (`top`/`bottom`) as opposed
* to the horizontal axis (`left`/`right`). Used to pick the axis for translation
* reads/writes and window dimension.
*/
export function isVertical(direction: DrawerDirection): boolean {
return direction === 'top' || direction === 'bottom';
}
/**
* Logarithmic resistance applied when dragging the drawer past its open
* position, so it follows the pointer with diminishing returns (rubber-band).
*/
export function dampenValue(v: number): number {
return 8 * (Math.log(v + 1) - 2);
}
/**
* Resolves the app wrapper that the background-scale effect transforms. Consumers
* opt in by adding `data-drawer-wrapper` to the element that holds their page
* content (sibling to the portalled drawer).
*/
export function getDrawerWrapper(): HTMLElement | null {
return document.querySelector<HTMLElement>('[data-drawer-wrapper]');
}
@@ -0,0 +1,31 @@
export { default as DrawerRoot } from './DrawerRoot.vue';
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 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 { injectDrawerRootContext, provideDrawerRootContext } from './context';
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';
+270
View File
@@ -0,0 +1,270 @@
/**
* Critical drawer styles — the slide keyframes plus the data-attribute-driven
* transforms that the drag engine toggles. Injected once at runtime via
* `useStyleTag` from DrawerRoot, so the headless primitive stays self-contained
* (no separate CSS file to import). Consumers still bring their own visual
* styling (size, colour, padding) on DrawerContent/DrawerOverlay.
*
* 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_STYLES = `
[data-drawer] {
touch-action: none;
will-change: transform;
transition: transform 0.5s cubic-bezier(0.32, 0.72, 0, 1);
animation-duration: 0.5s;
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
}
[data-drawer][data-drawer-snap-points='false'][data-drawer-direction='bottom'][data-state='open'] {
animation-name: slideFromBottom;
}
[data-drawer][data-drawer-snap-points='false'][data-drawer-direction='bottom'][data-state='closed'] {
animation-name: slideToBottom;
}
[data-drawer][data-drawer-snap-points='false'][data-drawer-direction='top'][data-state='open'] {
animation-name: slideFromTop;
}
[data-drawer][data-drawer-snap-points='false'][data-drawer-direction='top'][data-state='closed'] {
animation-name: slideToTop;
}
[data-drawer][data-drawer-snap-points='false'][data-drawer-direction='left'][data-state='open'] {
animation-name: slideFromLeft;
}
[data-drawer][data-drawer-snap-points='false'][data-drawer-direction='left'][data-state='closed'] {
animation-name: slideToLeft;
}
[data-drawer][data-drawer-snap-points='false'][data-drawer-direction='right'][data-state='open'] {
animation-name: slideFromRight;
}
[data-drawer][data-drawer-snap-points='false'][data-drawer-direction='right'][data-state='closed'] {
animation-name: slideToRight;
}
[data-drawer][data-drawer-snap-points='true'][data-drawer-direction='bottom'] {
transform: translate3d(0, var(--initial-transform, 100%), 0);
}
[data-drawer][data-drawer-snap-points='true'][data-drawer-direction='top'] {
transform: translate3d(0, calc(var(--initial-transform, 100%) * -1), 0);
}
[data-drawer][data-drawer-snap-points='true'][data-drawer-direction='left'] {
transform: translate3d(calc(var(--initial-transform, 100%) * -1), 0, 0);
}
[data-drawer][data-drawer-snap-points='true'][data-drawer-direction='right'] {
transform: translate3d(var(--initial-transform, 100%), 0, 0);
}
[data-drawer][data-drawer-delayed-snap-points='true'][data-drawer-direction='top'] {
transform: translate3d(0, var(--snap-point-height, 0), 0);
}
[data-drawer][data-drawer-delayed-snap-points='true'][data-drawer-direction='bottom'] {
transform: translate3d(0, var(--snap-point-height, 0), 0);
}
[data-drawer][data-drawer-delayed-snap-points='true'][data-drawer-direction='left'] {
transform: translate3d(var(--snap-point-height, 0), 0, 0);
}
[data-drawer][data-drawer-delayed-snap-points='true'][data-drawer-direction='right'] {
transform: translate3d(var(--snap-point-height, 0), 0, 0);
}
[data-drawer-overlay][data-drawer-snap-points='false'] {
animation-duration: 0.5s;
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
}
[data-drawer-overlay][data-drawer-snap-points='false'][data-state='open'] {
animation-name: fadeIn;
}
[data-drawer-overlay][data-state='closed'] {
animation-name: fadeOut;
}
[data-drawer-animate='false'] {
animation: none !important;
}
[data-drawer-overlay][data-drawer-snap-points='true'] {
opacity: 0;
transition: opacity 0.5s cubic-bezier(0.32, 0.72, 0, 1);
}
[data-drawer-overlay][data-drawer-snap-points='true'] {
opacity: 1;
}
[data-drawer]:not([data-drawer-custom-container='true'])::after {
content: '';
position: absolute;
background: inherit;
background-color: inherit;
}
[data-drawer][data-drawer-direction='top']::after {
top: initial;
bottom: 100%;
left: 0;
right: 0;
height: 200%;
}
[data-drawer][data-drawer-direction='bottom']::after {
top: 100%;
bottom: initial;
left: 0;
right: 0;
height: 200%;
}
[data-drawer][data-drawer-direction='left']::after {
left: initial;
right: 100%;
top: 0;
bottom: 0;
width: 200%;
}
[data-drawer][data-drawer-direction='right']::after {
left: 100%;
right: initial;
top: 0;
bottom: 0;
width: 200%;
}
[data-drawer-overlay][data-drawer-snap-points='true']:not([data-drawer-snap-points-overlay='true']):not(
[data-state='closed']
) {
opacity: 0;
}
[data-drawer-overlay][data-drawer-snap-points-overlay='true'] {
opacity: 1;
}
[data-drawer-handle] {
display: block;
position: relative;
opacity: 0.7;
background: #e2e2e4;
margin-left: auto;
margin-right: auto;
height: 5px;
width: 32px;
border-radius: 1rem;
touch-action: pan-y;
}
[data-drawer-handle]:hover,
[data-drawer-handle]:active {
opacity: 1;
}
[data-drawer-handle-hitarea] {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: max(100%, 2.75rem); /* 44px */
height: max(100%, 2.75rem); /* 44px */
touch-action: inherit;
}
@media (hover: hover) and (pointer: fine) {
[data-drawer] {
user-select: none;
}
}
@media (pointer: fine) {
[data-drawer-handle-hitarea] {
width: 100%;
height: 100%;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
to {
opacity: 0;
}
}
@keyframes slideFromBottom {
from {
transform: translate3d(0, var(--initial-transform, 100%), 0);
}
to {
transform: translate3d(0, 0, 0);
}
}
@keyframes slideToBottom {
to {
transform: translate3d(0, var(--initial-transform, 100%), 0);
}
}
@keyframes slideFromTop {
from {
transform: translate3d(0, calc(var(--initial-transform, 100%) * -1), 0);
}
to {
transform: translate3d(0, 0, 0);
}
}
@keyframes slideToTop {
to {
transform: translate3d(0, calc(var(--initial-transform, 100%) * -1), 0);
}
}
@keyframes slideFromLeft {
from {
transform: translate3d(calc(var(--initial-transform, 100%) * -1), 0, 0);
}
to {
transform: translate3d(0, 0, 0);
}
}
@keyframes slideToLeft {
to {
transform: translate3d(calc(var(--initial-transform, 100%) * -1), 0, 0);
}
}
@keyframes slideFromRight {
from {
transform: translate3d(var(--initial-transform, 100%), 0, 0);
}
to {
transform: translate3d(0, 0, 0);
}
}
@keyframes slideToRight {
to {
transform: translate3d(var(--initial-transform, 100%), 0, 0);
}
}
`;
@@ -0,0 +1,13 @@
/**
* The edge the drawer is anchored to and slides in from.
*/
export type DrawerDirection = 'top' | 'bottom' | 'left' | 'right';
/**
* A resolved snap point: the original `fraction` (01 of the screen, or a raw
* px value) paired with its computed pixel `height`.
*/
export interface SnapPoint {
fraction: number;
height: number;
}
@@ -0,0 +1,128 @@
import type { Ref } from 'vue';
import { ref, watch } from 'vue';
import { isSafari } from '@robonen/platform/browsers';
import { useEventListener, useMediaQuery } from '@robonen/vue';
interface BodyPosition {
position: string;
top: string;
left: string;
height: string;
}
interface PositionFixedOptions {
isOpen: Ref<boolean>;
modal: Ref<boolean>;
nested: Ref<boolean>;
hasBeenOpened: Ref<boolean>;
preventScrollRestoration: Ref<boolean>;
noBodyStyles: Ref<boolean>;
}
// Module-level so a single restoration survives across nested drawers that each
// run this composable — only the outermost open/close should touch the body.
let previousBodyPosition: BodyPosition | null = null;
/**
* Pins `document.body` with `position: fixed` while the drawer is open on iOS
* Safari, where the address bar otherwise causes a jarring viewport shift. A
* no-op on every other browser. Restores the scroll position on close.
*/
export function usePositionFixed(options: PositionFixedOptions) {
const { isOpen, modal, nested, hasBeenOpened, preventScrollRestoration, noBodyStyles } = options;
const activeUrl = ref(globalThis.window !== undefined ? globalThis.location.href : '');
const scrollPos = ref(0);
// Standalone PWAs have no address bar to fight (SSR-safe via useMediaQuery).
const isStandalone = useMediaQuery('(display-mode: standalone)');
function setPositionFixed(): void {
if (!isSafari())
return;
if (previousBodyPosition === null && isOpen.value && !noBodyStyles.value) {
previousBodyPosition = {
position: document.body.style.position,
top: document.body.style.top,
left: document.body.style.left,
height: document.body.style.height,
};
const { scrollX, innerHeight } = globalThis;
document.body.style.setProperty('position', 'fixed', 'important');
Object.assign(document.body.style, {
top: `${-scrollPos.value}px`,
left: `${-scrollX}px`,
right: '0px',
height: 'auto',
});
setTimeout(() => {
requestAnimationFrame(() => {
// If a bottom bar appeared after pinning, nudge the content up so it
// isn't hidden behind it.
const bottomBarHeight = innerHeight - window.innerHeight;
if (bottomBarHeight && scrollPos.value >= innerHeight)
document.body.style.top = `-${scrollPos.value + bottomBarHeight}px`;
});
}, 300);
}
}
function restorePositionSetting(): void {
if (!isSafari())
return;
if (previousBodyPosition !== null && !noBodyStyles.value) {
const y = -Number.parseInt(document.body.style.top, 10);
const x = -Number.parseInt(document.body.style.left, 10);
Object.assign(document.body.style, previousBodyPosition);
globalThis.requestAnimationFrame(() => {
if (preventScrollRestoration.value && activeUrl.value !== globalThis.location.href) {
activeUrl.value = globalThis.location.href;
return;
}
window.scrollTo(x, y);
});
previousBodyPosition = null;
}
}
function onScroll() {
scrollPos.value = window.scrollY;
}
// Capture the position at setup (the page may already be scrolled), then keep
// it in sync. `useEventListener` defaults to `defaultWindow` and auto-removes.
if (globalThis.window !== undefined)
onScroll();
useEventListener('scroll', onScroll, { passive: true });
// `activeUrl` is read inside restorePositionSetting's rAF to detect navigation,
// but it must NOT drive this watch — it mutates on close and would re-fire the
// (idempotent) handler once for nothing.
watch([isOpen, hasBeenOpened], () => {
if (nested.value || !hasBeenOpened.value)
return;
if (isOpen.value) {
if (!isStandalone.value)
setPositionFixed();
if (!modal.value) {
setTimeout(() => {
restorePositionSetting();
}, 500);
}
}
else {
restorePositionSetting();
}
});
return { restorePositionSetting };
}
@@ -0,0 +1,64 @@
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';
/**
* Scales the page background down behind the drawer (the stacked-card effect),
* transforming the element marked `data-drawer-wrapper`. Restores everything,
* including the body background, when the drawer closes. No-op unless
* `shouldScaleBackground` is enabled on the root.
*/
export function useScaleBackground() {
const { direction, isOpen, shouldScaleBackground, setBackgroundColorOnScale, noBodyStyles } = injectDrawerRootContext();
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.
if (isClient && isOpen.value && shouldScaleBackground.value) {
if (timeoutIdRef.value)
clearTimeout(timeoutIdRef.value);
const wrapper = getDrawerWrapper();
if (!wrapper)
return;
if (setBackgroundColorOnScale.value && !noBodyStyles.value)
assignStyle(document.body, { background: 'black' });
assignStyle(wrapper, {
transformOrigin: isVertical(direction.value) ? 'top' : 'left',
transitionProperty: 'transform, border-radius',
transitionDuration: `${TRANSITIONS.DURATION}s`,
transitionTimingFunction: `cubic-bezier(${TRANSITIONS.EASE.join(',')})`,
});
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)` }),
});
onWatcherCleanup(() => {
wrapperStylesCleanup();
timeoutIdRef.value = globalThis.setTimeout(() => {
if (initialBackgroundColor.value)
document.body.style.background = initialBackgroundColor.value;
else
document.body.style.removeProperty('background');
}, TRANSITIONS.DURATION * 1000);
});
}
}, { flush: 'pre' });
}
@@ -0,0 +1,283 @@
import type { Ref } from 'vue';
import { computed, nextTick, ref, 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 type { DrawerDirection } from './types';
interface UseSnapPointsProps {
activeSnapPoint: Ref<number | string | null | undefined>;
snapPoints: Ref<Array<number | string> | undefined>;
fadeFromIndex: Ref<number | undefined>;
drawerRef: Ref<HTMLElement | undefined>;
overlayRef: Ref<HTMLElement | undefined>;
onSnapPointChange: (activeSnapPointIndex: number, snapPointsOffset: number[]) => void;
direction: Ref<DrawerDirection>;
}
const transition = (property: 'transform' | 'opacity') =>
`${property} ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
/**
* 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.
*/
export function useSnapPoints({
activeSnapPoint,
snapPoints,
drawerRef,
overlayRef,
fadeFromIndex,
onSnapPointChange,
direction,
}: UseSnapPointsProps) {
const windowDimensions = ref(globalThis.window !== undefined
? { innerWidth: window.innerWidth, innerHeight: window.innerHeight }
: undefined);
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 };
}
// Defaults to `defaultWindow` (SSR-safe) and auto-removes on scope dispose.
useEventListener('resize', onResize);
const isLastSnapPoint = computed(
() => (snapPoints.value && activeSnapPoint.value === snapPoints.value[snapPoints.value.length - 1]) ?? null,
);
const shouldFade = computed(
() =>
(snapPoints.value
&& snapPoints.value.length > 0
&& (fadeFromIndex?.value || fadeFromIndex?.value === 0)
&& !Number.isNaN(fadeFromIndex?.value)
&& snapPoints.value[fadeFromIndex?.value ?? -1] === activeSnapPoint.value)
|| !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) {
const newSnapPointIndex = snapPointsOffset.value?.indexOf(dimension) ?? null;
// 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)`,
});
});
if (
snapPointsOffset.value
&& newSnapPointIndex !== snapPointsOffset.value.length - 1
&& newSnapPointIndex !== fadeFromIndex?.value
) {
setStyle(overlayRef.value, { transition: transition('opacity'), opacity: '0' });
}
else {
setStyle(overlayRef.value, { transition: transition('opacity'), opacity: '1' });
}
activeSnapPoint.value = newSnapPointIndex !== null ? snapPoints.value?.[newSnapPointIndex] ?? null : null;
}
watch(
[activeSnapPoint, snapPointsOffset, snapPoints],
() => {
if (activeSnapPoint.value) {
const newIndex = snapPoints.value?.indexOf(activeSnapPoint.value) ?? -1;
if (snapPointsOffset.value && newIndex !== -1 && typeof snapPointsOffset.value[newIndex] === 'number')
snapToPoint(snapPointsOffset.value[newIndex]);
}
},
{ immediate: true },
);
function onRelease({
draggedDistance,
closeDrawer,
velocity,
dismissible,
}: {
draggedDistance: number;
closeDrawer: () => void;
velocity: number;
dismissible: boolean;
}) {
if (fadeFromIndex.value === undefined)
return;
const currentPosition
= direction.value === 'bottom' || direction.value === 'right'
? (activeSnapPointOffset.value ?? 0) - draggedDistance
: (activeSnapPointOffset.value ?? 0) + draggedDistance;
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 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]);
return;
}
snapToPoint(closestSnapPoint);
}
function onDrag({ draggedDistance }: { draggedDistance: number }) {
if (activeSnapPointOffset.value === null)
return;
const newValue
= direction.value === 'bottom' || direction.value === 'right'
? (activeSnapPointOffset.value ?? 0) - draggedDistance
: (activeSnapPointOffset.value ?? 0) + draggedDistance;
// 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 ((direction.value === 'top' || direction.value === 'left') && newValue > snapPointsOffset.value[snapPointsOffset.value.length - 1])
return;
setStyle(drawerRef.value, {
transform: isVertical(direction.value) ? `translate3d(0, ${newValue}px, 0)` : `translate3d(${newValue}px, 0, 0)`,
});
}
function getPercentageDragged(absDraggedDistance: number, isDraggingDown: boolean) {
if (
!snapPoints.value
|| typeof activeSnapPointIndex.value !== 'number'
|| !snapPointsOffset.value
|| fadeFromIndex.value === undefined
)
return null;
// Whether we're dragging toward a snap point that should show the overlay.
const isOverlaySnapPoint = activeSnapPointIndex.value === fadeFromIndex.value - 1;
const isOverlaySnapPointOrHigher = activeSnapPointIndex.value >= fadeFromIndex.value;
if (isOverlaySnapPointOrHigher && isDraggingDown)
return 0;
// Don't animate, but still use this one when dragging away from the overlay snap point.
if (isOverlaySnapPoint && !isDraggingDown)
return 1;
if (!shouldFade.value && !isOverlaySnapPoint)
return null;
const targetSnapPointIndex = isOverlaySnapPoint ? activeSnapPointIndex.value + 1 : activeSnapPointIndex.value - 1;
// Distance between the overlay snap point and its neighbour, used to scale opacity.
const snapPointDistance = isOverlaySnapPoint
? snapPointsOffset.value[targetSnapPointIndex] - snapPointsOffset.value[targetSnapPointIndex - 1]
: snapPointsOffset.value[targetSnapPointIndex + 1] - snapPointsOffset.value[targetSnapPointIndex];
const percentageDragged = absDraggedDistance / Math.abs(snapPointDistance);
return isOverlaySnapPoint ? 1 - percentageDragged : percentageDragged;
}
return {
isLastSnapPoint,
shouldFade,
getPercentageDragged,
activeSnapPointIndex,
onRelease,
onDrag,
snapPointsOffset,
};
}