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:
@@ -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 (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.
|
||||
*/
|
||||
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 (0–1) 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 (0–1) 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';
|
||||
@@ -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` (0–1 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user