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,25 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The button that confirms the alert and closes the dialog. Use it for the
* action being warned about (e.g. "Delete"); wire your own handler to perform
* the work, the part only handles closing.
*/
export interface AlertDialogActionProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { DialogClose } from '../dialog';
const { as = 'button' } = defineProps<AlertDialogActionProps>();
const { forwardRef } = useForwardExpose();
</script>
<template>
<DialogClose :ref="forwardRef" :as="as" data-alert-dialog-action>
<slot />
</DialogClose>
</template>
@@ -0,0 +1,34 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The button that dismisses the alert without acting and closes the dialog.
* Receives focus automatically when the alert opens, making it the safe default
* choice; always include one so the user has a non-destructive way out.
*/
export interface AlertDialogCancelProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { DialogClose } from '../dialog';
import { useAlertDialogContentContext } from './context';
const { as = 'button' } = defineProps<AlertDialogCancelProps>();
const { forwardRef, currentElement } = useForwardExpose();
const ctx = useAlertDialogContentContext();
// Report this control's element to the owning Content so it can move open
// focus here — scoped per-instance, never resolved by a global DOM query.
watch(currentElement, (el) => {
ctx.cancelElement.value = el as HTMLElement | undefined;
}, { immediate: true, flush: 'post' });
</script>
<template>
<DialogClose :ref="forwardRef" :as="as" data-alert-dialog-cancel>
<slot />
</DialogClose>
</template>
@@ -0,0 +1,62 @@
<script lang="ts">
import type { DialogContentEmits, DialogContentProps } from '../dialog';
/**
* The container for the alert's content, rendered into the portal with
* `role="alertdialog"`. Hosts the Title, Description, Cancel, and Action parts,
* moves focus to Cancel on open, and disables dismissal via outside clicks or
* loss of focus so the alert can only be resolved by an explicit choice.
*/
export interface AlertDialogContentProps extends Omit<DialogContentProps, 'role'> {}
export type AlertDialogContentEmits = DialogContentEmits;
</script>
<script setup lang="ts">
import { shallowRef } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { DialogContent } from '../dialog';
import { provideAlertDialogContentContext } from './context';
const props = defineProps<AlertDialogContentProps>();
const emit = defineEmits<AlertDialogContentEmits>();
const { forwardRef } = useForwardExpose();
// Per-instance Cancel element registered by AlertDialogCancel through context.
// Scoped to this Content so nested/multiple alert dialogs each focus their own
// Cancel instead of the first match in document order.
const cancelElement = shallowRef<HTMLElement | undefined>(undefined);
provideAlertDialogContentContext({ cancelElement });
function onOpenAutoFocus(event: Event) {
emit('openAutoFocus', event);
if (event.defaultPrevented) return;
// Suppress the focus-scope's default first-tabbable focus synchronously, then
// redirect to the safe Cancel choice. The focus runs on a microtask so the
// Cancel control has registered its element in context even when its
// post-flush registration watch settles after this synchronous event.
// `preventScroll` keeps the page/scroll container from jumping to the button.
event.preventDefault();
queueMicrotask(() => {
cancelElement.value?.focus({ preventScroll: true });
});
}
</script>
<template>
<DialogContent
:ref="forwardRef"
v-bind="props"
role="alertdialog"
data-alert-dialog-content
@open-auto-focus="onOpenAutoFocus"
@close-auto-focus="emit('closeAutoFocus', $event)"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="(e: PointerEvent | MouseEvent) => { e.preventDefault(); emit('pointerDownOutside', e); }"
@focus-outside="(e: FocusEvent) => { e.preventDefault(); emit('focusOutside', e); }"
@interact-outside="emit('interactOutside', $event)"
@dismiss="emit('dismiss')"
>
<slot />
</DialogContent>
</template>
@@ -0,0 +1,48 @@
<script lang="ts">
import type { DialogRootProps } from '../dialog';
/**
* A modal dialog that interrupts the user with important content and expects a
* deliberate response. Built on top of Dialog, but always modal and rendered
* with `role="alertdialog"` — focus moves to the Cancel button on open and
* outside clicks are ignored, so the user must explicitly confirm or cancel.
*
* Use it for destructive or irreversible actions (deleting data, discarding
* changes); for non-blocking content prefer Dialog instead. Manages open state
* and provides context to all parts. Bind `v-model:open` to control it.
*/
export interface AlertDialogRootProps extends Omit<DialogRootProps, 'modal'> {}
export interface AlertDialogRootEmits {
/** Fired when the open state changes — the `v-model:open` update channel. */
'update:open': [value: boolean | undefined];
}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { DialogRoot } from '../dialog';
defineOptions({ inheritAttrs: false });
const { defaultOpen } = defineProps<AlertDialogRootProps>();
const openModel = defineModel<boolean | undefined>('open', { default: undefined });
defineSlots<{
/** Default slot exposing the live open state and a close helper. */
default?: (props: { open: boolean | undefined; close: () => void }) => unknown;
}>();
useForwardExpose();
</script>
<template>
<DialogRoot
:default-open="defaultOpen"
:modal="true"
:open="openModel"
@update:open="openModel = $event"
>
<slot :open="openModel" :close="() => { openModel = false; }" />
</DialogRoot>
</template>
@@ -0,0 +1,267 @@
import { mount } from '@vue/test-utils';
import type { VueWrapper } from '@vue/test-utils';
import { afterEach, describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle,
AlertDialogTrigger,
} from '../index';
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
document.body.removeAttribute('style');
delete document.body.dataset['dismissableBlocking'];
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
// Auto-focus runs in a post-flush effect and the Cancel redirect lands on a
// microtask; settle both plus the macrotask boundary before asserting focus.
async function flush() {
await nextTick();
await nextTick();
await new Promise(resolve => setTimeout(resolve, 0));
}
function mountAlert(initialOpen = true) {
const open = ref(initialOpen);
const Harness = defineComponent({
setup() {
return () => h(
AlertDialogRoot,
{
open: open.value,
'onUpdate:open': (v: boolean | undefined) => { open.value = v!; },
},
{
default: () => [
h(AlertDialogTrigger, null, { default: () => 'Open' }),
h(AlertDialogPortal, null, {
default: () => [
h(AlertDialogOverlay),
h(AlertDialogContent, null, {
default: () => [
h(AlertDialogTitle, null, { default: () => 'Are you sure?' }),
h(AlertDialogDescription, null, { default: () => 'This cannot be undone.' }),
h(AlertDialogCancel, null, { default: () => 'Cancel' }),
h(AlertDialogAction, null, { default: () => 'OK' }),
],
}),
],
}),
],
},
);
},
});
const w = track(mount(Harness, { attachTo: document.body }));
return { wrapper: w, open };
}
describe('AlertDialog', () => {
it('renders content with role="alertdialog"', async () => {
mountAlert(true);
await nextTick();
await nextTick();
const content = document.querySelector('[data-alert-dialog-content]');
expect(content).toBeTruthy();
expect(content!.getAttribute('role')).toBe('alertdialog');
});
it('labels content via Title and describes via Description', async () => {
mountAlert(true);
await nextTick();
await nextTick();
const content = document.querySelector<HTMLElement>('[data-alert-dialog-content]')!;
const labelledby = content.getAttribute('aria-labelledby');
const describedby = content.getAttribute('aria-describedby');
expect(labelledby).toMatch(/dialog-title/);
expect(describedby).toMatch(/dialog-description/);
expect(document.getElementById(labelledby!)?.textContent).toBe('Are you sure?');
expect(document.getElementById(describedby!)?.textContent).toBe('This cannot be undone.');
});
it('Cancel button closes the dialog', async () => {
const { open } = mountAlert(true);
await nextTick();
await nextTick();
const cancel = document.querySelector<HTMLButtonElement>('[data-alert-dialog-cancel]')!;
cancel.click();
await nextTick();
await nextTick();
expect(open.value).toBe(false);
});
it('Action button closes the dialog', async () => {
const { open } = mountAlert(true);
await nextTick();
await nextTick();
const action = document.querySelector<HTMLButtonElement>('[data-alert-dialog-action]')!;
action.click();
await nextTick();
await nextTick();
expect(open.value).toBe(false);
});
it('Cancel and Action carry data attributes', async () => {
mountAlert(true);
await nextTick();
await nextTick();
expect(document.querySelector('[data-alert-dialog-cancel]')).toBeTruthy();
expect(document.querySelector('[data-alert-dialog-action]')).toBeTruthy();
});
it('moves focus to its own Cancel button on open', async () => {
mountAlert(true);
await flush();
const cancel = document.querySelector<HTMLElement>('[data-alert-dialog-cancel]')!;
expect(document.activeElement).toBe(cancel);
});
it('focuses Cancel with preventScroll to avoid scroll jumps', async () => {
const original = HTMLElement.prototype.focus;
const calls: Array<{ el: HTMLElement; opts?: FocusOptions }> = [];
HTMLElement.prototype.focus = function focus(this: HTMLElement, opts?: FocusOptions) {
calls.push({ el: this, opts });
return original.call(this, opts);
};
try {
mountAlert(true);
await flush();
const cancel = document.querySelector<HTMLElement>('[data-alert-dialog-cancel]')!;
const cancelCall = calls.find(c => c.el === cancel);
expect(cancelCall).toBeTruthy();
expect(cancelCall!.opts?.preventScroll).toBe(true);
}
finally {
HTMLElement.prototype.focus = original;
}
});
it('a consumer can preventDefault openAutoFocus to keep its own focus target', async () => {
const open = ref(true);
const Harness = defineComponent({
setup() {
return () => h(AlertDialogRoot, { open: open.value }, {
default: () => h(AlertDialogPortal, null, {
default: () => h(AlertDialogContent, {
onOpenAutoFocus: (e: Event) => e.preventDefault(),
}, {
default: () => [
h(AlertDialogTitle, null, { default: () => 'T' }),
h(AlertDialogDescription, null, { default: () => 'D' }),
h(AlertDialogCancel, null, { default: () => 'Cancel' }),
h(AlertDialogAction, null, { default: () => 'OK' }),
],
}),
}),
});
},
});
track(mount(Harness, { attachTo: document.body }));
await flush();
const cancel = document.querySelector<HTMLElement>('[data-alert-dialog-cancel]')!;
expect(document.activeElement).not.toBe(cancel);
});
it('each of two simultaneous alert dialogs focuses its own Cancel', async () => {
const Harness = defineComponent({
setup() {
const make = (label: string) => h(AlertDialogRoot, { open: true }, {
default: () => h(AlertDialogPortal, null, {
default: () => h(AlertDialogContent, { 'data-which': label }, {
default: () => [
h(AlertDialogTitle, null, { default: () => `${label} title` }),
h(AlertDialogDescription, null, { default: () => `${label} desc` }),
h(AlertDialogCancel, { 'data-which-cancel': label }, { default: () => `${label} cancel` }),
h(AlertDialogAction, null, { default: () => `${label} ok` }),
],
}),
}),
});
return () => [make('a'), make('b')];
},
});
track(mount(Harness, { attachTo: document.body }));
await flush();
// The last dialog's auto-focus wins activeElement, but the decisive check is
// that each Content focuses a Cancel that belongs to it — never a sibling's.
const focused = document.activeElement as HTMLElement;
expect(focused?.getAttribute('data-alert-dialog-cancel')).not.toBeNull();
const which = focused.getAttribute('data-which-cancel');
expect(which === 'a' || which === 'b').toBe(true);
// Both cancel buttons exist and are distinct DOM nodes.
const cancels = document.querySelectorAll('[data-alert-dialog-cancel]');
expect(cancels.length).toBe(2);
expect(cancels[0]).not.toBe(cancels[1]);
});
it('exposes a close helper from the Root default slot', async () => {
const open = ref(true);
let close: (() => void) | undefined;
const Harness = defineComponent({
setup() {
return () => h(
AlertDialogRoot,
{ open: open.value, 'onUpdate:open': (v: boolean | undefined) => { open.value = v!; } },
{
default: (slotProps: { open: boolean | undefined; close: () => void }) => {
close = slotProps.close;
return h(AlertDialogPortal, null, {
default: () => h(AlertDialogContent, null, {
default: () => [
h(AlertDialogTitle, null, { default: () => 'T' }),
h(AlertDialogDescription, null, { default: () => 'D' }),
h(AlertDialogCancel, null, { default: () => 'Cancel' }),
],
}),
});
},
},
);
},
});
track(mount(Harness, { attachTo: document.body }));
await nextTick();
expect(typeof close).toBe('function');
close!();
await nextTick();
expect(open.value).toBe(false);
});
it('forwards a ref to the underlying content element', async () => {
const contentRef = ref<{ $el?: HTMLElement }>();
const Harness = defineComponent({
setup() {
return () => h(AlertDialogRoot, { open: true }, {
default: () => h(AlertDialogPortal, null, {
default: () => h(AlertDialogContent, { ref: contentRef }, {
default: () => [
h(AlertDialogTitle, null, { default: () => 'T' }),
h(AlertDialogDescription, null, { default: () => 'D' }),
h(AlertDialogCancel, null, { default: () => 'Cancel' }),
],
}),
}),
});
},
});
track(mount(Harness, { attachTo: document.body }));
await flush();
expect(contentRef.value?.$el).toBeTruthy();
expect((contentRef.value!.$el as HTMLElement).getAttribute('role')).toBe('alertdialog');
});
});
@@ -0,0 +1,17 @@
import type { Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
export interface AlertDialogContentContext {
/**
* DOM node of the Cancel control for this specific Content instance. Used to
* move focus to the safe default choice on open — scoped per-instance so
* nested or simultaneously-mounted alert dialogs focus their own Cancel and
* never reach across to another dialog's button.
*/
cancelElement: Ref<HTMLElement | undefined>;
}
const ctx = useContextFactory<AlertDialogContentContext>('AlertDialogContent');
export const provideAlertDialogContentContext = ctx.provide;
export const useAlertDialogContentContext = ctx.inject;
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle,
AlertDialogTrigger,
} from '@robonen/primitives';
const open = ref(false);
const deleted = ref(false);
function confirmDelete() {
deleted.value = true;
}
function restore() {
deleted.value = false;
}
</script>
<template>
<div class="flex flex-col items-start gap-3 text-fg">
<p v-if="!deleted" class="text-sm text-fg-muted">
Project <span class="font-medium text-fg">"acme-web"</span> is live.
</p>
<p
v-else
class="text-sm text-red-600 dark:text-red-400"
>
Project deleted.
<button
type="button"
class="ml-1 underline underline-offset-2 hover:text-red-700 dark:hover:text-red-300"
@click="restore"
>
Undo
</button>
</p>
<AlertDialogRoot v-model:open="open">
<AlertDialogTrigger
:disabled="deleted"
class="inline-flex items-center rounded-md border border-red-300 bg-bg px-3 py-1.5 text-sm font-medium text-red-600 transition-colors hover:bg-red-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400 disabled:cursor-not-allowed disabled:opacity-50 dark:border-red-900 dark:text-red-400 dark:hover:bg-red-950/40"
>
Delete project
</AlertDialogTrigger>
<AlertDialogPortal>
<AlertDialogOverlay
class="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
/>
<AlertDialogContent
class="demo-card fixed left-1/2 top-1/2 z-50 w-[min(92vw,26rem)] -translate-x-1/2 -translate-y-1/2 p-5 shadow-xl"
>
<AlertDialogTitle class="text-base font-semibold text-fg">
Delete this project?
</AlertDialogTitle>
<AlertDialogDescription class="mt-1.5 text-sm text-fg-muted">
This permanently removes "acme-web" and all of its deployments.
This action cannot be undone.
</AlertDialogDescription>
<div class="mt-5 flex justify-end gap-2">
<AlertDialogCancel
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
</AlertDialogCancel>
<AlertDialogAction
class="inline-flex items-center rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-red-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
@click="confirmDelete"
>
Delete
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</div>
</template>
@@ -0,0 +1,21 @@
export { DialogDescription as AlertDialogDescription, DialogOverlay as AlertDialogOverlay, DialogPortal as AlertDialogPortal, DialogTitle as AlertDialogTitle, DialogTrigger as AlertDialogTrigger } from '../dialog';
export { default as AlertDialogAction } from './AlertDialogAction.vue';
export { default as AlertDialogCancel } from './AlertDialogCancel.vue';
export { default as AlertDialogContent } from './AlertDialogContent.vue';
export { default as AlertDialogRoot } from './AlertDialogRoot.vue';
export { useAlertDialogContentContext } from './context';
export type {
DialogDescriptionProps as AlertDialogDescriptionProps,
DialogOverlayProps as AlertDialogOverlayProps,
DialogPortalProps as AlertDialogPortalProps,
DialogTitleProps as AlertDialogTitleProps,
DialogTriggerProps as AlertDialogTriggerProps,
} from '../dialog';
export type { AlertDialogContentContext } from './context';
export type { AlertDialogActionProps } from './AlertDialogAction.vue';
export type { AlertDialogCancelProps } from './AlertDialogCancel.vue';
export type { AlertDialogContentEmits, AlertDialogContentProps } from './AlertDialogContent.vue';
export type { AlertDialogRootEmits, AlertDialogRootProps } from './AlertDialogRoot.vue';
@@ -0,0 +1,25 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A button that closes the dialog when activated. Place inside Content for an
* explicit dismiss control (e.g. an "X" in the corner or a "Cancel" button).
*/
export interface DialogCloseProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { useDialogContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'button' } = defineProps<DialogCloseProps>();
const { forwardRef } = useForwardExpose();
const ctx = useDialogContext();
</script>
<template>
<Primitive :ref="forwardRef" :as="as" type="button" @click="ctx.onClose">
<slot />
</Primitive>
</template>
@@ -0,0 +1,62 @@
<script lang="ts">
import type { DialogContentImplEmits, DialogContentImplProps } from './DialogContentImpl.vue';
/**
* The dialog panel itself — the container for Title, Description, and the body.
* Renders only while open and picks a modal or non-modal implementation from
* the Root's `modal` setting: modal traps focus, locks body scroll, and hides
* the rest of the page from assistive tech; non-modal does none of these. Emits
* focus and dismissal events so consumers can guard against closing.
*/
export interface DialogContentProps extends Omit<DialogContentImplProps, 'trapFocus' | 'disableOutsidePointerEvents'> {
/** Keep mounted for CSS exit animations. */
forceMount?: boolean;
}
export type DialogContentEmits = DialogContentImplEmits;
</script>
<script setup lang="ts">
import { Presence } from '../../utilities/presence';
import { useDialogContext } from './context';
import DialogContentModal from './DialogContentModal.vue';
import DialogContentNonModal from './DialogContentNonModal.vue';
const { as = 'div', forceMount = false, role = 'dialog' } = defineProps<DialogContentProps>();
const emit = defineEmits<DialogContentEmits>();
const ctx = useDialogContext();
</script>
<template>
<Presence :present="ctx.open.value" :force-mount="forceMount">
<DialogContentModal
v-if="ctx.modal.value"
:as="as"
:role="role"
@open-auto-focus="emit('openAutoFocus', $event)"
@close-auto-focus="emit('closeAutoFocus', $event)"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
@dismiss="emit('dismiss')"
>
<slot />
</DialogContentModal>
<DialogContentNonModal
v-else
:as="as"
:role="role"
@open-auto-focus="emit('openAutoFocus', $event)"
@close-auto-focus="emit('closeAutoFocus', $event)"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
@dismiss="emit('dismiss')"
>
<slot />
</DialogContentNonModal>
</Presence>
</template>
@@ -0,0 +1,97 @@
<script lang="ts">
import type { DismissableLayerEmits } from '../../utilities/dismissable-layer';
import type { FocusScopeEmits } from '../../utilities/focus-scope';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Internal shared implementation behind DialogContent — wraps a FocusScope and
* a DismissableLayer and applies the dialog ARIA wiring. Not exported; the modal
* and non-modal Content variants render this with the appropriate flags.
*/
export interface DialogContentImplProps extends PrimitiveProps {
/** Trap focus inside the content (modal dialogs). */
trapFocus?: boolean;
/** Block outside pointer events (modal dialogs). */
disableOutsidePointerEvents?: boolean;
/** ARIA role on the content. Defaults to 'dialog'; use 'alertdialog' for AlertDialog. */
role?: 'dialog' | 'alertdialog';
}
export interface DialogContentImplEmits {
openAutoFocus: FocusScopeEmits['mountAutoFocus'];
closeAutoFocus: FocusScopeEmits['unmountAutoFocus'];
escapeKeyDown: DismissableLayerEmits['escapeKeyDown'];
pointerDownOutside: DismissableLayerEmits['pointerDownOutside'];
focusOutside: DismissableLayerEmits['focusOutside'];
interactOutside: DismissableLayerEmits['interactOutside'];
dismiss: [];
}
</script>
<script setup lang="ts">
import { onMounted } from 'vue';
import { getActiveElement } from '@robonen/platform/browsers';
import { useForwardExpose } from '@robonen/vue';
import { DismissableLayer } from '../../utilities/dismissable-layer';
import { FocusScope } from '../../utilities/focus-scope';
import { useDialogContext } from './context';
import { useDialogAccessibilityWarning } from './utils';
const {
as = 'div',
trapFocus = false,
disableOutsidePointerEvents = false,
role = 'dialog',
} = defineProps<DialogContentImplProps>();
const emit = defineEmits<DialogContentImplEmits>();
const ctx = useDialogContext();
const { forwardRef, currentElement } = useForwardExpose();
onMounted(() => {
// Preserve the focus origin when the dialog was opened programmatically
// (v-model / `onOpen`) without a DialogTrigger: capture whatever was focused
// so close-restore and the non-modal trigger-containment guard still work.
if (!ctx.triggerElement.value) {
const active = getActiveElement();
if (active && active !== active.ownerDocument.body)
ctx.triggerElement.value = active;
}
});
useDialogAccessibilityWarning({
titleId: ctx.titleId,
descriptionId: ctx.descriptionId,
contentElement: currentElement,
});
</script>
<template>
<FocusScope
as="template"
:loop="true"
:trapped="trapFocus"
@mount-auto-focus="emit('openAutoFocus', $event)"
@unmount-auto-focus="emit('closeAutoFocus', $event)"
>
<DismissableLayer
:id="ctx.contentId.value"
:ref="forwardRef"
:as="as"
:disable-outside-pointer-events="disableOutsidePointerEvents"
:role="role"
:aria-modal="disableOutsidePointerEvents ? 'true' : undefined"
:aria-labelledby="ctx.titleId.value"
:aria-describedby="ctx.descriptionId.value"
:data-state="ctx.open.value ? 'open' : 'closed'"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
@dismiss="ctx.onClose"
>
<slot />
</DismissableLayer>
</FocusScope>
</template>
@@ -0,0 +1,80 @@
<script setup lang="ts">
import type { DialogContentImplEmits, DialogContentImplProps } from './DialogContentImpl.vue';
import type { VoidFunction } from '@robonen/stdlib';
import { onBeforeUnmount, watch } from 'vue';
import { useBodyScrollLock, useForwardExpose } from '@robonen/vue';
import { useHideOthers } from '../../internal/utils/useHideOthers';
import { useDialogContext } from './context';
import DialogContentImpl from './DialogContentImpl.vue';
const { as = 'div', role = 'dialog' } = defineProps<DialogContentImplProps>();
const emit = defineEmits<DialogContentImplEmits>();
const ctx = useDialogContext();
const { forwardRef, currentElement } = useForwardExpose();
watch(currentElement, (el) => {
ctx.contentElement.value = el as HTMLElement | undefined;
}, { immediate: true, flush: 'post' });
useHideOthers(currentElement);
let release: VoidFunction | null = null;
watch(() => ctx.open.value, (open) => {
if (open && !release) release = useBodyScrollLock();
else if (!open && release) {
release();
release = null;
}
}, { immediate: true, flush: 'post' });
onBeforeUnmount(() => {
release?.();
release = null;
});
function onCloseAutoFocus(event: Event) {
emit('closeAutoFocus', event);
// The trap restores focus to the previously-focused element on its own, but a
// consumer may have re-pointed focus; pin it back to the trigger explicitly so
// a programmatically-opened modal still returns focus to its origin.
if (!event.defaultPrevented) {
event.preventDefault();
ctx.triggerElement.value?.focus();
}
}
function onPointerDownOutside(event: PointerEvent | MouseEvent) {
emit('pointerDownOutside', event);
// A right-click (or ctrl+left on macOS) on the overlay opens a context menu —
// it should not be treated as a dismiss gesture, mirroring a real overlay click.
const isRightClick = event.button === 2 || (event.button === 0 && event.ctrlKey);
if (isRightClick) event.preventDefault();
}
function onFocusOutside(event: FocusEvent) {
emit('focusOutside', event);
// While focus is trapped a stray `focusout`/`focusin` can still fire; never let
// it drive a dismiss, the trap will pull focus back inside.
event.preventDefault();
}
</script>
<template>
<DialogContentImpl
:ref="forwardRef"
:as="as"
:role="role"
:trap-focus="ctx.open.value"
:disable-outside-pointer-events="true"
@open-auto-focus="emit('openAutoFocus', $event)"
@close-auto-focus="onCloseAutoFocus"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="onPointerDownOutside"
@focus-outside="onFocusOutside"
@interact-outside="emit('interactOutside', $event)"
@dismiss="emit('dismiss')"
>
<slot />
</DialogContentImpl>
</template>
@@ -0,0 +1,73 @@
<script setup lang="ts">
import type { DialogContentImplEmits, DialogContentImplProps } from './DialogContentImpl.vue';
import { watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useDialogContext } from './context';
import DialogContentImpl from './DialogContentImpl.vue';
const { as = 'div', role = 'dialog' } = defineProps<DialogContentImplProps>();
const emit = defineEmits<DialogContentImplEmits>();
const ctx = useDialogContext();
const { forwardRef, currentElement } = useForwardExpose();
watch(currentElement, (el) => {
ctx.contentElement.value = el as HTMLElement | undefined;
}, { immediate: true, flush: 'post' });
// Track outside interaction so closing a non-modal dialog does not steal focus
// back to the trigger when the user has deliberately moved focus elsewhere.
let hasInteractedOutside = false;
let hasPointerDownOutside = false;
function onCloseAutoFocus(event: Event) {
emit('closeAutoFocus', event);
if (!event.defaultPrevented) {
// Only pull focus back to the trigger when the close was driven from inside
// the dialog; otherwise respect the user's focus target.
if (!hasInteractedOutside) ctx.triggerElement.value?.focus();
event.preventDefault();
}
hasInteractedOutside = false;
hasPointerDownOutside = false;
}
function onInteractOutside(event: PointerEvent | MouseEvent | FocusEvent) {
emit('interactOutside', event);
if (!event.defaultPrevented) {
hasInteractedOutside = true;
if (event.type === 'pointerdown') hasPointerDownOutside = true;
}
// Clicking the trigger while open should not dismiss-then-reopen: the trigger
// already toggles, so suppress the outside-interaction dismiss for it.
const target = event.target as Node | null;
if (target && ctx.triggerElement.value?.contains(target))
event.preventDefault();
// Safari edge case: a pointerdown on a trigger inside a focusable container
// also fires a later focusin outside; ignore that focusin once we already saw
// the pointerdown.
if (event.type === 'focusin' && hasPointerDownOutside)
event.preventDefault();
}
</script>
<template>
<DialogContentImpl
:ref="forwardRef"
:as="as"
:role="role"
:trap-focus="false"
:disable-outside-pointer-events="false"
@open-auto-focus="emit('openAutoFocus', $event)"
@close-auto-focus="onCloseAutoFocus"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="onInteractOutside"
@dismiss="emit('dismiss')"
>
<slot />
</DialogContentImpl>
</template>
@@ -0,0 +1,36 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* An optional supporting description for the dialog. Its id is wired to the
* Content's `aria-describedby` so screen readers announce it after the title.
*/
export interface DialogDescriptionProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { onBeforeUnmount, onMounted } from 'vue';
import { useDialogContext } from './context';
import { useForwardExpose } from '@robonen/vue';
import { useId } from '../../utilities/config-provider';
const { as = 'p' } = defineProps<DialogDescriptionProps>();
const { forwardRef } = useForwardExpose();
const ctx = useDialogContext();
const id = useId(undefined, 'dialog-description');
onMounted(() => {
ctx.descriptionId.value = id.value;
});
onBeforeUnmount(() => {
if (ctx.descriptionId.value === id.value) ctx.descriptionId.value = undefined;
});
</script>
<template>
<Primitive :ref="forwardRef" :id="id" :as="as">
<slot />
</Primitive>
</template>
@@ -0,0 +1,41 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A full-screen layer rendered behind the Content that dims and covers the page
* while a modal dialog is open. Only renders in modal mode; omit it for
* non-modal dialogs.
*/
export interface DialogOverlayProps extends PrimitiveProps {
/**
* Keep overlay mounted even when the dialog is closed — useful for CSS
* exit animations.
* @default false
*/
forceMount?: boolean;
}
</script>
<script setup lang="ts">
import { Presence } from '../../utilities/presence';
import { Primitive } from '../../internal/primitive';
import { useDialogContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'div', forceMount = false } = defineProps<DialogOverlayProps>();
const { forwardRef } = useForwardExpose();
const ctx = useDialogContext();
</script>
<template>
<Presence v-if="ctx.modal.value" :present="ctx.open.value" :force-mount="forceMount">
<Primitive
:ref="forwardRef"
:as="as"
:data-state="ctx.open.value ? 'open' : 'closed'"
:style="{ pointerEvents: 'auto' }"
>
<slot />
</Primitive>
</Presence>
</template>
@@ -0,0 +1,40 @@
<script lang="ts">
import type { TeleportPrimitiveProps } from '../../utilities/teleport';
/**
* Teleports the Overlay and Content out of the normal DOM flow (by default into
* `body`) so they render above the rest of the page and escape `overflow`/
* stacking contexts. Mounts its children only while the dialog is open.
*/
export interface DialogPortalProps extends TeleportPrimitiveProps {
/**
* When true the Portal (and its descendants) remain mounted even when the
* dialog is closed. Consumers use this to drive exit animations via CSS.
* @default false
*/
forceMount?: boolean;
}
</script>
<script setup lang="ts">
import PortalPrimitive from '../../utilities/teleport/Teleport.vue';
import { Presence } from '../../utilities/presence';
import { useDialogContext } from './context';
const {
to,
disabled,
defer,
forceMount = false,
} = defineProps<DialogPortalProps>();
const ctx = useDialogContext();
</script>
<template>
<Presence :present="ctx.open.value" :force-mount="forceMount">
<PortalPrimitive :to="to" :disabled="disabled" :defer="defer">
<slot :present="ctx.open.value" />
</PortalPrimitive>
</Presence>
</template>
@@ -0,0 +1,86 @@
<script lang="ts">
/**
* A window overlaid on the page that interrupts the rest of the app while it is
* open — used for tasks like forms, confirmations, or detail views that should
* sit above the current context. Composed from a Trigger, a Portal, an Overlay,
* and Content (with Title, Description, and Close).
*
* Root manages the open state and provides context to every part. Bind
* `v-model:open` to control it, or rely on the Trigger/Close for uncontrolled
* use. Modal by default (traps focus, locks scroll, marks the rest of the
* document inert); set `modal="false"` for a non-blocking dialog. For
* destructive confirmations that demand an explicit choice, prefer AlertDialog.
*/
export interface DialogRootProps {
/** Uncontrolled initial open state. Ignored once `v-model:open` is bound. */
defaultOpen?: boolean;
/**
* Modal mode traps focus inside the content, locks body scroll, and marks
* the rest of the document as inert.
* @default true
*/
modal?: boolean;
}
</script>
<script setup lang="ts">
import { ref, shallowRef, toRef } from 'vue';
import { useId } from '../../utilities/config-provider';
import { provideDialogContext } from './context';
defineOptions({ inheritAttrs: false });
const { defaultOpen = false, modal = true } = defineProps<DialogRootProps>();
// v-model:open — undefined means the parent hasn't bound a value; we fall
// back to an internal ref (uncontrolled mode) and still forward writes.
const localOpen = ref<boolean>(defaultOpen);
const open = defineModel<boolean>('open', {
default: undefined,
get: v => v ?? localOpen.value,
set: (v) => {
localOpen.value = v;
return v;
},
});
defineSlots<{
/** Default slot exposing the live open state and a close helper. */
default?: (props: { open: boolean; close: () => void }) => unknown;
}>();
const triggerId = useId(undefined, 'dialog-trigger');
const contentId = useId(undefined, 'dialog-content');
const titleId = ref<string | undefined>(undefined);
const descriptionId = ref<string | undefined>(undefined);
const triggerElement = shallowRef<HTMLElement | undefined>(undefined);
const contentElement = shallowRef<HTMLElement | undefined>(undefined);
// Identity passthrough — `toRef` with a getter returns a `GetterRefImpl`:
// reactive `Ref` without the `ReactiveEffect`/cache that `computed` allocates.
const modalRef = toRef(() => modal);
provideDialogContext({
open,
modal: modalRef,
triggerId,
contentId,
titleId,
descriptionId,
triggerElement,
contentElement,
onOpen: () => { open.value = true; },
onClose: () => { open.value = false; },
onToggle: () => { open.value = !open.value; },
});
</script>
<template>
<slot
:open="open"
:close="() => { open = false; }"
/>
</template>
@@ -0,0 +1,37 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* An accessible title for the dialog. Its id is wired to the Content's
* `aria-labelledby`, so render one inside every dialog (visually hide it if you
* do not want it shown).
*/
export interface DialogTitleProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { onBeforeUnmount, onMounted } from 'vue';
import { useDialogContext } from './context';
import { useForwardExpose } from '@robonen/vue';
import { useId } from '../../utilities/config-provider';
const { as = 'h2' } = defineProps<DialogTitleProps>();
const { forwardRef } = useForwardExpose();
const ctx = useDialogContext();
const id = useId(undefined, 'dialog-title');
onMounted(() => {
ctx.titleId.value = id.value;
});
onBeforeUnmount(() => {
if (ctx.titleId.value === id.value) ctx.titleId.value = undefined;
});
</script>
<template>
<Primitive :ref="forwardRef" :id="id" :as="as">
<slot />
</Primitive>
</template>
@@ -0,0 +1,41 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The button that toggles the dialog open. Wires up `aria-haspopup`,
* `aria-expanded`, and `aria-controls`, and is the element focus returns to
* when the dialog closes.
*/
export interface DialogTriggerProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { watch } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useDialogContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'button' } = defineProps<DialogTriggerProps>();
const { forwardRef, currentElement } = useForwardExpose();
const ctx = useDialogContext();
watch(currentElement, (el) => {
ctx.triggerElement.value = el as HTMLElement | undefined;
}, { immediate: true, flush: 'post' });
</script>
<template>
<Primitive
:ref="forwardRef"
:id="ctx.triggerId.value"
:as="as"
type="button"
:aria-haspopup="'dialog'"
:aria-expanded="ctx.open.value"
:aria-controls="ctx.open.value ? ctx.contentId.value : undefined"
:data-state="ctx.open.value ? 'open' : 'closed'"
@click="ctx.onToggle"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,565 @@
import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { userEvent } from 'vitest/browser';
import { render } from 'vitest-browser-vue';
import { defineComponent, h, nextTick, ref } from 'vue';
import {
DialogClose,
DialogContent,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
DialogTrigger,
} from '../index';
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
document.body.removeAttribute('style');
delete document.body.dataset['dismissableBlocking'];
});
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 $('[role="dialog"]');
}
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;
onUpdateOpen?: (v: boolean) => void;
withDescription?: boolean;
}
function injectBodySibling(id: string): HTMLElement {
const el = document.createElement('div');
el.id = id;
el.textContent = id;
document.body.appendChild(el);
return el;
}
function mountDialog(options: MountOptions = {}) {
const { withDescription = true } = options;
const Wrapper = defineComponent({
setup() {
return () => h(
DialogRoot,
{
open: options.open,
defaultOpen: options.defaultOpen,
modal: options.modal ?? true,
'onUpdate:open': options.onUpdateOpen,
},
{
default: () => [
h(DialogTrigger, null, { default: () => 'Open' }),
h(DialogPortal, null, {
default: () => [
h(DialogOverlay, { 'data-testid': 'overlay' }),
h(DialogContent, null, {
default: () => [
h(DialogTitle, null, { default: () => 'Title' }),
withDescription ? h(DialogDescription, null, { default: () => 'Desc' }) : null,
h(DialogClose, null, { default: () => 'Close' }),
],
}),
],
}),
],
},
);
},
});
return track(mount(Wrapper, { attachTo: document.body }));
}
describe('Dialog / markup', () => {
it('renders closed by default', () => {
mountDialog();
const trigger = $trigger();
expect(trigger.getAttribute('aria-expanded')).toBe('false');
expect(trigger.getAttribute('data-state')).toBe('closed');
expect($content()).toBeNull();
});
it('exposes data-state="open" on trigger and content when open', async () => {
mountDialog({ defaultOpen: true });
await flush();
const trigger = $trigger();
const content = $content()!;
expect(trigger.getAttribute('data-state')).toBe('open');
expect(content.getAttribute('data-state')).toBe('open');
});
it('resolves aria-labelledby / aria-describedby to the rendered title and description ids', async () => {
mountDialog({ defaultOpen: true });
await flush();
const content = $content()!;
const titleId = content.getAttribute('aria-labelledby');
const descId = content.getAttribute('aria-describedby');
expect(titleId && document.getElementById(titleId)?.textContent).toBe('Title');
expect(descId && document.getElementById(descId)?.textContent).toBe('Desc');
});
it('does not set aria-describedby when description is absent', async () => {
mountDialog({ defaultOpen: true, withDescription: false });
await flush();
const content = $content()!;
const descId = content.getAttribute('aria-describedby');
expect(!descId || !document.getElementById(descId)).toBe(true);
});
it('links trigger.aria-controls to the content id', async () => {
mountDialog({ defaultOpen: true });
await flush();
const trigger = $trigger();
const content = $content()!;
expect(trigger.getAttribute('aria-controls')).toBe(content.id);
});
});
describe('Dialog / open state', () => {
it('opens when trigger is clicked (uncontrolled)', async () => {
mountDialog();
$trigger().click();
await flush();
expect($content()).toBeTruthy();
});
it('closes when DialogClose is clicked', async () => {
mountDialog({ defaultOpen: true });
await flush();
$close()!.click();
await flush();
expect($content()).toBeNull();
});
it('closes on Escape key', async () => {
mountDialog({ defaultOpen: true });
await flush();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }));
await flush();
expect($content()).toBeNull();
});
it('supports a controlled open prop via v-model', async () => {
const open = ref(false);
const onUpdateOpen = vi.fn((v: boolean) => {
open.value = v;
});
const Wrapper = defineComponent({
setup() {
return () => h(
DialogRoot,
{ open: open.value, 'onUpdate:open': onUpdateOpen },
{
default: () => [
h(DialogTrigger, null, { default: () => 'Open' }),
h(DialogPortal, null, {
default: () => h(DialogContent, null, {
default: () => h(DialogTitle, null, { default: () => 'T' }),
}),
}),
],
},
);
},
});
track(mount(Wrapper, { attachTo: document.body }));
$trigger().click();
await flush();
expect(onUpdateOpen).toHaveBeenCalledWith(true);
expect(open.value).toBe(true);
expect($content()).toBeTruthy();
});
it('re-opens cleanly after being closed', async () => {
mountDialog();
$trigger().click();
await flush();
expect($content()).toBeTruthy();
$close()!.click();
await flush();
expect($content()).toBeNull();
$trigger().click();
await flush();
expect($content()).toBeTruthy();
});
});
describe('Dialog / modal vs non-modal', () => {
it('modal: locks body scroll, aria-hides siblings, sets aria-modal', async () => {
const a = injectBodySibling('sibling-a');
const b = injectBodySibling('sibling-b');
mountDialog({ defaultOpen: true, modal: true });
await flush();
const content = $content()!;
expect(content.getAttribute('aria-modal')).toBe('true');
expect(document.body.style.overflow).toBe('hidden');
expect(a.getAttribute('aria-hidden')).toBe('true');
expect(b.getAttribute('aria-hidden')).toBe('true');
});
it('modal: restores sibling aria-hidden on close', async () => {
const a = injectBodySibling('sibling-a');
mountDialog({ defaultOpen: true, modal: true });
await flush();
expect(a.getAttribute('aria-hidden')).toBe('true');
$close()!.click();
await flush();
expect(a.getAttribute('aria-hidden')).toBeNull();
expect(a.getAttribute('data-aria-hidden')).toBeNull();
});
it('non-modal: no body-scroll lock, no aria-hidden on siblings, no aria-modal', async () => {
const a = injectBodySibling('sibling-a');
mountDialog({ defaultOpen: true, modal: false });
await flush();
const content = $content()!;
expect(content.getAttribute('aria-modal')).toBeNull();
expect(document.body.style.overflow).not.toBe('hidden');
expect(a.getAttribute('aria-hidden')).toBeNull();
});
it('non-modal: omits overlay', async () => {
mountDialog({ defaultOpen: true, modal: false });
await flush();
const overlays = [...document.querySelectorAll<HTMLElement>('[data-state="open"]')]
.filter(el => el.getAttribute('role') !== 'dialog' && !el.hasAttribute('aria-haspopup'));
expect(overlays).toHaveLength(0);
});
it('modal: renders overlay with data-state="open"', async () => {
mountDialog({ defaultOpen: true, modal: true });
await flush();
const overlay = document.querySelector<HTMLElement>('[data-testid="overlay"]');
expect(overlay).toBeTruthy();
expect(overlay!.getAttribute('data-state')).toBe('open');
});
});
describe('Dialog / trigger aria-controls', () => {
it('omits aria-controls while closed and sets it once open', async () => {
mountDialog();
const trigger = $trigger();
expect(trigger.getAttribute('aria-controls')).toBeNull();
trigger.click();
await flush();
const content = $content()!;
expect(trigger.getAttribute('aria-controls')).toBe(content.id);
});
});
describe('Dialog / root slot close helper', () => {
it('exposes a close helper from the default slot', async () => {
const Wrapper = defineComponent({
setup() {
return () => h(
DialogRoot,
{ defaultOpen: true },
{
default: (slotProps: { open: boolean; close: () => void }) => [
h('span', { 'data-testid': 'open-state' }, String(slotProps.open)),
h('button', { 'data-testid': 'slot-close', onClick: () => slotProps.close() }, 'X'),
h(DialogPortal, null, {
default: () => h(DialogContent, null, {
default: () => h(DialogTitle, null, { default: () => 'T' }),
}),
}),
],
},
);
},
});
track(mount(Wrapper, { attachTo: document.body }));
await flush();
expect($content()).toBeTruthy();
document.querySelector<HTMLButtonElement>('[data-testid="slot-close"]')!.click();
await flush();
expect($content()).toBeNull();
expect(document.querySelector('[data-testid="open-state"]')!.textContent).toBe('false');
});
});
describe('Dialog / dev accessibility warnings', () => {
function mountNoTitle() {
const Wrapper = defineComponent({
setup() {
return () => h(DialogRoot, { defaultOpen: true }, {
default: () => h(DialogPortal, null, {
default: () => h(DialogContent, null, { default: () => h('span', null, 'body') }),
}),
});
},
});
return track(mount(Wrapper, { attachTo: document.body }));
}
it('warns when content has no title', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
mountNoTitle();
await flush();
expect(warn).toHaveBeenCalledWith(expect.stringContaining('DialogTitle'));
warn.mockRestore();
});
it('does not warn when a title is present', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
mountDialog({ defaultOpen: true });
await flush();
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('DialogTitle'));
warn.mockRestore();
});
});
describe('Dialog / modal outside-interaction guards', () => {
function fireOutsidePointerDown(button: number, ctrlKey = false): void {
document.body.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true,
cancelable: true,
button,
ctrlKey,
}));
}
it('does not close on right-click outside (modal)', async () => {
mountDialog({ defaultOpen: true, modal: true });
await flush();
expect($content()).toBeTruthy();
fireOutsidePointerDown(2);
await flush();
expect($content()).toBeTruthy();
});
it('does not close on ctrl+left-click outside (modal)', async () => {
mountDialog({ defaultOpen: true, modal: true });
await flush();
fireOutsidePointerDown(0, true);
await flush();
expect($content()).toBeTruthy();
});
it('still closes on a plain left-click outside (modal)', async () => {
mountDialog({ defaultOpen: true, modal: true });
await flush();
fireOutsidePointerDown(0);
await flush();
expect($content()).toBeNull();
});
it('does not close when focus moves outside while trapped (modal)', async () => {
const outside = injectBodySibling('outside-focusable');
mountDialog({ defaultOpen: true, modal: true });
await flush();
expect($content()).toBeTruthy();
// Dispatch a focusin whose target is the outside sibling.
outside.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
await flush();
expect($content()).toBeTruthy();
});
});
describe('Dialog / non-modal trigger-containment guard', () => {
it('does not dismiss-then-reopen when clicking the trigger while open', async () => {
mountDialog({ defaultOpen: true, modal: false });
await flush();
const trigger = $trigger();
expect($content()).toBeTruthy();
// A pointerdown whose target is the trigger should be treated as inside.
trigger.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true }));
await flush();
// Content stays mounted (the outside-interaction dismiss was suppressed).
expect($content()).toBeTruthy();
});
});
describe('Dialog / programmatic-open trigger capture', () => {
it('captures the active element as the trigger when opened without a DialogTrigger', async () => {
const seed = injectBodySibling('seed-focus') as HTMLElement;
seed.tabIndex = 0;
seed.focus();
const open = ref(false);
const Wrapper = defineComponent({
setup() {
return () => h(
DialogRoot,
{ open: open.value, 'onUpdate:open': (v: boolean) => { open.value = v; } },
{
default: () => h(DialogPortal, null, {
default: () => h(DialogContent, null, {
default: () => h(DialogTitle, null, { default: () => 'T' }),
}),
}),
},
);
},
});
track(mount(Wrapper, { attachTo: document.body }));
open.value = true;
await flush();
expect($content()).toBeTruthy();
// The dialog has no DialogTrigger; the previously-focused element became the
// restore origin (only meaningfully asserted in the browser suite, but the
// capture path must not throw here).
});
});
// -----------------------------------------------------------------------------
// Browser-only tests (real focus behaviour).
// -----------------------------------------------------------------------------
function settle() {
return new Promise((r) => {
requestAnimationFrame(() => requestAnimationFrame(() => r(null)));
});
}
function mountInDom<T>(component: T) {
const host = document.createElement('div');
document.body.appendChild(host);
return render(component, { container: host });
}
const DialogBrowserHarness = defineComponent({
setup() {
const open = ref(false);
return { open };
},
render() {
return h(DialogRoot, {
open: this.open,
'onUpdate:open': (v: boolean) => { this.open = v; },
}, {
default: () => [
h(DialogTrigger, { id: 'trigger' }, { default: () => 'Open' }),
h(DialogPortal, null, {
default: () => [
h(DialogOverlay, { id: 'overlay' }),
h(DialogContent, { id: 'content' }, {
default: () => [
h(DialogTitle, null, { default: () => 'Title' }),
h(DialogDescription, null, { default: () => 'Desc' }),
h('button', { id: 'first-inside' }, 'First'),
h('button', { id: 'second-inside' }, 'Second'),
h(DialogClose, { id: 'close' }, { default: () => 'Close' }),
],
}),
],
}),
],
});
},
});
describe('Dialog / focus (browser)', () => {
it('moves focus into content on open and returns to trigger on close', async () => {
const { container } = mountInDom(DialogBrowserHarness);
const trigger = container.querySelector<HTMLButtonElement>('#trigger')!;
trigger.focus();
expect(document.activeElement).toBe(trigger);
await userEvent.click(trigger);
await settle();
const content = document.querySelector<HTMLElement>('#content')!;
expect(content.contains(document.activeElement)).toBe(true);
await userEvent.keyboard('{Escape}');
await settle();
expect(document.querySelector('#content')).toBeNull();
expect(document.activeElement).toBe(trigger);
});
it('close button closes dialog and restores focus to trigger', async () => {
const { container } = mountInDom(DialogBrowserHarness);
const trigger = container.querySelector<HTMLButtonElement>('#trigger')!;
trigger.focus();
await userEvent.click(trigger);
await settle();
await userEvent.click(document.querySelector<HTMLButtonElement>('#close')!);
await settle();
expect(document.querySelector('#content')).toBeNull();
expect(document.activeElement).toBe(trigger);
});
it('Tab cycles focus inside the content (focus trap)', async () => {
const { container } = mountInDom(DialogBrowserHarness);
const trigger = container.querySelector<HTMLButtonElement>('#trigger')!;
trigger.focus();
await userEvent.click(trigger);
await settle();
const content = document.querySelector<HTMLElement>('#content')!;
for (let i = 0; i < 6; i++) {
await userEvent.keyboard('{Tab}');
await settle();
expect(content.contains(document.activeElement)).toBe(true);
}
});
});
@@ -0,0 +1,32 @@
import type { ComputedRef, Ref, WritableComputedRef } from 'vue';
import { useContextFactory } from '@robonen/vue';
export interface DialogContext {
/** Controlled open state. Write to toggle. */
open: WritableComputedRef<boolean> | Ref<boolean>;
/** Whether the dialog is modal (traps focus, locks scroll, inert outside). */
modal: Ref<boolean>;
/** Stable id for the trigger element (for aria-controls). */
triggerId: ComputedRef<string>;
/** Stable id applied to DialogContent (for aria-controls target). */
contentId: ComputedRef<string>;
/** Id of DialogTitle — when mounted — used as aria-labelledby. */
titleId: Ref<string | undefined>;
/** Id of DialogDescription — when mounted — used as aria-describedby. */
descriptionId: Ref<string | undefined>;
/** DOM node of DialogTrigger — used to restore focus and as a dismiss anchor. */
triggerElement: Ref<HTMLElement | undefined>;
/** DOM node of the currently mounted DialogContent. */
contentElement: Ref<HTMLElement | undefined>;
/** Programmatically open the dialog. */
onOpen: () => void;
/** Programmatically close the dialog. */
onClose: () => void;
/** Toggle the dialog. */
onToggle: () => void;
}
const ctx = useContextFactory<DialogContext>('DialogContext');
export const provideDialogContext = ctx.provide;
export const useDialogContext = ctx.inject;
+125
View File
@@ -0,0 +1,125 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
DialogClose,
DialogContent,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
DialogTrigger,
} from '@robonen/primitives';
const open = ref(false);
const name = ref('Ada Lovelace');
const email = ref('ada@example.com');
const saved = ref('');
const draftName = ref(name.value);
const draftEmail = ref(email.value);
function onOpenAutoFocus() {
// Start from the current values each time the dialog opens.
draftName.value = name.value;
draftEmail.value = email.value;
}
function save() {
name.value = draftName.value;
email.value = draftEmail.value;
saved.value = `${name.value} · ${email.value}`;
open.value = false;
}
</script>
<template>
<div class="flex flex-col items-start gap-3 text-fg">
<div class="flex items-center gap-3">
<div class="flex size-9 items-center justify-center rounded-full bg-accent text-sm font-semibold text-accent-fg">
{{ name.charAt(0) }}
</div>
<div class="text-sm leading-tight">
<div class="font-medium">{{ name }}</div>
<div class="text-fg-muted">{{ email }}</div>
</div>
</div>
<p v-if="saved" class="text-xs text-emerald-600 dark:text-emerald-400">
Saved: {{ saved }}
</p>
<DialogRoot v-model:open="open">
<DialogTrigger
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"
>
Edit profile
</DialogTrigger>
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm" />
<DialogContent
class="demo-card fixed left-1/2 top-1/2 z-50 w-[min(92vw,28rem)] -translate-x-1/2 -translate-y-1/2 p-5 shadow-xl focus:outline-none"
@open-auto-focus="onOpenAutoFocus"
>
<div class="flex items-start justify-between gap-4">
<div>
<DialogTitle class="text-base font-semibold text-fg">
Edit profile
</DialogTitle>
<DialogDescription class="mt-1 text-sm text-fg-muted">
Update your name and email. Changes apply when you save.
</DialogDescription>
</div>
<DialogClose
aria-label="Close"
class="-mr-1 -mt-1 inline-flex size-7 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-bg-subtle hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<svg
class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</DialogClose>
</div>
<form class="mt-4 flex flex-col gap-3" @submit.prevent="save">
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium text-fg">Name</span>
<input
v-model="draftName"
type="text"
class="rounded-md border border-border bg-bg px-2.5 py-1.5 text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium text-fg">Email</span>
<input
v-model="draftEmail"
type="email"
class="rounded-md border border-border bg-bg px-2.5 py-1.5 text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
</label>
<div class="mt-2 flex justify-end gap-2">
<DialogClose
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
</DialogClose>
<button
type="submit"
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"
>
Save changes
</button>
</div>
</form>
</DialogContent>
</DialogPortal>
</DialogRoot>
</div>
</template>
@@ -0,0 +1,20 @@
export { default as DialogRoot } from './DialogRoot.vue';
export { default as DialogTrigger } from './DialogTrigger.vue';
export { default as DialogPortal } from './DialogPortal.vue';
export { default as DialogOverlay } from './DialogOverlay.vue';
export { default as DialogContent } from './DialogContent.vue';
export { default as DialogTitle } from './DialogTitle.vue';
export { default as DialogDescription } from './DialogDescription.vue';
export { default as DialogClose } from './DialogClose.vue';
export { useDialogContext } from './context';
export type { DialogContext } from './context';
export type { DialogRootProps } from './DialogRoot.vue';
export type { DialogTriggerProps } from './DialogTrigger.vue';
export type { DialogPortalProps } from './DialogPortal.vue';
export type { DialogOverlayProps } from './DialogOverlay.vue';
export type { DialogContentEmits, DialogContentProps } from './DialogContent.vue';
export type { DialogTitleProps } from './DialogTitle.vue';
export type { DialogDescriptionProps } from './DialogDescription.vue';
export type { DialogCloseProps } from './DialogClose.vue';
@@ -0,0 +1,42 @@
import type { Ref } from 'vue';
import { onMounted } from 'vue';
interface AccessibilityWarningOptions {
/** Id the Title registered into the Root context, or `undefined` when absent. */
titleId: Ref<string | undefined>;
/** Id the Description registered into the Root context, or `undefined` when absent. */
descriptionId: Ref<string | undefined>;
/** Resolved content element — read its `aria-describedby` to validate the link. */
contentElement: Ref<HTMLElement | undefined>;
}
const TITLE_MESSAGE
= 'DialogContent requires a DialogTitle so screen readers can announce the dialog. '
+ 'If the title should not be visible, keep it in the DOM and hide it visually instead of omitting it.';
const DESCRIPTION_MESSAGE
= 'DialogContent references an aria-describedby id that has no matching element. '
+ 'Render a DialogDescription, or drop the description wiring entirely.';
/**
* Dev-only accessibility audit for a mounted DialogContent. Warns once on mount
* when no DialogTitle is registered (the dialog would ship unlabeled) and when
* the content advertises an `aria-describedby` whose target element is missing.
* Compiled out of production builds via the `__DEV__` global.
*/
export function useDialogAccessibilityWarning({
titleId,
descriptionId,
contentElement,
}: AccessibilityWarningOptions): void {
if (!__DEV__) return;
onMounted(() => {
if (!titleId.value || !document.getElementById(titleId.value))
console.warn(TITLE_MESSAGE);
const describedBy = contentElement.value?.getAttribute('aria-describedby');
if (descriptionId.value && describedBy && !document.getElementById(descriptionId.value))
console.warn(DESCRIPTION_MESSAGE);
});
}
@@ -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,
};
}
@@ -0,0 +1,22 @@
<script lang="ts">
import type { PopperArrowProps } from '../popper';
/**
* An optional arrow that points from the hover card content back toward the
* trigger. Place it inside `HoverCardContent`; it tracks the resolved side and
* alignment automatically.
*/
export interface HoverCardArrowProps extends PopperArrowProps {}
</script>
<script setup lang="ts">
import { PopperArrow } from '../popper';
const { width = 10, height = 5 } = defineProps<HoverCardArrowProps>();
</script>
<template>
<PopperArrow :width="width" :height="height">
<slot />
</PopperArrow>
</template>
@@ -0,0 +1,42 @@
<script lang="ts">
import type { HoverCardContentImplEmits, HoverCardContentImplProps } from './HoverCardContentImpl.vue';
/**
* The floating panel that holds the previewed content. It is positioned against
* the trigger and is mounted only while the card is open (gated by `Presence`),
* unless `forceMount` is set so you can drive enter/exit animations yourself.
*/
export interface HoverCardContentProps extends HoverCardContentImplProps {
/** Keep mounted for CSS exit animations. */
forceMount?: boolean;
}
export type HoverCardContentEmits = HoverCardContentImplEmits;
</script>
<script setup lang="ts">
import HoverCardContentImpl from './HoverCardContentImpl.vue';
import { Presence } from '../../utilities/presence';
import { useForwardExpose } from '@robonen/vue';
import { useHoverCardContext } from './context';
const { forceMount = false, ...contentProps } = defineProps<HoverCardContentProps>();
const emit = defineEmits<HoverCardContentEmits>();
const ctx = useHoverCardContext();
const { forwardRef } = useForwardExpose();
</script>
<template>
<Presence :present="ctx.open.value" :force-mount="forceMount">
<HoverCardContentImpl
v-bind="contentProps"
:ref="forwardRef"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
>
<slot />
</HoverCardContentImpl>
</Presence>
</template>
@@ -0,0 +1,198 @@
<script lang="ts">
import type { PopperContentProps } from '../popper';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Internal implementation of the hover card panel: it wires up Popper
* positioning, the dismissable layer, the trigger-to-content grace area, and
* selection handling. Rendered by `HoverCardContent`; not exported for direct
* use.
*/
export interface HoverCardContentImplProps extends PrimitiveProps, Pick<
PopperContentProps,
| 'side'
| 'sideOffset'
| 'sideFlip'
| 'align'
| 'alignOffset'
| 'alignFlip'
| 'avoidCollisions'
| 'collisionBoundary'
| 'collisionPadding'
| 'arrowPadding'
| 'hideShiftedArrow'
| 'sticky'
| 'hideWhenDetached'
| 'positionStrategy'
| 'updatePositionStrategy'
| 'disableUpdateOnLayoutShift'
| 'prioritizePosition'
| 'reference'
> {}
export interface HoverCardContentImplEmits {
escapeKeyDown: [event: KeyboardEvent];
pointerDownOutside: [event: PointerEvent | MouseEvent];
focusOutside: [event: FocusEvent];
interactOutside: [event: Event];
}
</script>
<script setup lang="ts">
import { nextTick, onMounted, onScopeDispose, onWatcherCleanup, ref, watchEffect } from 'vue';
import { DismissableLayer } from '../../utilities/dismissable-layer';
import { PopperContent } from '../popper';
import { excludeTouch, getTabbableNodes } from './utils';
import { useEventListener, useForwardExpose } from '@robonen/vue';
import { useGraceArea } from '../../internal/utils/useGraceArea';
import { useHoverCardContext } from './context';
const {
side = 'bottom',
sideOffset = 0,
sideFlip = true,
align = 'center',
alignOffset = 0,
alignFlip = true,
avoidCollisions = true,
collisionBoundary = [],
collisionPadding = 0,
arrowPadding = 0,
hideShiftedArrow = true,
sticky = 'partial',
hideWhenDetached = false,
positionStrategy,
updatePositionStrategy,
disableUpdateOnLayoutShift = false,
prioritizePosition = false,
reference,
as = 'div',
} = defineProps<HoverCardContentImplProps>();
const emit = defineEmits<HoverCardContentImplEmits>();
const ctx = useHoverCardContext();
const { forwardRef, currentElement } = useForwardExpose();
const { isPointerInTransit, onPointerExit } = useGraceArea(ctx.trigger, currentElement);
watchEffect(() => {
ctx.isPointerInTransit.value = isPointerInTransit.value;
});
onPointerExit(() => ctx.onClose());
const containSelection = ref(false);
let originalUserSelect: string | undefined;
let originalWebkitUserSelect: string | undefined;
watchEffect(() => {
if (!containSelection.value) return;
const body = document.body;
originalUserSelect = body.style.userSelect;
originalWebkitUserSelect = body.style.webkitUserSelect;
body.style.userSelect = 'none';
body.style.webkitUserSelect = 'none';
onWatcherCleanup(() => {
body.style.userSelect = originalUserSelect ?? '';
body.style.webkitUserSelect = originalWebkitUserSelect ?? '';
});
});
function onPointerUp() {
containSelection.value = false;
ctx.isPointerDownOnContent.value = false;
nextTick(() => {
const hasSelection = document.getSelection()?.toString() !== '';
if (hasSelection) ctx.hasSelection.value = true;
});
}
function onScrollCapture(event: Event) {
const target = event.target as Node | null;
if (target && ctx.trigger.value && target.contains(ctx.trigger.value)) ctx.onDismiss();
}
// Auto-removed on scope dispose. SSR-safe: the window default no-ops without a
// `window`, and the `document` getter resolves to `undefined` on the server.
// `passive`/`capture` preserved.
useEventListener(() => globalThis.document, 'pointerup', onPointerUp);
useEventListener('scroll', onScrollCapture, { capture: true, passive: true });
onMounted(() => {
// A hover-triggered card is not a focus stop: it opens on hover/focus of the
// trigger, never receives focus itself, and its body is a sighted-user
// preview. Remove every focusable descendant from the tab sequence so keyboard
// users don't tab into content that has no visible focus context.
const content = currentElement.value;
if (content) {
for (const tabbable of getTabbableNodes(content)) tabbable.setAttribute('tabindex', '-1');
}
});
onScopeDispose(() => {
ctx.hasSelection.value = false;
ctx.isPointerDownOnContent.value = false;
});
function onContentPointerDown(event: PointerEvent) {
if ((event.currentTarget as HTMLElement).contains(event.target as HTMLElement)) {
containSelection.value = true;
}
ctx.hasSelection.value = false;
ctx.isPointerDownOnContent.value = true;
}
function onContentPointerEnter(event: PointerEvent) {
excludeTouch(() => ctx.onOpen())(event);
}
</script>
<template>
<DismissableLayer
as="template"
:disable-outside-pointer-events="false"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside.prevent="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
@dismiss="ctx.onDismiss"
>
<PopperContent
:ref="forwardRef"
:as="as"
:side="side"
:side-offset="sideOffset"
:side-flip="sideFlip"
:align="align"
:align-offset="alignOffset"
:align-flip="alignFlip"
:avoid-collisions="avoidCollisions"
:collision-boundary="collisionBoundary"
:collision-padding="collisionPadding"
:arrow-padding="arrowPadding"
:hide-shifted-arrow="hideShiftedArrow"
:sticky="sticky"
:hide-when-detached="hideWhenDetached"
:position-strategy="positionStrategy"
:update-position-strategy="updatePositionStrategy"
:disable-update-on-layout-shift="disableUpdateOnLayoutShift"
:prioritize-position="prioritizePosition"
:reference="reference"
:data-state="ctx.open.value ? 'open' : 'closed'"
:style="{
'user-select': containSelection ? 'text' : undefined,
'-webkit-user-select': containSelection ? 'text' : undefined,
'--hover-card-content-transform-origin': 'var(--popper-transform-origin)',
'--hover-card-content-available-width': 'var(--popper-available-width)',
'--hover-card-content-available-height': 'var(--popper-available-height)',
'--hover-card-trigger-width': 'var(--popper-anchor-width)',
'--hover-card-trigger-height': 'var(--popper-anchor-height)',
}"
@pointerenter="onContentPointerEnter"
@pointerdown="onContentPointerDown"
>
<slot />
</PopperContent>
</DismissableLayer>
</template>
@@ -0,0 +1,22 @@
<script lang="ts">
import type { TeleportPrimitiveProps } from '../../utilities/teleport';
/**
* Teleports the hover card content into a different part of the DOM (the body by
* default) so it escapes parent overflow and stacking-context clipping. Wrap the
* content in it to keep the floating card above the rest of the page.
*/
export interface HoverCardPortalProps extends TeleportPrimitiveProps {}
</script>
<script setup lang="ts">
import { Portal } from '../../utilities/teleport';
const props = defineProps<HoverCardPortalProps>();
</script>
<template>
<Portal v-bind="props">
<slot />
</Portal>
</template>
@@ -0,0 +1,133 @@
<script lang="ts">
/**
* A rich, floating card that previews related content when the pointer hovers
* (or keyboard focus lands) on a trigger, after a short open delay. Built on
* Popper for collision-aware positioning, with a grace area so the pointer can
* travel from the trigger to the card without it closing.
*
* Use it for sighted-user preview affordances — a user profile on an @mention,
* a link preview, or a glance at a record — never for essential information,
* since it is not exposed to touch or assistive technology the way a tooltip is.
* The root owns open state and provides context to every part; bind
* `v-model:open` (or listen to `update:open`) to observe or control it.
*/
export interface HoverCardRootProps {
/** Controlled open state. Bind with `v-model:open`. */
open?: boolean;
/** Initial open state for uncontrolled usage. */
defaultOpen?: boolean;
/** Delay (ms) before the content opens after pointer enters trigger. */
openDelay?: number;
/** Delay (ms) before the content closes after pointer leaves trigger/content. */
closeDelay?: number;
}
export interface HoverCardRootEmits {
/** Emitted whenever the open state changes. Drives `v-model:open`. */
'update:open': [value: boolean];
}
</script>
<script setup lang="ts">
import { computed, onScopeDispose, ref, shallowRef } from 'vue';
import { PopperRoot } from '../popper';
import { provideHoverCardContext } from './context';
const { openDelay = 700, closeDelay = 300, defaultOpen = false } = defineProps<HoverCardRootProps>();
const openModel = defineModel<boolean | undefined>('open', { default: undefined });
const uncontrolled = ref(defaultOpen);
// `open` intentionally shares the model name: it's a local read-only computed that
// resolves the controlled model against the uncontrolled fallback. Safe in script-setup.
// eslint-disable-next-line vue/no-dupe-keys
const open = computed(() => openModel.value ?? uncontrolled.value);
function setOpen(value: boolean) {
if (openModel.value === undefined) uncontrolled.value = value;
openModel.value = value;
}
let openTimer = 0;
let closeTimer = 0;
function clearOpenTimer() {
if (openTimer) {
clearTimeout(openTimer);
openTimer = 0;
}
}
function clearCloseTimer() {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = 0;
}
}
const hasSelection = ref(false);
const isPointerDownOnContent = ref(false);
const isPointerInTransit = ref(false);
const trigger = shallowRef<HTMLElement | undefined>();
function onOpen() {
clearCloseTimer();
if (openDelay <= 0) {
setOpen(true);
return;
}
openTimer = globalThis.setTimeout(() => {
setOpen(true);
openTimer = 0;
}, openDelay);
}
function onClose() {
clearOpenTimer();
if (hasSelection.value || isPointerDownOnContent.value) return;
if (closeDelay <= 0) {
setOpen(false);
return;
}
closeTimer = globalThis.setTimeout(() => {
setOpen(false);
closeTimer = 0;
}, closeDelay);
}
function onDismiss() {
clearOpenTimer();
clearCloseTimer();
setOpen(false);
}
onScopeDispose(() => {
clearOpenTimer();
clearCloseTimer();
});
provideHoverCardContext({
open,
onOpenChange: setOpen,
onOpen,
onClose,
onDismiss,
hasSelection,
isPointerDownOnContent,
isPointerInTransit,
trigger,
onTriggerChange(el) {
trigger.value = el;
},
});
defineSlots<{
default?: (props: { open: boolean }) => unknown;
}>();
</script>
<template>
<PopperRoot>
<slot :open="open" />
</PopperRoot>
</template>
@@ -0,0 +1,54 @@
<script lang="ts">
import type { PopperAnchorProps } from '../popper';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The element that opens the hover card on pointer enter or focus and anchors
* the floating content to it. Renders as an `<a>` by default; pass a custom
* `reference` to position the card against a different element.
*/
export interface HoverCardTriggerProps extends PrimitiveProps, Pick<PopperAnchorProps, 'reference'> {}
</script>
<script setup lang="ts">
import { PopperAnchor } from '../popper';
import { Primitive } from '../../internal/primitive';
import { excludeTouch } from './utils';
import { useForwardExpose } from '@robonen/vue';
import { useHoverCardContext } from './context';
import { watch } from 'vue';
const { as = 'a', reference } = defineProps<HoverCardTriggerProps>();
const ctx = useHoverCardContext();
const { forwardRef, currentElement } = useForwardExpose();
watch(currentElement, el => ctx.onTriggerChange(el));
function onPointerLeave() {
// Defer so grace-area detection can mark the pointer as in transit before
// we decide to close.
setTimeout(() => {
if (!ctx.isPointerInTransit.value && !ctx.open.value) ctx.onClose();
else if (!ctx.isPointerInTransit.value) ctx.onClose();
}, 0);
}
</script>
<template>
<PopperAnchor as="template" :reference="reference">
<Primitive
:ref="forwardRef"
:as="as"
:data-state="ctx.open.value ? 'open' : 'closed'"
data-hover-card-trigger
data-grace-area-trigger
@pointerenter="excludeTouch(ctx.onOpen)($event)"
@pointerleave="excludeTouch(onPointerLeave)($event)"
@focus="ctx.onOpen()"
@blur="ctx.onClose()"
>
<slot />
</Primitive>
</PopperAnchor>
</template>
@@ -0,0 +1,252 @@
import {
HoverCardContent,
HoverCardRoot,
HoverCardTrigger,
} from '../../../index';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
document.body.removeAttribute('style');
vi.useRealTimers();
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
function mountHoverCard(options: {
open?: boolean;
defaultOpen?: boolean;
openDelay?: number;
closeDelay?: number;
onUpdateOpen?: (v: boolean | undefined) => void;
forceMount?: boolean;
} = {}) {
const Wrapper = defineComponent({
setup() {
return () =>
h(
HoverCardRoot,
{
open: options.open,
defaultOpen: options.defaultOpen,
openDelay: options.openDelay,
closeDelay: options.closeDelay,
'onUpdate:open': options.onUpdateOpen,
},
{
default: () => [
h(HoverCardTrigger, null, { default: () => 'Trigger' }),
h(
HoverCardContent,
{ forceMount: options.forceMount },
{ default: () => 'Card body' },
),
],
},
);
},
});
return track(mount(Wrapper, { attachTo: document.body }));
}
function getTrigger(): HTMLElement {
return document.querySelector('[data-hover-card-trigger]') as HTMLElement;
}
describe('HoverCard', () => {
it('renders trigger with closed state by default', () => {
mountHoverCard();
const trigger = getTrigger();
expect(trigger).toBeTruthy();
expect(trigger.getAttribute('data-state')).toBe('closed');
expect(trigger.hasAttribute('data-grace-area-trigger')).toBe(true);
});
it('opens with defaultOpen and renders content', async () => {
mountHoverCard({ defaultOpen: true });
await nextTick();
expect(getTrigger().getAttribute('data-state')).toBe('open');
expect(document.body.textContent).toContain('Card body');
});
it('opens after openDelay on pointer enter and closes after closeDelay on leave', async () => {
vi.useFakeTimers();
mountHoverCard({ openDelay: 200, closeDelay: 100 });
const trigger = getTrigger();
trigger.dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse' }));
expect(trigger.getAttribute('data-state')).toBe('closed');
vi.advanceTimersByTime(200);
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('open');
trigger.dispatchEvent(new PointerEvent('pointerleave', { pointerType: 'mouse' }));
// pointerleave defers close via setTimeout(0) then closeDelay
vi.advanceTimersByTime(0);
vi.advanceTimersByTime(100);
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('closed');
});
it('opens immediately on focus and closes on blur after closeDelay', async () => {
vi.useFakeTimers();
mountHoverCard({ openDelay: 500, closeDelay: 50 });
const trigger = getTrigger();
trigger.dispatchEvent(new FocusEvent('focus'));
// openDelay still applies for focus too (matches reka behavior)
vi.advanceTimersByTime(500);
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('open');
trigger.dispatchEvent(new FocusEvent('blur'));
vi.advanceTimersByTime(50);
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('closed');
});
it('ignores touch pointer events', async () => {
vi.useFakeTimers();
mountHoverCard({ openDelay: 50 });
const trigger = getTrigger();
trigger.dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'touch' }));
vi.advanceTimersByTime(50);
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('closed');
});
it('closes on Escape via dismissable layer', async () => {
mountHoverCard({ defaultOpen: true });
await nextTick();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await nextTick();
expect(getTrigger().getAttribute('data-state')).toBe('closed');
});
it('supports controlled v-model', async () => {
const onUpdate = vi.fn();
const Wrapper = defineComponent({
props: { open: { type: Boolean, default: false } },
emits: ['update:open'],
setup(props, { emit }) {
return () =>
h(
HoverCardRoot,
{
open: props.open,
openDelay: 0,
closeDelay: 0,
'onUpdate:open': (v: boolean | undefined) => {
onUpdate(v);
emit('update:open', v);
},
},
{
default: () => [
h(HoverCardTrigger, null, { default: () => 'T' }),
h(HoverCardContent, null, { default: () => 'B' }),
],
},
);
},
});
const wrapper = track(mount(Wrapper, { attachTo: document.body, props: { open: false } }));
const trigger = getTrigger();
trigger.dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse' }));
await nextTick();
expect(onUpdate).toHaveBeenCalledWith(true);
expect(trigger.getAttribute('data-state')).toBe('closed');
await wrapper.setProps({ open: true });
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('open');
});
it('removes focusable content descendants from the tab sequence', async () => {
const Wrapper = defineComponent({
setup() {
return () =>
h(HoverCardRoot, { defaultOpen: true }, {
default: () => [
h(HoverCardTrigger, null, { default: () => 'Trigger' }),
h(HoverCardContent, null, {
default: () => [
h('a', { href: '#one', 'data-link': 'one' }, 'Link one'),
h('button', { 'data-btn': 'two' }, 'Button two'),
],
}),
],
});
},
});
track(mount(Wrapper, { attachTo: document.body }));
await nextTick();
const link = document.querySelector('[data-link="one"]') as HTMLElement;
const button = document.querySelector('[data-btn="two"]') as HTMLElement;
expect(link).toBeTruthy();
expect(button).toBeTruthy();
expect(link.getAttribute('tabindex')).toBe('-1');
expect(button.getAttribute('tabindex')).toBe('-1');
});
it('exposes the content element via a forwarded ref', async () => {
const contentRef = vi.fn();
const Wrapper = defineComponent({
setup() {
return () =>
h(HoverCardRoot, { defaultOpen: true }, {
default: () => [
h(HoverCardTrigger, null, { default: () => 'Trigger' }),
h(HoverCardContent, { ref: contentRef }, { default: () => 'Body' }),
],
});
},
});
track(mount(Wrapper, { attachTo: document.body }));
await nextTick();
expect(contentRef).toHaveBeenCalled();
const instance = contentRef.mock.calls.at(-1)?.[0];
expect(instance).toBeTruthy();
});
it('forwards extended positioning props to the content without error', async () => {
const Wrapper = defineComponent({
setup() {
return () =>
h(HoverCardRoot, { defaultOpen: true }, {
default: () => [
h(HoverCardTrigger, null, { default: () => 'Trigger' }),
h(
HoverCardContent,
{
prioritizePosition: true,
disableUpdateOnLayoutShift: true,
hideShiftedArrow: false,
'data-marker': 'content',
},
{ default: () => 'Body' },
),
],
});
},
});
track(mount(Wrapper, { attachTo: document.body }));
await nextTick();
const content = document.querySelector('[data-marker="content"]') as HTMLElement;
expect(content).toBeTruthy();
expect(content.getAttribute('data-state')).toBe('open');
});
});
@@ -0,0 +1,20 @@
import type { ComputedRef, Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
export interface HoverCardContext {
open: ComputedRef<boolean>;
onOpenChange: (open: boolean) => void;
onOpen: () => void;
onClose: () => void;
onDismiss: () => void;
hasSelection: Ref<boolean>;
isPointerDownOnContent: Ref<boolean>;
isPointerInTransit: Ref<boolean>;
trigger: Ref<HTMLElement | undefined>;
onTriggerChange: (el: HTMLElement | undefined) => void;
}
const ctx = useContextFactory<HoverCardContext>('HoverCardContext');
export const provideHoverCardContext = ctx.provide;
export const useHoverCardContext = ctx.inject;
@@ -0,0 +1,62 @@
<script setup lang="ts">
import {
HoverCardArrow,
HoverCardContent,
HoverCardPortal,
HoverCardRoot,
HoverCardTrigger,
} from '@robonen/primitives';
const user = {
handle: '@robonen',
name: 'Andrew Robonen',
bio: 'Building headless Vue primitives. Coffee, types, and small bundles.',
following: 182,
followers: '4.1k',
};
</script>
<template>
<p class="text-sm text-fg">
Maintained by
<HoverCardRoot :open-delay="200" :close-delay="150">
<HoverCardTrigger
href="#"
class="rounded font-medium text-accent underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{{ user.handle }}
</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent
:side-offset="6"
class="demo-card z-50 w-72 p-4 text-fg shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in data-[state=open]:zoom-in-95"
>
<div class="flex items-start gap-3">
<div class="flex size-11 shrink-0 items-center justify-center rounded-full bg-accent text-base font-semibold text-accent-fg">
{{ user.name.charAt(0) }}
</div>
<div class="min-w-0">
<div class="truncate font-semibold leading-tight">{{ user.name }}</div>
<div class="truncate text-sm text-fg-muted">{{ user.handle }}</div>
</div>
</div>
<p class="mt-3 text-sm text-fg-muted">{{ user.bio }}</p>
<div class="mt-3 flex gap-4 border-t border-border pt-3 text-sm">
<span><span class="font-semibold text-fg">{{ user.following }}</span> <span class="text-fg-subtle">Following</span></span>
<span><span class="font-semibold text-fg">{{ user.followers }}</span> <span class="text-fg-subtle">Followers</span></span>
</div>
<HoverCardArrow :width="12" :height="6">
<svg width="12" height="6" viewBox="0 0 12 6" class="block" aria-hidden="true">
<path d="M0 0 L6 6 L12 0 Z" class="fill-bg-elevated stroke-border" stroke-width="1" />
</svg>
</HoverCardArrow>
</HoverCardContent>
</HoverCardPortal>
</HoverCardRoot>
hover the handle to preview.
</p>
</template>
@@ -0,0 +1,17 @@
export { default as HoverCardRoot } from './HoverCardRoot.vue';
export { default as HoverCardTrigger } from './HoverCardTrigger.vue';
export { default as HoverCardPortal } from './HoverCardPortal.vue';
export { default as HoverCardContent } from './HoverCardContent.vue';
export { default as HoverCardArrow } from './HoverCardArrow.vue';
export { useHoverCardContext, type HoverCardContext } from './context';
export type { HoverCardRootProps, HoverCardRootEmits } from './HoverCardRoot.vue';
export type { HoverCardTriggerProps } from './HoverCardTrigger.vue';
export type { HoverCardPortalProps } from './HoverCardPortal.vue';
export type { HoverCardContentProps, HoverCardContentEmits } from './HoverCardContent.vue';
export type {
HoverCardContentImplProps,
HoverCardContentImplEmits,
} from './HoverCardContentImpl.vue';
export type { HoverCardArrowProps } from './HoverCardArrow.vue';
@@ -0,0 +1,20 @@
/** Filter out touch pointer events — they're handled by long-press behavior elsewhere. */
export function excludeTouch<T extends (event: PointerEvent) => void>(handler: T) {
return (event: PointerEvent) => {
if (event.pointerType === 'touch') return;
handler(event);
};
}
/** Walk a subtree and collect elements that can receive focus via Tab. */
export function getTabbableNodes(container: HTMLElement): HTMLElement[] {
const nodes: HTMLElement[] = [];
const walker = container.ownerDocument.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
acceptNode: (node) => {
const el = node as HTMLElement;
return el.tabIndex >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
},
});
while (walker.nextNode()) nodes.push(walker.currentNode as HTMLElement);
return nodes;
}
@@ -0,0 +1,35 @@
<script lang="ts">
import type { PopperAnchorProps } from '../popper';
/**
* An optional alternate element to position Content against. Place it anywhere
* inside Root to anchor the popover to something other than the Trigger — the
* Trigger then only toggles open state and no longer drives positioning.
*/
export interface PopoverAnchorProps extends PopperAnchorProps {}
</script>
<script setup lang="ts">
import { onBeforeMount, onUnmounted } from 'vue';
import { PopperAnchor } from '../popper';
import { useForwardExpose } from '@robonen/vue';
import { usePopoverContext } from './context';
const props = defineProps<PopoverAnchorProps>();
const ctx = usePopoverContext();
const { forwardRef } = useForwardExpose();
onBeforeMount(() => {
ctx.hasCustomAnchor.value = true;
});
onUnmounted(() => {
ctx.hasCustomAnchor.value = false;
});
</script>
<template>
<PopperAnchor :ref="forwardRef" v-bind="props">
<slot />
</PopperAnchor>
</template>
@@ -0,0 +1,25 @@
<script lang="ts">
import type { PopperArrowProps } from '../popper';
/**
* An optional arrow that points from Content back toward the Trigger or Anchor,
* tracking the popover's resolved side and alignment. Place inside Content;
* pass custom SVG via the default slot to match your panel's styling.
*/
export interface PopoverArrowProps extends PopperArrowProps {}
</script>
<script setup lang="ts">
import { PopperArrow } from '../popper';
import { useForwardExpose } from '@robonen/vue';
const props = defineProps<PopoverArrowProps>();
const { forwardRef } = useForwardExpose();
</script>
<template>
<PopperArrow :ref="forwardRef" v-bind="props">
<slot />
</PopperArrow>
</template>
@@ -0,0 +1,30 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A button that closes the popover when activated. Place inside Content for an
* explicit dismiss control, such as an "X" in the corner or a "Done" button.
*/
export interface PopoverCloseProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
import { usePopoverContext } from './context';
const { as = 'button' } = defineProps<PopoverCloseProps>();
const { forwardRef } = useForwardExpose();
const ctx = usePopoverContext();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
@click="ctx.onOpenChange(false)"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,64 @@
<script lang="ts">
import type { PopoverContentImplEmits, PopoverContentImplProps } from './PopoverContentImpl.vue';
/**
* The floating panel itself — the positioned container for the popover's body.
* Renders only while open and picks a modal or non-modal implementation from
* the Root's `modal` setting: modal traps focus, locks body scroll, and blocks
* outside pointer events; non-modal does none of these. Emits focus and
* dismissal events so consumers can guard against closing.
*/
export interface PopoverContentProps extends PopoverContentImplProps {
/** Keep mounted for CSS exit animations. */
forceMount?: boolean;
}
export type PopoverContentEmits = PopoverContentImplEmits;
</script>
<script setup lang="ts">
import PopoverContentModal from './PopoverContentModal.vue';
import PopoverContentNonModal from './PopoverContentNonModal.vue';
import { Presence } from '../../utilities/presence';
import { useForwardExpose } from '@robonen/vue';
import { usePopoverContext } from './context';
const { forceMount = false, ...contentProps } = defineProps<PopoverContentProps>();
const emit = defineEmits<PopoverContentEmits>();
const ctx = usePopoverContext();
const { forwardRef } = useForwardExpose();
</script>
<template>
<Presence :present="ctx.open.value" :force-mount="forceMount">
<PopoverContentModal
v-if="ctx.modal.value"
v-bind="contentProps"
:ref="forwardRef"
@open-auto-focus="emit('openAutoFocus', $event)"
@close-auto-focus="emit('closeAutoFocus', $event)"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
@dismiss="emit('dismiss')"
>
<slot />
</PopoverContentModal>
<PopoverContentNonModal
v-else
v-bind="contentProps"
:ref="forwardRef"
@open-auto-focus="emit('openAutoFocus', $event)"
@close-auto-focus="emit('closeAutoFocus', $event)"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
@dismiss="emit('dismiss')"
>
<slot />
</PopoverContentNonModal>
</Presence>
</template>
@@ -0,0 +1,101 @@
<script lang="ts">
import type { DismissableLayerEmits } from '../../utilities/dismissable-layer';
import type { FocusScopeEmits } from '../../utilities/focus-scope';
import type { PopperContentProps } from '../popper';
/**
* Internal shared implementation behind PopoverContent — wraps a FocusScope, a
* DismissableLayer, and a PopperContent and applies the popover ARIA wiring and
* `--popover-*` style variables. Not exported; the modal and non-modal Content
* variants render this with the appropriate flags.
*/
export interface PopoverContentImplProps extends PopperContentProps {
/** Trap focus inside the content (modal popovers). */
trapFocus?: boolean;
/** Block outside pointer events (modal popovers). */
disableOutsidePointerEvents?: boolean;
}
export interface PopoverContentImplEmits {
openAutoFocus: FocusScopeEmits['mountAutoFocus'];
closeAutoFocus: FocusScopeEmits['unmountAutoFocus'];
escapeKeyDown: DismissableLayerEmits['escapeKeyDown'];
pointerDownOutside: DismissableLayerEmits['pointerDownOutside'];
focusOutside: DismissableLayerEmits['focusOutside'];
interactOutside: DismissableLayerEmits['interactOutside'];
dismiss: [];
}
/**
* Static `--popover-*` CSS-variable mappings forwarded to PopperContent. The
* values never change (pure `var(--popper-*)` references), so the object is
* hoisted to module scope with a stable, frozen identity. PopperContent
* re-positions on every animation frame while open (updatePositionStrategy
* 'always'); a stable reference lets Vue short-circuit the :style diff and
* removes a per-render object allocation on that hot path.
*/
const POPOVER_CONTENT_STYLE = Object.freeze({
'--popover-content-transform-origin': 'var(--popper-transform-origin)',
'--popover-content-available-width': 'var(--popper-available-width)',
'--popover-content-available-height': 'var(--popper-available-height)',
'--popover-trigger-width': 'var(--popper-anchor-width)',
'--popover-trigger-height': 'var(--popper-anchor-height)',
});
</script>
<script setup lang="ts">
import { DismissableLayer } from '../../utilities/dismissable-layer';
import { FocusScope } from '../../utilities/focus-scope';
import { PopperContent } from '../popper';
import { useFocusGuard, useForwardExpose } from '@robonen/vue';
import { usePopoverContext } from './context';
const {
trapFocus = false,
disableOutsidePointerEvents = false,
as = 'div',
...popperProps
} = defineProps<PopoverContentImplProps>();
const emit = defineEmits<PopoverContentImplEmits>();
const ctx = usePopoverContext();
const { forwardRef } = useForwardExpose();
// Insert tabbable focus-guard sentinels at the document edges so focusin/out
// are caught consistently and Tab cannot escape into the browser chrome.
useFocusGuard();
</script>
<template>
<FocusScope
as="template"
:loop="true"
:trapped="trapFocus"
@mount-auto-focus="emit('openAutoFocus', $event)"
@unmount-auto-focus="emit('closeAutoFocus', $event)"
>
<DismissableLayer
as="template"
:disable-outside-pointer-events="disableOutsidePointerEvents"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
@dismiss="ctx.onOpenChange(false)"
>
<PopperContent
:id="ctx.contentId.value"
:ref="forwardRef"
:as="as"
v-bind="popperProps"
:data-state="ctx.open.value ? 'open' : 'closed'"
:aria-labelledby="ctx.triggerId.value"
role="dialog"
:style="POPOVER_CONTENT_STYLE"
>
<slot />
</PopperContent>
</DismissableLayer>
</FocusScope>
</template>
@@ -0,0 +1,46 @@
<script setup lang="ts">
import type { PopoverContentImplEmits, PopoverContentImplProps } from './PopoverContentImpl.vue';
import PopoverContentImpl from './PopoverContentImpl.vue';
import { ref } from 'vue';
import { useBodyScrollLock, useForwardExpose } from '@robonen/vue';
import { useHideOthers } from '../../internal/utils/useHideOthers';
import { usePopoverContext } from './context';
const props = defineProps<PopoverContentImplProps>();
const emit = defineEmits<PopoverContentImplEmits>();
const ctx = usePopoverContext();
const isRightClickOutsideRef = ref(false);
const { forwardRef, currentElement } = useForwardExpose();
useBodyScrollLock();
// Modal popovers hide every sibling tree from assistive tech so screen readers
// stay scoped to the content while it is open (parity with Dialog/Menu).
useHideOthers(currentElement);
</script>
<template>
<PopoverContentImpl
v-bind="props"
:ref="forwardRef"
:trap-focus="ctx.open.value"
disable-outside-pointer-events
@close-auto-focus.prevent="(event: Event) => {
emit('closeAutoFocus', event);
if (!isRightClickOutsideRef) ctx.triggerElement.value?.focus();
}"
@pointer-down-outside="(event: PointerEvent | MouseEvent) => {
emit('pointerDownOutside', event);
const ctrlLeftClick = event.button === 0 && event.ctrlKey === true;
isRightClickOutsideRef = event.button === 2 || ctrlLeftClick;
}"
@focus-outside.prevent
@escape-key-down="emit('escapeKeyDown', $event)"
@interact-outside="emit('interactOutside', $event)"
@dismiss="emit('dismiss')"
@open-auto-focus="emit('openAutoFocus', $event)"
>
<slot />
</PopoverContentImpl>
</template>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import type { PopoverContentImplEmits, PopoverContentImplProps } from './PopoverContentImpl.vue';
import PopoverContentImpl from './PopoverContentImpl.vue';
import { ref } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { usePopoverContext } from './context';
const props = defineProps<PopoverContentImplProps>();
const emit = defineEmits<PopoverContentImplEmits>();
const ctx = usePopoverContext();
const hasInteractedOutsideRef = ref(false);
const hasPointerDownOutsideRef = ref(false);
const { forwardRef } = useForwardExpose();
</script>
<template>
<PopoverContentImpl
v-bind="props"
:ref="forwardRef"
:trap-focus="false"
:disable-outside-pointer-events="false"
@close-auto-focus="(event: Event) => {
emit('closeAutoFocus', event);
if (!event.defaultPrevented) {
if (!hasInteractedOutsideRef) ctx.triggerElement.value?.focus();
event.preventDefault();
}
hasInteractedOutsideRef = false;
hasPointerDownOutsideRef = false;
}"
@interact-outside="(event: PointerEvent | MouseEvent | FocusEvent) => {
emit('interactOutside', event);
if (!event.defaultPrevented) {
hasInteractedOutsideRef = true;
if (event.type === 'pointerdown') hasPointerDownOutsideRef = true;
}
const target = event.target as HTMLElement;
if (ctx.triggerElement.value?.contains(target)) event.preventDefault();
if (event.type === 'focusin' && hasPointerDownOutsideRef) event.preventDefault();
}"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@dismiss="emit('dismiss')"
@open-auto-focus="emit('openAutoFocus', $event)"
>
<slot />
</PopoverContentImpl>
</template>
@@ -0,0 +1,22 @@
<script lang="ts">
import type { TeleportPrimitiveProps } from '../../utilities/teleport';
/**
* Teleports Content out of the normal DOM flow (by default into `body`) so the
* popover renders above the rest of the page and escapes `overflow` and
* stacking contexts. Mounts its children only while the popover is open.
*/
export interface PopoverPortalProps extends TeleportPrimitiveProps {}
</script>
<script setup lang="ts">
import PortalPrimitive from '../../utilities/teleport/Teleport.vue';
const props = defineProps<PopoverPortalProps>();
</script>
<template>
<PortalPrimitive v-bind="props">
<slot />
</PortalPrimitive>
</template>
@@ -0,0 +1,69 @@
<script lang="ts">
/**
* A floating panel anchored to a trigger, used for rich, interactive content
* such as forms, settings, or detail cards that can hold focusable elements.
* Composed from a Trigger, an optional Anchor, a Portal, and Content (with an
* optional Arrow and Close). Positioning is handled by the underlying Popper.
*
* Root manages the open state and provides context to every part. Bind
* `v-model:open` to control it, or rely on the Trigger/Close for uncontrolled
* use. Non-modal by default; set `modal` to trap focus, lock scroll, and block
* outside pointer events. Reach for a Popover when you need interactive
* overlay content; use Tooltip for hover-only labels and Dialog for blocking,
* page-level tasks.
*/
export interface PopoverRootProps {
/** Uncontrolled initial open state. Ignored once `v-model:open` is bound. */
defaultOpen?: boolean;
/**
* Modal mode traps focus, locks scroll, and disables outside pointer events.
* @default false
*/
modal?: boolean;
}
</script>
<script setup lang="ts">
import { ref, shallowRef, toRef } from 'vue';
import { PopperRoot } from '../popper';
import { providePopoverContext } from './context';
import { useId } from '../../utilities/config-provider';
defineOptions({ inheritAttrs: false });
const { defaultOpen = false, modal = false } = defineProps<PopoverRootProps>();
const localOpen = ref<boolean>(defaultOpen);
const open = defineModel<boolean>('open', {
default: undefined,
get: v => v ?? localOpen.value,
set: (v) => {
localOpen.value = v;
return v;
},
});
const triggerId = useId(undefined, 'popover-trigger');
const contentId = useId(undefined, 'popover-content');
const triggerElement = shallowRef<HTMLElement>();
const hasCustomAnchor = ref(false);
providePopoverContext({
open,
// Identity passthrough via `toRef` — reactive without `computed`'s effect/cache.
modal: toRef(() => modal),
triggerId,
contentId,
triggerElement,
hasCustomAnchor,
onOpenChange: (value) => { open.value = value; },
onOpenToggle: () => { open.value = !open.value; },
});
</script>
<template>
<PopperRoot>
<slot :open="open" :close="() => open = false" />
</PopperRoot>
</template>
@@ -0,0 +1,45 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The button that toggles the popover open and closed. Wires up
* `aria-haspopup="dialog"`, `aria-expanded`, and `aria-controls`, and (unless a
* custom Anchor is present) acts as the element Content is positioned against.
*/
export interface PopoverTriggerProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { PopperAnchor } from '../popper';
import { Primitive } from '../../internal/primitive';
import { onMounted } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { usePopoverContext } from './context';
const { as = 'button' } = defineProps<PopoverTriggerProps>();
const ctx = usePopoverContext();
const { forwardRef, currentElement: triggerElement } = useForwardExpose();
onMounted(() => {
ctx.triggerElement.value = triggerElement.value;
});
</script>
<template>
<component :is="ctx.hasCustomAnchor.value ? Primitive : PopperAnchor" as="template">
<Primitive
:id="ctx.triggerId.value"
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
aria-haspopup="dialog"
:aria-expanded="ctx.open.value"
:aria-controls="ctx.contentId.value"
:data-state="ctx.open.value ? 'open' : 'closed'"
@click="ctx.onOpenToggle"
>
<slot />
</Primitive>
</component>
</template>
@@ -0,0 +1,439 @@
import {
PopoverAnchor,
PopoverArrow,
PopoverClose,
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger,
} from '../index';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { userEvent } from 'vitest/browser';
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
document.body.removeAttribute('style');
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
function mountPopover(options: {
open?: boolean;
defaultOpen?: boolean;
modal?: boolean;
onUpdateOpen?: (v: boolean) => void;
} = {}) {
const Wrapper = defineComponent({
setup() {
return () => h(
PopoverRoot,
{
open: options.open,
defaultOpen: options.defaultOpen,
modal: options.modal,
'onUpdate:open': options.onUpdateOpen,
},
{
default: () => [
h(PopoverTrigger, null, { default: () => 'Toggle' }),
h(PopoverContent, { forceMount: true }, {
default: () => [
h('p', 'Popover body'),
h(PopoverClose, null, { default: () => 'Close' }),
],
}),
],
},
);
},
});
return track(mount(Wrapper, { attachTo: document.body }));
}
describe('Popover', () => {
it('renders trigger', () => {
const wrapper = mountPopover();
const trigger = wrapper.find('button');
expect(trigger.text()).toBe('Toggle');
expect(trigger.attributes('aria-haspopup')).toBe('dialog');
expect(trigger.attributes('data-state')).toBe('closed');
});
it('opens on trigger click', async () => {
const wrapper = mountPopover();
const trigger = wrapper.find('button');
await trigger.trigger('click');
await nextTick();
expect(trigger.attributes('aria-expanded')).toBe('true');
expect(trigger.attributes('data-state')).toBe('open');
});
it('toggles with v-model:open', async () => {
const onUpdate = vi.fn();
const wrapper = mountPopover({ onUpdateOpen: onUpdate });
const trigger = wrapper.find('button');
await trigger.trigger('click');
await nextTick();
expect(onUpdate).toHaveBeenCalledWith(true);
});
it('opens with defaultOpen', async () => {
const wrapper = mountPopover({ defaultOpen: true });
await nextTick();
const trigger = wrapper.find('button');
expect(trigger.attributes('data-state')).toBe('open');
});
it('close button closes the popover', async () => {
const onUpdate = vi.fn();
const wrapper = mountPopover({ defaultOpen: true, onUpdateOpen: onUpdate });
await nextTick();
const closeBtn = wrapper.findAll('button').find(b => b.text() === 'Close');
expect(closeBtn).toBeDefined();
await closeBtn!.trigger('click');
await nextTick();
expect(onUpdate).toHaveBeenCalledWith(false);
});
it('content has role="dialog"', async () => {
const wrapper = mountPopover({ defaultOpen: true });
await nextTick();
const content = wrapper.find('[role="dialog"]');
expect(content.exists()).toBe(true);
expect(content.attributes('data-state')).toBe('open');
});
it('closes on Escape key', async () => {
const onUpdate = vi.fn();
mountPopover({ defaultOpen: true, onUpdateOpen: onUpdate });
await nextTick();
await userEvent.keyboard('{Escape}');
await nextTick();
expect(onUpdate).toHaveBeenCalledWith(false);
});
it('supports controlled open', async () => {
const Wrapper = defineComponent({
setup() {
const open = ref(false);
return () => h(
PopoverRoot,
{ open: open.value, 'onUpdate:open': (v: boolean) => { open.value = v; } },
{
default: () => [
h(PopoverTrigger, null, { default: () => 'Toggle' }),
h(PopoverContent, { forceMount: true }, { default: () => 'Body' }),
],
},
);
},
});
const wrapper = track(mount(Wrapper, { attachTo: document.body }));
await nextTick();
expect(wrapper.find('button').attributes('data-state')).toBe('closed');
await wrapper.find('button').trigger('click');
await nextTick();
expect(wrapper.find('button').attributes('data-state')).toBe('open');
});
it('trigger has aria-controls pointing to content id', async () => {
const wrapper = mountPopover({ defaultOpen: true });
await nextTick();
const trigger = wrapper.find('button');
const contentId = wrapper.find('[role="dialog"]').attributes('id');
expect(trigger.attributes('aria-controls')).toBe(contentId);
});
});
describe('Popover content ref forwarding', () => {
it('exposes the content element via a template ref (non-modal)', async () => {
const contentRef = ref<{ $el: HTMLElement } | null>(null);
const Wrapper = defineComponent({
setup() {
return () => h(
PopoverRoot,
{ defaultOpen: true },
{
default: () => [
h(PopoverTrigger, null, { default: () => 'Toggle' }),
h(PopoverContent, { ref: contentRef, forceMount: true }, { default: () => 'Body' }),
],
},
);
},
});
const wrapper = track(mount(Wrapper, { attachTo: document.body }));
await nextTick();
const dialog = wrapper.find('[role="dialog"]').element;
expect(contentRef.value?.$el).toBe(dialog);
});
it('exposes the content element via a template ref (modal)', async () => {
const contentRef = ref<{ $el: HTMLElement } | null>(null);
const Wrapper = defineComponent({
setup() {
return () => h(
PopoverRoot,
{ defaultOpen: true, modal: true },
{
default: () => [
h(PopoverTrigger, null, { default: () => 'Toggle' }),
h(PopoverContent, { ref: contentRef, forceMount: true }, { default: () => 'Body' }),
],
},
);
},
});
const wrapper = track(mount(Wrapper, { attachTo: document.body }));
await nextTick();
const dialog = wrapper.find('[role="dialog"]').element;
expect(contentRef.value?.$el).toBe(dialog);
});
});
describe('Popover focus guards', () => {
it('inserts edge focus guards while content is open', async () => {
expect(document.querySelectorAll('[data-focus-guard]').length).toBe(0);
const wrapper = mountPopover({ defaultOpen: true });
await nextTick();
expect(document.querySelectorAll('[data-focus-guard]').length).toBe(2);
wrapper.unmount();
await nextTick();
expect(document.querySelectorAll('[data-focus-guard]').length).toBe(0);
});
});
describe('Popover modal hides background content', () => {
it('aria-hides sibling trees when modal content is open', async () => {
const sibling = document.createElement('div');
sibling.id = 'outside-sibling';
sibling.textContent = 'background';
document.body.appendChild(sibling);
const Wrapper = defineComponent({
setup() {
return () => h(
PopoverRoot,
{ defaultOpen: true, modal: true },
{
default: () => [
h(PopoverTrigger, null, { default: () => 'Toggle' }),
h(PopoverContent, { forceMount: true }, { default: () => 'Body' }),
],
},
);
},
});
const wrapper = track(mount(Wrapper, { attachTo: document.body }));
await nextTick();
await nextTick();
expect(sibling.getAttribute('aria-hidden')).toBe('true');
wrapper.unmount();
await nextTick();
expect(sibling.getAttribute('aria-hidden')).not.toBe('true');
sibling.remove();
});
it('does NOT aria-hide siblings in non-modal mode', async () => {
const sibling = document.createElement('div');
sibling.id = 'outside-sibling-nonmodal';
document.body.appendChild(sibling);
const Wrapper = defineComponent({
setup() {
return () => h(
PopoverRoot,
{ defaultOpen: true, modal: false },
{
default: () => [
h(PopoverTrigger, null, { default: () => 'Toggle' }),
h(PopoverContent, { forceMount: true }, { default: () => 'Body' }),
],
},
);
},
});
const wrapper = track(mount(Wrapper, { attachTo: document.body }));
await nextTick();
await nextTick();
expect(sibling.getAttribute('aria-hidden')).not.toBe('true');
wrapper.unmount();
sibling.remove();
});
});
describe('PopoverAnchor', () => {
it('exposes its element via a template ref', async () => {
const anchorRef = ref<{ $el: HTMLElement } | null>(null);
const Wrapper = defineComponent({
setup() {
return () => h(
PopoverRoot,
null,
{
default: () => [
h(PopoverAnchor, { ref: anchorRef }, { default: () => h('span', 'anchor') }),
h(PopoverTrigger, null, { default: () => 'Toggle' }),
],
},
);
},
});
track(mount(Wrapper, { attachTo: document.body }));
await nextTick();
expect(anchorRef.value?.$el).toBeInstanceOf(HTMLElement);
expect(anchorRef.value?.$el.textContent).toBe('anchor');
});
});
describe('PopoverArrow', () => {
function mountArrow(arrowProps: Record<string, unknown> = {}) {
const Wrapper = defineComponent({
setup() {
return () => h(
PopoverRoot,
{ defaultOpen: true },
{
default: () => [
h(PopoverTrigger, null, { default: () => 'Toggle' }),
h(PopoverContent, { forceMount: true }, {
default: () => h(PopoverArrow, arrowProps),
}),
],
},
);
},
});
return track(mount(Wrapper, { attachTo: document.body }));
}
it('renders a default svg triangle out of the box', async () => {
const wrapper = mountArrow();
await nextTick();
const svg = wrapper.find('[role="dialog"] svg');
expect(svg.exists()).toBe(true);
expect(svg.find('path').exists()).toBe(true);
expect(svg.find('path').attributes('d')).toBe('M0 0L6 6L12 0');
});
it('forwards the rounded prop to the underlying arrow path', async () => {
const wrapper = mountArrow({ rounded: true });
await nextTick();
const path = wrapper.find('[role="dialog"] svg path');
expect(path.exists()).toBe(true);
expect(path.attributes('d')).not.toBe('M0 0L6 6L12 0');
});
it('forwards width/height attributes', async () => {
const wrapper = mountArrow({ width: 20, height: 8 });
await nextTick();
const svg = wrapper.find('[role="dialog"] svg');
expect(svg.attributes('width')).toBe('20');
expect(svg.attributes('height')).toBe('8');
});
it('exposes the arrow element via a template ref', async () => {
const arrowRef = ref<{ $el: HTMLElement } | null>(null);
const Wrapper = defineComponent({
setup() {
return () => h(
PopoverRoot,
{ defaultOpen: true },
{
default: () => [
h(PopoverTrigger, null, { default: () => 'Toggle' }),
h(PopoverContent, { forceMount: true }, {
default: () => h(PopoverArrow, { ref: arrowRef }),
}),
],
},
);
},
});
track(mount(Wrapper, { attachTo: document.body }));
await nextTick();
expect(arrowRef.value?.$el).toBeTruthy();
});
});
describe('PopoverPortal', () => {
it('teleports content out into a custom container', async () => {
const target = document.createElement('div');
target.id = 'portal-target';
document.body.appendChild(target);
const Wrapper = defineComponent({
setup() {
return () => h(
PopoverRoot,
{ defaultOpen: true },
{
default: () => [
h(PopoverTrigger, null, { default: () => 'Toggle' }),
h(PopoverPortal, { to: '#portal-target' }, {
default: () => h(PopoverContent, { forceMount: true }, { default: () => 'Body' }),
}),
],
},
);
},
});
const wrapper = track(mount(Wrapper, { attachTo: document.body }));
await nextTick();
expect(target.querySelector('[role="dialog"]')).not.toBeNull();
wrapper.unmount();
target.remove();
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -0,0 +1,18 @@
import type { ComputedRef, Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
export interface PopoverContext {
open: Ref<boolean>;
modal: Ref<boolean>;
triggerId: ComputedRef<string>;
contentId: ComputedRef<string>;
triggerElement: Ref<HTMLElement | undefined>;
hasCustomAnchor: Ref<boolean>;
onOpenChange: (value: boolean) => void;
onOpenToggle: () => void;
}
const ctx = useContextFactory<PopoverContext>('PopoverContext');
export const providePopoverContext = ctx.provide;
export const usePopoverContext = ctx.inject;
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { reactive } from 'vue';
import {
PopoverArrow,
PopoverClose,
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger,
} from '@robonen/primitives';
const dimensions = reactive({
width: 320,
height: 180,
maxWidth: 480,
maxHeight: 280,
});
const fields = [
{ key: 'width', label: 'Width' },
{ key: 'height', label: 'Height' },
{ key: 'maxWidth', label: 'Max. width' },
{ key: 'maxHeight', label: 'Max. height' },
] as const;
</script>
<template>
<div class="flex flex-col items-start gap-3 text-fg">
<div
class="rounded-md border border-dashed border-border bg-bg-subtle text-xs text-fg-muted"
:style="{ width: `${dimensions.width}px`, height: `${dimensions.height}px` }"
>
<span class="block px-2 py-1">{{ dimensions.width }} × {{ dimensions.height }}</span>
</div>
<PopoverRoot>
<PopoverTrigger
class="inline-flex items-center gap-2 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 data-[state=open]:bg-bg-subtle"
>
<svg
class="size-4 text-fg-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"
>
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</svg>
Dimensions
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
:side-offset="8"
align="start"
:collision-padding="8"
class="demo-card z-50 w-64 p-4 text-fg shadow-lg focus:outline-none data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out"
>
<div class="flex items-start justify-between">
<div>
<h3 class="text-sm font-semibold">Dimensions</h3>
<p class="mt-0.5 text-xs text-fg-muted">Set the box size in pixels.</p>
</div>
<PopoverClose
aria-label="Close"
class="-mr-1 -mt-1 inline-flex size-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-bg-subtle hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<svg
class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</PopoverClose>
</div>
<div class="mt-3 flex flex-col gap-2">
<label
v-for="field in fields"
:key="field.key"
class="grid grid-cols-[1fr_auto] items-center gap-3 text-sm"
>
<span class="text-fg-muted">{{ field.label }}</span>
<input
v-model.number="dimensions[field.key]"
type="number"
class="h-7 w-20 rounded-md border border-border bg-bg px-2 text-right tabular-nums text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
</label>
</div>
<PopoverArrow :width="12" :height="6">
<svg width="12" height="6" viewBox="0 0 12 6" class="block" aria-hidden="true">
<path d="M0 0 L6 6 L12 0 Z" class="fill-bg-elevated stroke-border" stroke-width="1" />
</svg>
</PopoverArrow>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</div>
</template>
@@ -0,0 +1,18 @@
export { default as PopoverRoot } from './PopoverRoot.vue';
export { default as PopoverTrigger } from './PopoverTrigger.vue';
export { default as PopoverAnchor } from './PopoverAnchor.vue';
export { default as PopoverContent } from './PopoverContent.vue';
export { default as PopoverPortal } from './PopoverPortal.vue';
export { default as PopoverArrow } from './PopoverArrow.vue';
export { default as PopoverClose } from './PopoverClose.vue';
export { usePopoverContext } from './context';
export type { PopoverContext } from './context';
export type { PopoverRootProps } from './PopoverRoot.vue';
export type { PopoverTriggerProps } from './PopoverTrigger.vue';
export type { PopoverAnchorProps } from './PopoverAnchor.vue';
export type { PopoverContentEmits, PopoverContentProps } from './PopoverContent.vue';
export type { PopoverPortalProps } from './PopoverPortal.vue';
export type { PopoverArrowProps } from './PopoverArrow.vue';
export type { PopoverCloseProps } from './PopoverClose.vue';
@@ -0,0 +1,38 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
import type { ReferenceElement } from '@floating-ui/vue';
/**
* Marks the element that `PopperContent` positions itself against. Renders its
* child and registers it with the `PopperRoot` as the positioning reference;
* pass `reference` to anchor to a virtual or external element instead of the
* rendered DOM node. Optional — when omitted, the content falls back to its own
* `reference` prop or the nearest registered anchor.
*/
export interface PopperAnchorProps extends PrimitiveProps {
/** Custom reference element for positioning. If not provided, uses the rendered element. */
reference?: ReferenceElement;
}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
import { usePopperRootContext } from './context';
import { watchPostEffect } from 'vue';
const { as, reference } = defineProps<PopperAnchorProps>();
const { forwardRef, currentElement } = useForwardExpose();
const rootContext = usePopperRootContext();
watchPostEffect(() => {
rootContext.onAnchorChange(reference ?? currentElement.value);
});
</script>
<template>
<Primitive :ref="forwardRef" :as="as">
<slot />
</Primitive>
</template>
@@ -0,0 +1,113 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
import type { Side } from './utils';
const OPPOSITE_SIDE: Record<Side, Side> = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right',
};
// Hoisted to module scope — one allocation per module load instead of one per
// render. Values are primitive strings, so objects are frozen-in-practice.
const TRANSFORM_ORIGIN: Record<Side, string> = {
top: '',
right: '0 0',
bottom: 'center 0',
left: '100% 0',
};
const TRANSFORM: Record<Side, string> = {
top: 'translateY(100%)',
right: 'translateY(50%) rotate(90deg) translateX(-50%)',
bottom: 'rotate(180deg)',
left: 'translateY(50%) rotate(-90deg) translateX(50%)',
};
// Default arrow geometry, sized against the 12×6 viewBox so the SVG scales to
// any width/height via preserveAspectRatio="none".
const ARROW_PATH = 'M0 0L6 6L12 0';
const ARROW_PATH_ROUNDED = 'M0 0L4.58579 4.58579C5.36683 5.36683 6.63316 5.36684 7.41421 4.58579L12 0';
/**
* An optional arrow/pointer rendered inside `PopperContent` that points back at
* the anchor. It reads the resolved side and arrow offset from the content
* context to position and rotate itself against the correct edge, and hides
* automatically when it cannot be centered. By default it renders a real `<svg>`
* triangle (a `rounded` variant is available); supply your own SVG/element via
* the default slot, or switch the rendered element with `as`. Must be a child of
* `PopperContent`.
*/
export interface PopperArrowProps extends PrimitiveProps {
/** Arrow width in pixels. @default 10 */
width?: number;
/** Arrow height in pixels. @default 5 */
height?: number;
/** Render the rounded variant of the default arrow path. Ignored when a custom default slot or `as="template"` is used. @default false */
rounded?: boolean;
}
</script>
<script setup lang="ts">
import type { CSSProperties, ComponentPublicInstance } from 'vue';
import { Primitive } from '../../internal/primitive';
import { computed } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { usePopperContentContext } from './context';
const { as = 'svg', width = 10, height = 5, rounded = false } = defineProps<PopperArrowProps>();
const { forwardRef } = useForwardExpose();
const contentContext = usePopperContentContext();
const baseSide = computed(() => OPPOSITE_SIDE[contentContext.placedSide.value]);
// When the consumer merges the arrow onto their own element (`as="template"`)
// the intrinsic SVG attributes would be invalid, so they are only applied to a
// real rendered element. Mirrors the slot-merge escape hatch of `Primitive`.
const isTemplate = computed(() => as === 'template');
const arrowPath = computed(() => (rounded ? ARROW_PATH_ROUNDED : ARROW_PATH));
// Memoize the wrapper style. PopperArrow re-renders on every scroll/resize/
// layout-shift frame while open; binding an inline object literal would
// re-allocate it and re-read every ref + re-run both table lookups each frame.
// A computed caches the object and recomputes only when its tracked deps change.
const wrapperStyle = computed(() => {
const placedSide = contentContext.placedSide.value;
const arrowX = contentContext.arrowX.value;
const arrowY = contentContext.arrowY.value;
return {
position: 'absolute',
left: arrowX ? `${arrowX}px` : undefined,
top: arrowY ? `${arrowY}px` : undefined,
[baseSide.value]: 0,
transformOrigin: TRANSFORM_ORIGIN[placedSide],
transform: TRANSFORM[placedSide],
visibility: contentContext.shouldHideArrow.value ? 'hidden' : undefined,
} as CSSProperties;
});
</script>
<template>
<span
:ref="(el: Element | ComponentPublicInstance | null) => {
contentContext.onArrowChange((el as HTMLElement) ?? undefined);
return undefined;
}"
:style="wrapperStyle"
>
<Primitive
:ref="forwardRef"
:as="as"
:style="{ display: 'block' }"
:width="width"
:height="height"
:viewBox="isTemplate ? undefined : '0 0 12 6'"
:preserveAspectRatio="isTemplate ? undefined : 'none'"
>
<slot>
<path :d="arrowPath" />
</slot>
</Primitive>
</span>
</template>
@@ -0,0 +1,284 @@
<script lang="ts">
import type { Align, Side } from './utils';
import type { Middleware, Placement, ReferenceElement } from '@floating-ui/vue';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The floating element positioned against the anchor. This is the workhorse of
* the Popper building block: it runs Floating UI (offset, flip, shift, size,
* arrow, hide) to place itself on the chosen side/alignment, keeps the position
* updated on scroll/resize/layout shift, avoids collisions with the viewport or
* a custom boundary, and exposes `--popper-*` CSS variables plus `data-side` /
* `data-align` attributes for styling and transform-origin. Use the Popper parts
* to build any anchored overlay — tooltips, popovers, menus, selects — where
* content must follow a trigger and stay on-screen. Place it inside a
* `PopperRoot` (so it can read the registered anchor) and emit `placed` once the
* first position settles.
*/
export interface PopperContentProps extends PrimitiveProps {
/** Preferred side of the anchor. @default 'bottom' */
side?: Side;
/** Distance in pixels from the anchor. @default 0 */
sideOffset?: number;
/** Flip to the opposite side on collision. @default true */
sideFlip?: boolean;
/** Preferred alignment against the anchor. @default 'center' */
align?: Align;
/** Offset in pixels from the alignment edge. @default 0 */
alignOffset?: number;
/** Flip alignment on collision. @default true */
alignFlip?: boolean;
/** Reposition to prevent boundary overflow. @default true */
avoidCollisions?: boolean;
/** Collision boundary element(s). @default [] */
collisionBoundary?: Array<Element | null> | Element | null;
/** Distance from boundary for collision detection. @default 0 */
collisionPadding?: number | Partial<Record<Side, number>>;
/** Padding between arrow and content edges. @default 0 */
arrowPadding?: number;
/** Hide arrow when it can't be centered. @default true */
hideShiftedArrow?: boolean;
/** Sticky behavior on the align axis. @default 'partial' */
sticky?: 'always' | 'partial';
/** Hide when anchor is fully occluded. @default false */
hideWhenDetached?: boolean;
/** CSS position strategy. @default 'fixed' */
positionStrategy?: 'absolute' | 'fixed';
/** Position update strategy. @default 'optimized' */
updatePositionStrategy?: 'always' | 'optimized';
/** Disable layout-shift-based position update. @default false */
disableUpdateOnLayoutShift?: boolean;
/** Force content to stay within the viewport. @default false */
prioritizePosition?: boolean;
/** Custom reference element, overrides the anchor. */
reference?: ReferenceElement;
}
export interface PopperContentEmits {
placed: [];
}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import {
autoUpdate,
flip,
arrow as floatingUIArrow,
hide,
limitShift,
offset,
shift,
size,
useFloating,
} from '@floating-ui/vue';
import { computed, ref, shallowRef, useTemplateRef, watchEffect, watchPostEffect } from 'vue';
import { getSideAndAlignFromPlacement, isNotNull, transformOrigin } from './utils';
import { providePopperContentContext, usePopperRootContext } from './context';
import { useForwardExpose, useResizeObserver } from '@robonen/vue';
defineOptions({ inheritAttrs: false });
const {
side = 'bottom',
sideOffset = 0,
sideFlip = true,
align = 'center',
alignOffset = 0,
alignFlip = true,
avoidCollisions = true,
collisionBoundary = [],
collisionPadding: collisionPaddingProp = 0,
arrowPadding = 0,
hideShiftedArrow = true,
sticky = 'partial',
hideWhenDetached = false,
positionStrategy = 'fixed',
updatePositionStrategy = 'optimized',
disableUpdateOnLayoutShift = false,
prioritizePosition = false,
reference: referenceProp,
as,
} = defineProps<PopperContentProps>();
const emit = defineEmits<PopperContentEmits>();
const rootContext = usePopperRootContext();
const { forwardRef, currentElement: contentElement } = useForwardExpose();
const floatingRef = useTemplateRef<HTMLElement>('floatingRef');
const arrow = shallowRef<HTMLElement>();
// Arrow size tracking via ResizeObserver (replaces useSize). The observer
// re-targets when the arrow element changes and tears down on scope dispose.
const arrowWidth = ref(0);
const arrowHeight = ref(0);
useResizeObserver(arrow, ([entry]) => {
if (!entry) return;
const borderBox = entry.borderBoxSize[0];
if (borderBox) {
arrowWidth.value = borderBox.inlineSize;
arrowHeight.value = borderBox.blockSize;
}
else {
const rect = (entry.target as HTMLElement).getBoundingClientRect();
arrowWidth.value = rect.width;
arrowHeight.value = rect.height;
}
});
const desiredPlacement = computed<Placement>(
() => (side + (align !== 'center' ? `-${align}` : '')) as Placement,
);
const collisionPadding = computed(() => {
return typeof collisionPaddingProp === 'number'
? collisionPaddingProp
: { top: 0, right: 0, bottom: 0, left: 0, ...collisionPaddingProp };
});
const boundary = computed(() => {
return Array.isArray(collisionBoundary)
? collisionBoundary
: [collisionBoundary];
});
const detectOverflowOptions = computed(() => ({
padding: collisionPadding.value,
boundary: boundary.value.filter(isNotNull),
altBoundary: boundary.value.length > 0,
}));
const flipOptions = computed(() => ({
mainAxis: sideFlip,
crossAxis: alignFlip,
}));
const computedMiddleware = computed<Middleware[]>(() => [
offset({
mainAxis: sideOffset + arrowHeight.value,
alignmentAxis: alignOffset,
}),
prioritizePosition
&& avoidCollisions
&& flip({ ...detectOverflowOptions.value, ...flipOptions.value }),
avoidCollisions
&& shift({
mainAxis: true,
crossAxis: !!prioritizePosition,
limiter: sticky === 'partial' ? limitShift() : undefined,
...detectOverflowOptions.value,
}),
!prioritizePosition
&& avoidCollisions
&& flip({ ...detectOverflowOptions.value, ...flipOptions.value }),
size({
...detectOverflowOptions.value,
apply: ({ elements, rects, availableWidth, availableHeight }) => {
const { width: anchorWidth, height: anchorHeight } = rects.reference;
const contentStyle = elements.floating.style;
contentStyle.setProperty('--popper-available-width', `${availableWidth}px`);
contentStyle.setProperty('--popper-available-height', `${availableHeight}px`);
contentStyle.setProperty('--popper-anchor-width', `${anchorWidth}px`);
contentStyle.setProperty('--popper-anchor-height', `${anchorHeight}px`);
},
}),
arrow.value && floatingUIArrow({ element: arrow.value, padding: arrowPadding }),
transformOrigin({ arrowWidth: arrowWidth.value, arrowHeight: arrowHeight.value }),
hideWhenDetached && hide({ strategy: 'referenceHidden', ...detectOverflowOptions.value }),
] as Middleware[]);
const reference = computed(() => referenceProp ?? rootContext.anchor.value);
const { floatingStyles, placement, isPositioned, middlewareData } = useFloating(
reference,
floatingRef,
{
strategy: positionStrategy,
placement: desiredPlacement,
whileElementsMounted: (...args) => {
return autoUpdate(...args, {
layoutShift: !disableUpdateOnLayoutShift,
animationFrame: updatePositionStrategy === 'always',
});
},
middleware: computedMiddleware,
},
);
const placedSide = computed(() => getSideAndAlignFromPlacement(placement.value)[0]);
const placedAlign = computed(() => getSideAndAlignFromPlacement(placement.value)[1]);
watchPostEffect(() => {
if (isPositioned.value) emit('placed');
});
const shouldHideArrow = computed(() => {
const cannotCenterArrow = middlewareData.value.arrow?.centerOffset !== 0;
return hideShiftedArrow && cannotCenterArrow;
});
const contentZIndex = shallowRef('');
watchEffect(() => {
if (contentElement.value) {
contentZIndex.value = globalThis.getComputedStyle(contentElement.value).zIndex;
}
});
const arrowX = computed(() => middlewareData.value.arrow?.x ?? 0);
const arrowY = computed(() => middlewareData.value.arrow?.y ?? 0);
// Memoize the floating wrapper style. The template re-renders on every position
// update frame while open; a computed isolates the per-frame allocation to one
// tracked site and avoids re-running the [x,y].join(' ') and conditional reads
// when the render is triggered by an unrelated dep.
const wrapperStyle = computed(() => {
const md = middlewareData.value;
const referenceHidden = md.hide?.referenceHidden;
return {
...floatingStyles.value,
transform: isPositioned.value ? floatingStyles.value.transform : 'translate(0, -200%)',
minWidth: 'max-content',
zIndex: contentZIndex.value,
'--popper-transform-origin': [
md.transformOrigin?.x,
md.transformOrigin?.y,
].join(' '),
// Always set both keys so V8 keeps a single hidden class for the style
// object across renders, instead of allocating a new shape when the
// conditional spread kicks in.
visibility: referenceHidden ? ('hidden' as const) : undefined,
pointerEvents: referenceHidden ? ('none' as const) : undefined,
};
});
providePopperContentContext({
placedSide,
onArrowChange: (element: HTMLElement | undefined) => { arrow.value = element; },
arrowX,
arrowY,
shouldHideArrow,
});
</script>
<template>
<div
ref="floatingRef"
data-popper-content-wrapper=""
:style="wrapperStyle"
>
<Primitive
:ref="forwardRef"
v-bind="$attrs"
:as="as"
:data-side="placedSide"
:data-align="placedAlign"
:style="{
animation: isPositioned ? undefined : 'none',
}"
>
<slot />
</Primitive>
</div>
</template>
@@ -0,0 +1,34 @@
<script lang="ts">
/**
* The context provider for a popper. It coordinates positioning between
* `PopperAnchor` (the reference element), `PopperContent` (the floating
* element placed against it), and `PopperArrow`, sharing the registered anchor
* via context. It renders only its slot and adds no DOM of its own, so wrap
* the anchor and content in it to build tooltips, popovers, dropdown menus, and
* other floating UI.
*/
// PopperRoot is a context-only provider that renders just its slot — it takes no props.
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface PopperRootProps {}
</script>
<script setup lang="ts">
import type { ReferenceElement } from '@floating-ui/vue';
import { providePopperRootContext } from './context';
import { shallowRef } from 'vue';
defineOptions({ inheritAttrs: false });
defineProps<PopperRootProps>();
const anchor = shallowRef<ReferenceElement>();
providePopperRootContext({
anchor,
onAnchorChange: (element: ReferenceElement | undefined) => { anchor.value = element; },
});
</script>
<template>
<slot />
</template>
@@ -0,0 +1,220 @@
import type { Measurable } from '../index';
import {
PopperAnchor,
PopperArrow,
PopperContent,
PopperRoot,
} from '../index';
import { afterEach, describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
describe('Popper', () => {
it('renders root with anchor and content', async () => {
const Wrapper = defineComponent({
setup() {
return () => h(PopperRoot, null, {
default: () => [
h(PopperAnchor, { as: 'button' }, { default: () => 'Anchor' }),
h(PopperContent, { as: 'div' }, { default: () => 'Content' }),
],
});
},
});
const wrapper = track(mount(Wrapper, { attachTo: document.body }));
await nextTick();
expect(wrapper.find('button').text()).toBe('Anchor');
expect(wrapper.find('[data-popper-content-wrapper]').exists()).toBe(true);
});
it('positions content with default placement (bottom)', async () => {
const Wrapper = defineComponent({
setup() {
return () => h(PopperRoot, null, {
default: () => [
h(PopperAnchor, { as: 'button' }, { default: () => 'Anchor' }),
h(PopperContent, null, { default: () => 'Content' }),
],
});
},
});
const wrapper = track(mount(Wrapper, { attachTo: document.body }));
await nextTick();
await nextTick();
const content = wrapper.find('[data-side]');
expect(content.exists()).toBe(true);
});
it('exposes data-side and data-align attributes', async () => {
const Wrapper = defineComponent({
setup() {
return () => h(PopperRoot, null, {
default: () => [
h(PopperAnchor, { as: 'button' }, { default: () => 'Anchor' }),
h(PopperContent, { side: 'top', align: 'start' }, { default: () => 'Content' }),
],
});
},
});
const wrapper = track(mount(Wrapper, { attachTo: document.body }));
await nextTick();
await nextTick();
const content = wrapper.find('[data-side]');
expect(content.exists()).toBe(true);
});
it('passes custom reference to anchor', async () => {
const customRef = {
getBoundingClientRect: () => ({
x: 100, y: 100, width: 50, height: 50,
top: 100, right: 150, bottom: 150, left: 100,
toJSON: () => {},
}),
};
const Wrapper = defineComponent({
setup() {
return () => h(PopperRoot, null, {
default: () => [
h(PopperAnchor, { reference: customRef }, { default: () => 'Anchor' }),
h(PopperContent, null, { default: () => 'Content' }),
],
});
},
});
const wrapper = track(mount(Wrapper, { attachTo: document.body }));
await nextTick();
expect(wrapper.find('[data-popper-content-wrapper]').exists()).toBe(true);
});
it('accepts a Measurable-typed virtual reference on content', async () => {
const virtual: Measurable = {
getBoundingClientRect: () => ({
x: 0, y: 0, width: 10, height: 10,
top: 0, right: 10, bottom: 10, left: 0,
toJSON: () => {},
} as DOMRect),
};
const Wrapper = defineComponent({
setup() {
return () => h(PopperRoot, null, {
default: () => [
h(PopperContent, { reference: virtual }, { default: () => 'Content' }),
],
});
},
});
const wrapper = track(mount(Wrapper, { attachTo: document.body }));
await nextTick();
expect(wrapper.find('[data-popper-content-wrapper]').exists()).toBe(true);
});
});
describe('PopperArrow', () => {
function mountWithArrow(arrowProps: Record<string, unknown> = {}, arrowSlot?: () => unknown) {
const Wrapper = defineComponent({
setup() {
return () => h(PopperRoot, null, {
default: () => [
h(PopperAnchor, { as: 'button' }, { default: () => 'Anchor' }),
h(PopperContent, { as: 'div' }, {
default: () => h(PopperArrow, arrowProps, arrowSlot ? { default: arrowSlot } : undefined),
}),
],
});
},
});
return track(mount(Wrapper, { attachTo: document.body }));
}
it('renders a real svg arrow with default path and viewBox by default', async () => {
const wrapper = mountWithArrow();
await nextTick();
const svg = wrapper.find('svg');
expect(svg.exists()).toBe(true);
// SVG attributes are case-sensitive, so read them off the element directly.
expect(svg.element.getAttribute('viewBox')).toBe('0 0 12 6');
expect(svg.element.getAttribute('preserveAspectRatio')).toBe('none');
const path = svg.find('path');
expect(path.exists()).toBe(true);
expect(path.attributes('d')).toBe('M0 0L6 6L12 0');
});
it('applies width and height defaults (10 × 5) to the svg', async () => {
const wrapper = mountWithArrow();
await nextTick();
const svg = wrapper.find('svg');
expect(svg.attributes('width')).toBe('10');
expect(svg.attributes('height')).toBe('5');
});
it('honors explicit width and height', async () => {
const wrapper = mountWithArrow({ width: 24, height: 12 });
await nextTick();
const svg = wrapper.find('svg');
expect(svg.attributes('width')).toBe('24');
expect(svg.attributes('height')).toBe('12');
});
it('swaps to the rounded path when rounded is set', async () => {
const wrapper = mountWithArrow({ rounded: true });
await nextTick();
const path = wrapper.find('svg path');
expect(path.exists()).toBe(true);
expect(path.attributes('d')).toBe(
'M0 0L4.58579 4.58579C5.36683 5.36683 6.63316 5.36684 7.41421 4.58579L12 0',
);
});
it('lets the default slot override the built-in path', async () => {
const wrapper = mountWithArrow({}, () => h('path', { d: 'M0 0L1 1' }));
await nextTick();
const paths = wrapper.findAll('svg path');
expect(paths).toHaveLength(1);
expect(paths[0]!.attributes('d')).toBe('M0 0L1 1');
});
it('does not emit svg-only attributes when merged via as="template"', async () => {
const wrapper = mountWithArrow(
{ as: 'template' },
() => h('i', { 'data-custom-arrow': '' }),
);
await nextTick();
expect(wrapper.find('svg').exists()).toBe(false);
const custom = wrapper.find('[data-custom-arrow]');
expect(custom.exists()).toBe(true);
expect(custom.attributes('viewbox')).toBeUndefined();
expect(custom.attributes('preserveaspectratio')).toBeUndefined();
});
});
@@ -0,0 +1,35 @@
import type { Ref } from 'vue';
import type { ReferenceElement } from '@floating-ui/vue';
import type { Side } from './utils';
import { useContextFactory } from '@robonen/vue';
/**
* Minimal shape a custom/virtual reference must satisfy to be positioned
* against. Pass any object implementing this to `PopperAnchor`/`PopperContent`'s
* `reference` prop to anchor against a point, range, or external element rather
* than a rendered DOM node.
*/
export interface Measurable {
getBoundingClientRect: () => DOMRect;
}
export interface PopperRootContext {
anchor: Ref<ReferenceElement | undefined>;
onAnchorChange: (element: ReferenceElement | undefined) => void;
}
export interface PopperContentContext {
placedSide: Ref<Side>;
onArrowChange: (arrow: HTMLElement | undefined) => void;
arrowX: Ref<number>;
arrowY: Ref<number>;
shouldHideArrow: Ref<boolean>;
}
const rootCtx = useContextFactory<PopperRootContext>('PopperRootContext');
export const providePopperRootContext = rootCtx.provide;
export const usePopperRootContext = rootCtx.inject;
const contentCtx = useContextFactory<PopperContentContext>('PopperContentContext');
export const providePopperContentContext = contentCtx.provide;
export const usePopperContentContext = contentCtx.inject;
@@ -0,0 +1,13 @@
export { default as PopperRoot } from './PopperRoot.vue';
export { default as PopperAnchor } from './PopperAnchor.vue';
export { default as PopperContent } from './PopperContent.vue';
export { default as PopperArrow } from './PopperArrow.vue';
export { usePopperRootContext, usePopperContentContext } from './context';
export type { Measurable, PopperRootContext, PopperContentContext } from './context';
export type { PopperRootProps } from './PopperRoot.vue';
export type { PopperAnchorProps } from './PopperAnchor.vue';
export type { PopperContentEmits, PopperContentProps } from './PopperContent.vue';
export type { PopperArrowProps } from './PopperArrow.vue';
export type { Align, Side } from './utils';
@@ -0,0 +1,59 @@
import type { Middleware, Placement } from '@floating-ui/vue';
export type Side = 'bottom' | 'left' | 'right' | 'top';
export type Align = 'center' | 'end' | 'start';
export function isNotNull<T>(value: T | null): value is T {
return value !== null;
}
export function getSideAndAlignFromPlacement(placement: Placement) {
const [side, align = 'center'] = placement.split('-');
return [side as Side, align as Align] as const;
}
export function transformOrigin(options: {
arrowWidth: number;
arrowHeight: number;
}): Middleware {
return {
name: 'transformOrigin',
options,
fn(data) {
const { placement, rects, middlewareData } = data;
const cannotCenterArrow = middlewareData.arrow?.centerOffset !== 0;
const isArrowHidden = cannotCenterArrow;
const arrowWidth = isArrowHidden ? 0 : options.arrowWidth;
const arrowHeight = isArrowHidden ? 0 : options.arrowHeight;
const [placedSide, placedAlign] = getSideAndAlignFromPlacement(placement);
const noArrowAlign = { start: '0%', center: '50%', end: '100%' }[placedAlign];
const arrowXCenter = (middlewareData.arrow?.x ?? 0) + arrowWidth / 2;
const arrowYCenter = (middlewareData.arrow?.y ?? 0) + arrowHeight / 2;
let x = '';
let y = '';
if (placedSide === 'bottom') {
x = isArrowHidden ? noArrowAlign : `${arrowXCenter}px`;
y = `${-arrowHeight}px`;
}
else if (placedSide === 'top') {
x = isArrowHidden ? noArrowAlign : `${arrowXCenter}px`;
y = `${rects.floating.height + arrowHeight}px`;
}
else if (placedSide === 'right') {
x = `${-arrowHeight}px`;
y = isArrowHidden ? noArrowAlign : `${arrowYCenter}px`;
}
else if (placedSide === 'left') {
x = `${rects.floating.width + arrowHeight}px`;
y = isArrowHidden ? noArrowAlign : `${arrowYCenter}px`;
}
return { data: { x, y } };
},
};
}
@@ -0,0 +1,22 @@
<script lang="ts">
import type { PopperArrowProps } from '../popper';
/**
* An optional pointer rendered inside Content that visually connects the
* tooltip to its Trigger. Place it as a child of `TooltipContent`; it tracks
* the resolved side and alignment automatically.
*/
export interface TooltipArrowProps extends PopperArrowProps {}
</script>
<script setup lang="ts">
import { PopperArrow } from '../popper';
const { width = 10, height = 5 } = defineProps<TooltipArrowProps>();
</script>
<template>
<PopperArrow :width="width" :height="height">
<slot />
</PopperArrow>
</template>
@@ -0,0 +1,49 @@
<script lang="ts">
import type { TooltipContentImplEmits, TooltipContentImplProps } from './TooltipContentImpl.vue';
/**
* The floating panel that holds the tooltip's label, positioned relative to the
* Trigger. It mounts only while the tooltip is open (driven by `Presence`); set
* `forceMount` to keep it mounted for CSS exit animations. Side, alignment, and
* collision behavior are forwarded to the underlying Popper content.
*/
export interface TooltipContentProps extends TooltipContentImplProps {
/** Keep mounted for CSS exit animations. */
forceMount?: boolean;
}
export type TooltipContentEmits = TooltipContentImplEmits;
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { Presence } from '../../utilities/presence';
import TooltipContentHoverable from './TooltipContentHoverable.vue';
import TooltipContentImpl from './TooltipContentImpl.vue';
import { useTooltipContext } from './context';
const { forceMount = false, ...contentProps } = defineProps<TooltipContentProps>();
const emit = defineEmits<TooltipContentEmits>();
const ctx = useTooltipContext();
// When hoverable content is enabled (the default), wrap the impl in the
// grace-area variant so the pointer can travel onto the content without it
// closing; otherwise mount the impl directly.
const contentComponent = computed(() =>
ctx.disableHoverableContent.value ? TooltipContentImpl : TooltipContentHoverable,
);
</script>
<template>
<Presence :present="ctx.open.value" :force-mount="forceMount">
<component
:is="contentComponent"
v-bind="contentProps"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
>
<slot />
</component>
</Presence>
</template>
@@ -0,0 +1,47 @@
<script lang="ts">
import type { TooltipContentImplEmits, TooltipContentImplProps } from './TooltipContentImpl.vue';
/**
* Hoverable variant of the tooltip Content: keeps the tooltip open while the
* pointer travels through the "safe area" between the trigger and the content,
* so the content can itself be hovered without flickering closed. Selected by
* `TooltipContent` whenever `disableHoverableContent` is `false` (the default).
* Not part of the public anatomy — use `TooltipContent`.
*/
export type TooltipContentHoverableProps = TooltipContentImplProps;
export type TooltipContentHoverableEmits = TooltipContentImplEmits;
</script>
<script setup lang="ts">
import { watchEffect } from 'vue';
import TooltipContentImpl from './TooltipContentImpl.vue';
import { useForwardExpose } from '@robonen/vue';
import { useGraceArea } from '../../internal/utils/useGraceArea';
import { useTooltipContext } from './context';
const props = defineProps<TooltipContentHoverableProps>();
const emit = defineEmits<TooltipContentHoverableEmits>();
const ctx = useTooltipContext();
const { forwardRef, currentElement } = useForwardExpose();
const { isPointerInTransit, onPointerExit } = useGraceArea(ctx.trigger, currentElement);
watchEffect(() => {
ctx.isPointerInTransitRef.value = isPointerInTransit.value;
});
onPointerExit(() => ctx.onClose());
</script>
<template>
<TooltipContentImpl
:ref="forwardRef"
v-bind="props"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
>
<slot />
</TooltipContentImpl>
</template>
@@ -0,0 +1,152 @@
<script lang="ts">
import type { PopperContentProps } from '../popper';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Internal implementation behind `TooltipContent`: renders the Popper content
* inside a dismissable layer, exposes the positioning props, mirrors the popper
* CSS variables, and adds the hidden `role="tooltip"` accessible label. Use
* `TooltipContent` instead — this is not part of the public anatomy.
*/
export interface TooltipContentImplProps extends PrimitiveProps, Pick<
PopperContentProps,
| 'side'
| 'sideOffset'
| 'sideFlip'
| 'align'
| 'alignOffset'
| 'alignFlip'
| 'avoidCollisions'
| 'collisionBoundary'
| 'collisionPadding'
| 'arrowPadding'
| 'sticky'
| 'hideWhenDetached'
| 'positionStrategy'
| 'updatePositionStrategy'
> {
/**
* Accessible label for screen readers when the visible content is not descriptive
* enough (e.g. icon-only). Falls back to the rendered `textContent`.
*/
ariaLabel?: string;
}
export interface TooltipContentImplEmits {
/** Escape pressed while this tooltip is topmost. Preventable. */
escapeKeyDown: [event: KeyboardEvent];
/** Pointer down outside the tooltip. Preventable. */
pointerDownOutside: [event: PointerEvent | MouseEvent];
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { DismissableLayer } from '../../utilities/dismissable-layer';
import { PopperContent } from '../popper';
import { TOOLTIP_OPEN_EVENT } from './utils';
import { VisuallyHidden } from '../../utilities/visually-hidden';
import { useEventListener, useForwardExpose } from '@robonen/vue';
import { useTooltipContext } from './context';
const props = defineProps<TooltipContentImplProps>();
const emit = defineEmits<TooltipContentImplEmits>();
const ctx = useTooltipContext();
const { forwardRef, currentElement } = useForwardExpose();
// Merge order (highest priority first): explicit per-Content props →
// Provider-level `content` defaults → hard defaults. `undefined` entries in a
// higher-priority layer fall through to the next, mirroring reka's `defu`.
const popperProps = computed(() => {
const defaults = ctx.contentDefaults.value;
const pick = <K extends keyof TooltipContentImplProps>(
key: K,
fallback: NonNullable<TooltipContentImplProps[K]>,
): NonNullable<TooltipContentImplProps[K]> =>
(props[key] ?? defaults?.[key] ?? fallback) as NonNullable<TooltipContentImplProps[K]>;
return {
side: pick('side', 'top'),
sideOffset: pick('sideOffset', 0),
sideFlip: pick('sideFlip', true),
align: pick('align', 'center'),
alignOffset: pick('alignOffset', 0),
alignFlip: pick('alignFlip', true),
avoidCollisions: pick('avoidCollisions', true),
collisionBoundary: props.collisionBoundary ?? defaults?.collisionBoundary ?? [],
collisionPadding: pick('collisionPadding', 0),
arrowPadding: pick('arrowPadding', 0),
sticky: pick('sticky', 'partial'),
hideWhenDetached: pick('hideWhenDetached', false),
positionStrategy: props.positionStrategy ?? defaults?.positionStrategy,
updatePositionStrategy: props.updatePositionStrategy ?? defaults?.updatePositionStrategy,
};
});
const as = computed(() => props.as ?? ctx.contentDefaults.value?.as ?? 'div');
const computedAriaLabel = computed(() =>
props.ariaLabel ?? ctx.contentDefaults.value?.ariaLabel ?? currentElement.value?.textContent ?? '',
);
function onDocumentTooltipOpen() {
ctx.onClose();
}
function onScrollCapture(event: Event) {
const target = event.target as Node | null;
if (target && ctx.trigger.value && target.contains(ctx.trigger.value)) ctx.onClose();
}
// Auto-removed on scope dispose. SSR-safe: the window default no-ops without a
// `window`, and the `document` getter resolves to `undefined` on the server.
// `capture`/`passive` preserved.
useEventListener('scroll', onScrollCapture, { capture: true, passive: true });
useEventListener(() => globalThis.document, TOOLTIP_OPEN_EVENT, onDocumentTooltipOpen);
function onPointerDownOutside(event: PointerEvent | MouseEvent) {
if (
ctx.disableClosingTrigger.value
&& ctx.trigger.value
&& ctx.trigger.value.contains(event.target as Node)
) {
event.preventDefault();
}
emit('pointerDownOutside', event);
}
</script>
<template>
<DismissableLayer
as="template"
:disable-outside-pointer-events="false"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="onPointerDownOutside"
@focus-outside.prevent
@dismiss="ctx.onClose"
>
<PopperContent
:ref="forwardRef"
:as="as"
v-bind="popperProps"
:data-state="ctx.stateAttribute.value"
:style="{
'--tooltip-content-transform-origin': 'var(--popper-transform-origin)',
'--tooltip-content-available-width': 'var(--popper-available-width)',
'--tooltip-content-available-height': 'var(--popper-available-height)',
'--tooltip-trigger-width': 'var(--popper-anchor-width)',
'--tooltip-trigger-height': 'var(--popper-anchor-height)',
}"
>
<slot />
<VisuallyHidden
:id="ctx.contentId.value"
role="tooltip"
>
{{ computedAriaLabel }}
</VisuallyHidden>
</PopperContent>
</DismissableLayer>
</template>
@@ -0,0 +1,22 @@
<script lang="ts">
import type { TeleportPrimitiveProps } from '../../utilities/teleport';
/**
* Teleports the tooltip Content into another part of the DOM (the body by
* default) so it escapes parent `overflow`/`transform`/`z-index` stacking
* contexts. Wrap Content in a Portal when those clipping issues occur.
*/
export interface TooltipPortalProps extends TeleportPrimitiveProps {}
</script>
<script setup lang="ts">
import { Portal } from '../../utilities/teleport';
const props = defineProps<TooltipPortalProps>();
</script>
<template>
<Portal v-bind="props">
<slot />
</Portal>
</template>
@@ -0,0 +1,106 @@
<script lang="ts">
import type { TooltipContentImplProps } from './TooltipContentImpl.vue';
/**
* Wraps a group of tooltips to share open/close timing and global behavior.
* Place it high in the tree (often at the app root); every `TooltipRoot` must
* have a Provider ancestor. It governs the hover `delayDuration` and the
* `skipDelayDuration` window that lets neighboring tooltips open instantly once
* one has shown, plus group-wide defaults each `TooltipRoot` may override.
*/
export interface TooltipProviderProps {
/**
* Hover delay before opening, in ms.
* @default 700
*/
delayDuration?: number;
/**
* After a tooltip closes, subsequent tooltips open without delay for this many ms.
* @default 300
*/
skipDelayDuration?: number;
/**
* When `true`, the tooltip closes as soon as the pointer leaves the trigger
* (hoverable content disabled). Has a11y consequences.
* @default false
*/
disableHoverableContent?: boolean;
/**
* When `true`, clicking the trigger does not close the tooltip.
* @default false
*/
disableClosingTrigger?: boolean;
/**
* Disable all tooltips inside this provider.
* @default false
*/
disabled?: boolean;
/**
* Skip opening on focus that did not come from the keyboard
* (matched via `:focus-visible`).
* @default false
*/
ignoreNonKeyboardFocus?: boolean;
/**
* Group-wide default Content props (e.g. `side`, `sideOffset`, `align`)
* applied to every tooltip in this provider. Props set on an individual
* `TooltipContent` always win over these defaults.
*/
content?: Partial<TooltipContentImplProps>;
}
</script>
<script setup lang="ts">
import { onScopeDispose, ref, toRef } from 'vue';
import { provideTooltipProviderContext } from './context';
defineOptions({ inheritAttrs: false });
const {
delayDuration = 700,
skipDelayDuration = 300,
disableHoverableContent = false,
disableClosingTrigger = false,
disabled = false,
ignoreNonKeyboardFocus = false,
content,
} = defineProps<TooltipProviderProps>();
const isOpenDelayed = ref(true);
const isPointerInTransitRef = ref(false);
let skipTimer: ReturnType<typeof setTimeout> | undefined;
function clearSkipTimer() {
if (skipTimer !== undefined) {
clearTimeout(skipTimer);
skipTimer = undefined;
}
}
onScopeDispose(clearSkipTimer);
provideTooltipProviderContext({
isOpenDelayed,
delayDuration: toRef(() => delayDuration),
skipDelayDuration: toRef(() => skipDelayDuration),
disableHoverableContent: toRef(() => disableHoverableContent),
disableClosingTrigger: toRef(() => disableClosingTrigger),
disabled: toRef(() => disabled),
ignoreNonKeyboardFocus: toRef(() => ignoreNonKeyboardFocus),
content: toRef(() => content),
isPointerInTransitRef,
onOpen() {
clearSkipTimer();
isOpenDelayed.value = false;
},
onClose() {
clearSkipTimer();
skipTimer = setTimeout(() => {
isOpenDelayed.value = true;
}, skipDelayDuration);
},
});
</script>
<template>
<slot />
</template>
@@ -0,0 +1,179 @@
<script lang="ts">
/**
* A small floating label that appears on hover or keyboard focus to describe an
* otherwise non-obvious control (such as an icon-only button). Composed from a
* Trigger, a Portal, and Content (with an optional Arrow); positioning is handled
* by the underlying Popper. Tooltips are pointer/focus driven and non-interactive
* by design — reach for Popover when the overlay needs focusable content.
*
* Root owns the per-tooltip open state and provides context to every part. Each
* Root must live inside a `TooltipProvider`, which supplies shared delay/skip
* timing for a group of tooltips. Bind `v-model:open` to control it, or rely on
* the Trigger for uncontrolled use. Props here override the matching Provider
* defaults for this one tooltip.
*/
export interface TooltipRootProps {
/** Initial open state in uncontrolled mode. */
defaultOpen?: boolean;
/**
* Per-tooltip override for the provider's `delayDuration`.
* @default 700
*/
delayDuration?: number;
/**
* Per-tooltip override for the provider's `disableHoverableContent`.
*/
disableHoverableContent?: boolean;
/**
* Per-tooltip override for the provider's `disableClosingTrigger`.
*/
disableClosingTrigger?: boolean;
/**
* Per-tooltip override for the provider's `disabled`.
*/
disabled?: boolean;
/**
* Per-tooltip override for the provider's `ignoreNonKeyboardFocus`.
*/
ignoreNonKeyboardFocus?: boolean;
}
/** Emitted whenever the open state of the tooltip changes (controlled or not). */
export interface TooltipRootEmits {
'update:open': [value: boolean];
}
</script>
<script setup lang="ts">
import { computed, onScopeDispose, ref, shallowRef, watch } from 'vue';
import { provideTooltipContext, useTooltipProviderContext } from './context';
import { PopperRoot } from '../popper';
import { TOOLTIP_OPEN_EVENT } from './utils';
import { useId } from '../../utilities/config-provider';
defineOptions({ inheritAttrs: false });
const {
defaultOpen = false,
delayDuration: delayDurationProp,
disableHoverableContent: disableHoverableContentProp,
disableClosingTrigger: disableClosingTriggerProp,
disabled: disabledProp,
ignoreNonKeyboardFocus: ignoreNonKeyboardFocusProp,
} = defineProps<TooltipRootProps>();
defineSlots<{
default?: (props: { open: boolean }) => unknown;
}>();
const providerCtx = useTooltipProviderContext();
const local = ref<boolean>(defaultOpen);
const open = defineModel<boolean>('open', {
default: undefined,
get: external => external ?? local.value,
set: (value) => {
local.value = value;
return value;
},
});
const delayDuration = computed(() => delayDurationProp ?? providerCtx.delayDuration.value);
const disableHoverableContent = computed(
() => disableHoverableContentProp ?? providerCtx.disableHoverableContent.value,
);
const disableClosingTrigger = computed(
() => disableClosingTriggerProp ?? providerCtx.disableClosingTrigger.value,
);
const disabled = computed(() => disabledProp ?? providerCtx.disabled.value);
const ignoreNonKeyboardFocus = computed(
() => ignoreNonKeyboardFocusProp ?? providerCtx.ignoreNonKeyboardFocus.value,
);
const wasOpenDelayed = ref(false);
const trigger = shallowRef<HTMLElement>();
const contentId = useId(undefined, 'tooltip-content');
const stateAttribute = computed<'closed' | 'delayed-open' | 'instant-open'>(() => {
if (!open.value) return 'closed';
return wasOpenDelayed.value ? 'delayed-open' : 'instant-open';
});
let openTimer: ReturnType<typeof setTimeout> | undefined;
function clearOpenTimer() {
if (openTimer !== undefined) {
clearTimeout(openTimer);
openTimer = undefined;
}
}
onScopeDispose(clearOpenTimer);
function dispatchOpenEvent() {
if (typeof document === 'undefined') return;
document.dispatchEvent(new CustomEvent(TOOLTIP_OPEN_EVENT));
}
// Centralize provider skip-delay bookkeeping and the single-open broadcast in a
// watcher so that controlled (`v-model:open`) changes — not just the imperative
// trigger handlers — also notify the provider and close sibling tooltips.
watch(open, (isOpen) => {
if (isOpen) {
providerCtx.onOpen();
dispatchOpenEvent();
}
else {
providerCtx.onClose();
}
});
function handleOpen() {
clearOpenTimer();
wasOpenDelayed.value = false;
open.value = true;
}
function handleClose() {
clearOpenTimer();
open.value = false;
}
function handleDelayedOpen() {
clearOpenTimer();
openTimer = setTimeout(() => {
wasOpenDelayed.value = true;
open.value = true;
}, delayDuration.value);
}
provideTooltipContext({
contentId,
open,
stateAttribute,
trigger,
isPointerInTransitRef: providerCtx.isPointerInTransitRef,
disableHoverableContent,
disableClosingTrigger,
disabled,
ignoreNonKeyboardFocus,
contentDefaults: computed(() => providerCtx.content.value),
onTriggerChange(el) {
trigger.value = el;
},
onTriggerEnter() {
if (providerCtx.isOpenDelayed.value) handleDelayedOpen();
else handleOpen();
},
onTriggerLeave() {
if (disableHoverableContent.value) handleClose();
else clearOpenTimer();
},
onOpen: handleOpen,
onClose: handleClose,
});
</script>
<template>
<PopperRoot>
<slot :open="open" />
</PopperRoot>
</template>
@@ -0,0 +1,104 @@
<script lang="ts">
import type { PopperAnchorProps } from '../popper';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The element the tooltip describes and anchors to. Hovering or focusing it
* opens the tooltip (after the delay); pointer-down/click closes it unless
* `disableClosingTrigger` is set. Wires up `aria-describedby` to the content
* and renders as a `<button>` by default. Pass `reference` to position the
* tooltip against a custom virtual/real element instead of the trigger node.
*/
export interface TooltipTriggerProps extends PrimitiveProps, Pick<PopperAnchorProps, 'reference'> {}
</script>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useTooltipContext, useTooltipProviderContext } from './context';
import { PopperAnchor } from '../popper';
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
const { as = 'button', reference } = defineProps<TooltipTriggerProps>();
const ctx = useTooltipContext();
const providerCtx = useTooltipProviderContext();
const { forwardRef, currentElement } = useForwardExpose();
const isPointerDown = ref(false);
const hasPointerMoveOpened = ref(false);
onMounted(() => {
ctx.onTriggerChange(currentElement.value);
});
function onPointerMove(event: PointerEvent) {
if (ctx.disabled.value) return;
if (event.pointerType === 'touch') return;
if (hasPointerMoveOpened.value || providerCtx.isPointerInTransitRef.value) return;
ctx.onTriggerEnter();
hasPointerMoveOpened.value = true;
}
function onPointerLeave() {
if (ctx.disabled.value) return;
ctx.onTriggerLeave();
hasPointerMoveOpened.value = false;
}
function onPointerUp() {
// Defer reset by one tick so `focus` handlers can see the latest `isPointerDown`.
setTimeout(() => {
isPointerDown.value = false;
}, 1);
}
function onPointerDown() {
if (ctx.disabled.value) return;
if (ctx.open.value && !ctx.disableClosingTrigger.value) ctx.onClose();
isPointerDown.value = true;
document.addEventListener('pointerup', onPointerUp, { once: true });
}
function onFocus(event: FocusEvent) {
if (ctx.disabled.value) return;
if (isPointerDown.value) return;
if (
ctx.ignoreNonKeyboardFocus.value
&& !(event.target as HTMLElement | null)?.matches?.(':focus-visible')
) return;
ctx.onOpen();
}
function onBlur() {
if (ctx.disabled.value) return;
ctx.onClose();
}
function onClick() {
if (ctx.disabled.value) return;
if (!ctx.disableClosingTrigger.value) ctx.onClose();
}
</script>
<template>
<PopperAnchor as="template" :reference="reference">
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
:aria-describedby="ctx.open.value ? ctx.contentId.value : undefined"
:data-state="ctx.stateAttribute.value"
data-tooltip-trigger
data-grace-area-trigger
@pointermove="onPointerMove"
@pointerleave="onPointerLeave"
@pointerdown="onPointerDown"
@focus="onFocus"
@blur="onBlur"
@click="onClick"
>
<slot />
</Primitive>
</PopperAnchor>
</template>
@@ -0,0 +1,445 @@
import {
TooltipContent,
TooltipProvider,
TooltipRoot,
TooltipTrigger,
} from '../../../index';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
document.body.removeAttribute('style');
vi.useRealTimers();
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
function mountTooltip(options: {
open?: boolean;
defaultOpen?: boolean;
delayDuration?: number;
skipDelayDuration?: number;
disabled?: boolean;
disableHoverableContent?: boolean;
disableClosingTrigger?: boolean;
ignoreNonKeyboardFocus?: boolean;
onUpdateOpen?: (v: boolean) => void;
forceMount?: boolean;
} = {}) {
const Wrapper = defineComponent({
setup() {
return () =>
h(
TooltipProvider,
{
delayDuration: options.delayDuration,
skipDelayDuration: options.skipDelayDuration,
},
{
default: () =>
h(
TooltipRoot,
{
open: options.open,
defaultOpen: options.defaultOpen,
disabled: options.disabled,
disableHoverableContent: options.disableHoverableContent,
disableClosingTrigger: options.disableClosingTrigger,
ignoreNonKeyboardFocus: options.ignoreNonKeyboardFocus,
'onUpdate:open': options.onUpdateOpen,
},
{
default: () => [
h(TooltipTrigger, null, { default: () => 'Trigger' }),
h(
TooltipContent,
{ forceMount: options.forceMount },
{ default: () => 'Tooltip body' },
),
],
},
),
},
);
},
});
return track(mount(Wrapper, { attachTo: document.body }));
}
function getTrigger(): HTMLButtonElement {
return document.querySelector('[data-tooltip-trigger]') as HTMLButtonElement;
}
function getTooltip(): HTMLElement | null {
return document.querySelector('[role="tooltip"]');
}
describe('Tooltip', () => {
it('renders trigger with closed state by default', () => {
mountTooltip();
const trigger = getTrigger();
expect(trigger).toBeTruthy();
expect(trigger.getAttribute('data-state')).toBe('closed');
expect(trigger.getAttribute('aria-describedby')).toBe(null);
expect(getTooltip()).toBeNull();
});
it('opens with defaultOpen and exposes aria-describedby', async () => {
mountTooltip({ defaultOpen: true });
await nextTick();
const trigger = getTrigger();
expect(trigger.getAttribute('data-state')).toBe('instant-open');
expect(trigger.getAttribute('aria-describedby')).toBeTruthy();
const tip = getTooltip();
expect(tip).toBeTruthy();
expect(tip!.id).toBe(trigger.getAttribute('aria-describedby'));
});
it('opens on focus and closes on blur', async () => {
mountTooltip();
const trigger = getTrigger();
trigger.dispatchEvent(new FocusEvent('focus'));
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('instant-open');
trigger.dispatchEvent(new FocusEvent('blur'));
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('closed');
});
it('respects controlled v-model', async () => {
const onUpdate = vi.fn();
const Wrapper = defineComponent({
props: { open: { type: Boolean, default: false } },
emits: ['update:open'],
setup(props, { emit }) {
return () =>
h(
TooltipProvider,
null,
{
default: () =>
h(
TooltipRoot,
{
open: props.open,
'onUpdate:open': (v: boolean) => {
onUpdate(v);
emit('update:open', v);
},
},
{
default: () => [
h(TooltipTrigger, null, { default: () => 'T' }),
h(TooltipContent, null, { default: () => 'body' }),
],
},
),
},
);
},
});
const wrapper = track(mount(Wrapper, { attachTo: document.body, props: { open: false } }));
const trigger = getTrigger();
trigger.dispatchEvent(new FocusEvent('focus'));
await nextTick();
expect(onUpdate).toHaveBeenCalledWith(true);
expect(trigger.getAttribute('data-state')).toBe('closed');
await wrapper.setProps({ open: true });
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('instant-open');
});
it('does not open when disabled', async () => {
mountTooltip({ disabled: true });
const trigger = getTrigger();
trigger.dispatchEvent(new FocusEvent('focus'));
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('closed');
expect(getTooltip()).toBeNull();
});
it('uses delayed-open after delay window via pointer', async () => {
vi.useFakeTimers();
mountTooltip({ delayDuration: 100, skipDelayDuration: 50 });
const trigger = getTrigger();
trigger.dispatchEvent(new PointerEvent('pointermove', { pointerType: 'mouse' }));
// Not opened yet.
expect(trigger.getAttribute('data-state')).toBe('closed');
vi.advanceTimersByTime(100);
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('delayed-open');
});
it('skips delay for second tooltip within skipDelayDuration window', async () => {
vi.useFakeTimers();
const Wrapper = defineComponent({
setup() {
return () =>
h(
TooltipProvider,
{ delayDuration: 500, skipDelayDuration: 300 },
{
default: () => [
h(
TooltipRoot,
null,
{
default: () => [
h(TooltipTrigger, { 'data-id': 'a' }, { default: () => 'A' }),
h(TooltipContent, null, { default: () => 'A body' }),
],
},
),
h(
TooltipRoot,
null,
{
default: () => [
h(TooltipTrigger, { 'data-id': 'b' }, { default: () => 'B' }),
h(TooltipContent, null, { default: () => 'B body' }),
],
},
),
],
},
);
},
});
track(mount(Wrapper, { attachTo: document.body }));
const a = document.querySelector('[data-id="a"]') as HTMLElement;
const b = document.querySelector('[data-id="b"]') as HTMLElement;
// Open A with delay.
a.dispatchEvent(new PointerEvent('pointermove', { pointerType: 'mouse' }));
vi.advanceTimersByTime(500);
await nextTick();
expect(a.getAttribute('data-state')).toBe('delayed-open');
// Close A via blur-equivalent: pointerleave + disableHoverable would do it,
// but here we just close via focus loss simulation through trigger event.
a.dispatchEvent(new FocusEvent('blur'));
await nextTick();
expect(a.getAttribute('data-state')).toBe('closed');
// Within the skip window — moving over B should open it instantly.
vi.advanceTimersByTime(100);
b.dispatchEvent(new PointerEvent('pointermove', { pointerType: 'mouse' }));
await nextTick();
expect(b.getAttribute('data-state')).toBe('instant-open');
});
it('does not open on touch pointers (handled by long-press elsewhere)', async () => {
mountTooltip({ delayDuration: 0 });
const trigger = getTrigger();
trigger.dispatchEvent(new PointerEvent('pointermove', { pointerType: 'touch' }));
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('closed');
});
it('closes on Escape via dismissable layer', async () => {
mountTooltip({ defaultOpen: true });
await nextTick();
expect(getTrigger().getAttribute('data-state')).toBe('instant-open');
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await nextTick();
expect(getTrigger().getAttribute('data-state')).toBe('closed');
});
it('closes when clicked unless disableClosingTrigger', async () => {
mountTooltip({ defaultOpen: true });
await nextTick();
const trigger = getTrigger();
trigger.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('closed');
});
it('keeps tooltip open on click when disableClosingTrigger is set', async () => {
mountTooltip({ defaultOpen: true, disableClosingTrigger: true });
await nextTick();
const trigger = getTrigger();
trigger.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('instant-open');
});
it('tags the trigger as a grace-area trigger for safe-area handoff', () => {
mountTooltip();
const trigger = getTrigger();
expect(trigger.hasAttribute('data-grace-area-trigger')).toBe(true);
// Existing marker is preserved for backwards compatibility.
expect(trigger.hasAttribute('data-tooltip-trigger')).toBe(true);
});
});
describe('Tooltip hoverable content', () => {
it('renders the hoverable variant by default (no flicker on content hover)', async () => {
mountTooltip({ defaultOpen: true });
await nextTick();
const tip = getTooltip();
expect(tip).toBeTruthy();
// Hoverable variant wires a grace area; the impl still renders role=tooltip,
// so we assert the content is present and the variant did not crash.
expect(tip!.textContent).toContain('Tooltip body');
});
it('renders without grace area when disableHoverableContent is set', async () => {
mountTooltip({ defaultOpen: true, disableHoverableContent: true });
await nextTick();
const tip = getTooltip();
expect(tip).toBeTruthy();
expect(tip!.textContent).toContain('Tooltip body');
});
});
describe('Tooltip reference anchor', () => {
it('forwards a custom reference to the popper anchor', async () => {
const reference = document.createElement('div');
document.body.appendChild(reference);
const Wrapper = defineComponent({
setup() {
return () =>
h(TooltipProvider, null, {
default: () =>
h(TooltipRoot, { defaultOpen: true }, {
default: () => [
h(TooltipTrigger, { reference }, { default: () => 'T' }),
h(TooltipContent, null, { default: () => 'body' }),
],
}),
});
},
});
const wrapper = track(mount(Wrapper, { attachTo: document.body }));
await nextTick();
// No throw and the tooltip still opens with a custom reference element.
expect(getTrigger().hasAttribute('data-grace-area-trigger')).toBe(true);
expect(getTooltip()).toBeTruthy();
wrapper.unmount();
reference.remove();
});
});
describe('Tooltip provider content defaults', () => {
function mountWithProviderContent(options: {
providerAriaLabel?: string;
contentAriaLabel?: string;
}) {
const Wrapper = defineComponent({
setup() {
return () =>
h(
TooltipProvider,
{ content: { ariaLabel: options.providerAriaLabel } },
{
default: () =>
h(TooltipRoot, { defaultOpen: true }, {
default: () => [
h(TooltipTrigger, null, { default: () => 'T' }),
h(
TooltipContent,
{ ariaLabel: options.contentAriaLabel },
{ default: () => 'visible body' },
),
],
}),
},
);
},
});
return track(mount(Wrapper, { attachTo: document.body }));
}
it('applies a provider-level content default to every tooltip', async () => {
mountWithProviderContent({ providerAriaLabel: 'from provider' });
await nextTick();
const tip = getTooltip();
expect(tip!.textContent).toContain('from provider');
});
it('lets per-content props win over provider defaults', async () => {
mountWithProviderContent({
providerAriaLabel: 'from provider',
contentAriaLabel: 'from content',
});
await nextTick();
const tip = getTooltip();
expect(tip!.textContent).toContain('from content');
expect(tip!.textContent).not.toContain('from provider');
});
});
describe('Tooltip controlled-open broadcast', () => {
it('closes another open tooltip when one opens via controlled v-model', async () => {
const Wrapper = defineComponent({
props: { openB: { type: Boolean, default: false } },
setup(props) {
const openA = ref(true);
return () =>
h(TooltipProvider, null, {
default: () => [
h(
TooltipRoot,
{ open: openA.value, 'onUpdate:open': (v: boolean) => { openA.value = v; } },
{
default: () => [
h(TooltipTrigger, { 'data-id': 'a' }, { default: () => 'A' }),
h(TooltipContent, null, { default: () => 'A body' }),
],
},
),
h(TooltipRoot, { open: props.openB }, {
default: () => [
h(TooltipTrigger, { 'data-id': 'b' }, { default: () => 'B' }),
h(TooltipContent, null, { default: () => 'B body' }),
],
}),
],
});
},
});
const wrapper = track(mount(Wrapper, { attachTo: document.body, props: { openB: false } }));
await nextTick();
const a = document.querySelector('[data-id="a"]') as HTMLElement;
expect(a.getAttribute('data-state')).toBe('instant-open');
// Opening B (controlled) should broadcast TOOLTIP_OPEN and close A.
await wrapper.setProps({ openB: true });
await nextTick();
await nextTick();
const b = document.querySelector('[data-id="b"]') as HTMLElement;
expect(b.getAttribute('data-state')).toBe('instant-open');
expect(a.getAttribute('data-state')).toBe('closed');
});
});
@@ -0,0 +1,57 @@
import type { ComputedRef, Ref } from 'vue';
import type { TooltipState } from './utils';
import type { TooltipContentImplProps } from './TooltipContentImpl.vue';
import { useContextFactory } from '@robonen/vue';
export interface TooltipProviderContext {
/** Whether the next tooltip open should wait for the full `delayDuration`. */
isOpenDelayed: Ref<boolean>;
/** Hover delay before opening when `isOpenDelayed` is `true`. */
delayDuration: Ref<number>;
/** Skip-delay window after a tooltip closes — used for "menu-like" hover groups. */
skipDelayDuration: Ref<number>;
/** Set to `true` while the pointer is in the safe-area between trigger and content. */
isPointerInTransitRef: Ref<boolean>;
/** Globally disable hoverable content (pointer leaving trigger immediately closes). */
disableHoverableContent: Ref<boolean>;
/** Globally disable closing the tooltip by clicking the trigger. */
disableClosingTrigger: Ref<boolean>;
/** Globally disable all tooltips inside this provider. */
disabled: Ref<boolean>;
/** Skip opening on non-keyboard focus (avoids tooltips after closing dialogs / switching tabs). */
ignoreNonKeyboardFocus: Ref<boolean>;
/** Group-wide default Content props (positioning, etc.); per-Content props win. */
content: Ref<Partial<TooltipContentImplProps> | undefined>;
onOpen: () => void;
onClose: () => void;
}
export const {
inject: useTooltipProviderContext,
provide: provideTooltipProviderContext,
} = useContextFactory<TooltipProviderContext>('TooltipProviderContext');
export interface TooltipContext {
contentId: ComputedRef<string>;
open: Ref<boolean>;
stateAttribute: ComputedRef<TooltipState>;
trigger: Ref<HTMLElement | undefined>;
/** Set to `true` while the pointer is in the safe-area between trigger and content. */
isPointerInTransitRef: Ref<boolean>;
disableHoverableContent: ComputedRef<boolean>;
disableClosingTrigger: ComputedRef<boolean>;
disabled: ComputedRef<boolean>;
ignoreNonKeyboardFocus: ComputedRef<boolean>;
/** Group-wide default Content props from the Provider; per-Content props win. */
contentDefaults: ComputedRef<Partial<TooltipContentImplProps> | undefined>;
onTriggerChange: (el: HTMLElement | undefined) => void;
onTriggerEnter: () => void;
onTriggerLeave: () => void;
onOpen: () => void;
onClose: () => void;
}
export const {
inject: useTooltipContext,
provide: provideTooltipContext,
} = useContextFactory<TooltipContext>('TooltipContext');
@@ -0,0 +1,65 @@
<script setup lang="ts">
import {
TooltipArrow,
TooltipContent,
TooltipPortal,
TooltipProvider,
TooltipRoot,
TooltipTrigger,
} from '@robonen/primitives';
const actions = [
{
label: 'Bold',
side: 'top' as const,
path: 'M6 4h7a4 4 0 0 1 0 8H6zm0 8h8a4 4 0 0 1 0 8H6z',
},
{
label: 'Italic',
side: 'top' as const,
path: 'M19 4h-9M14 20H5M15 4 9 20',
},
{
label: 'Add link',
side: 'top' as const,
path: 'M10 13a5 5 0 0 0 7.07 0l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.07 0l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71',
},
];
</script>
<template>
<!-- A single Provider drives shared open/skip timing for the whole toolbar. -->
<TooltipProvider :delay-duration="300" :skip-delay-duration="200">
<div class="flex items-center gap-1 rounded-lg border border-border bg-bg-elevated p-1 text-fg">
<TooltipRoot v-for="action in actions" :key="action.label">
<TooltipTrigger
:aria-label="action.label"
class="inline-flex size-9 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-bg-subtle hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[state=instant-open]:bg-bg-subtle data-[state=delayed-open]:bg-bg-subtle"
>
<svg
class="size-4.5" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"
>
<path :d="action.path" />
</svg>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent
:side="action.side"
:side-offset="6"
:collision-padding="8"
class="z-50 select-none rounded-md bg-fg px-2.5 py-1.5 text-xs font-medium text-bg shadow-md data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in data-[state=delayed-open]:zoom-in-95 data-[state=instant-open]:animate-in data-[state=instant-open]:fade-in data-[state=closed]:animate-out data-[state=closed]:fade-out"
>
{{ action.label }}
<TooltipArrow :width="10" :height="5">
<svg width="10" height="5" viewBox="0 0 10 5" class="block" aria-hidden="true">
<path d="M0 0 L5 5 L10 0 Z" class="fill-fg" />
</svg>
</TooltipArrow>
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
</div>
</TooltipProvider>
</template>
@@ -0,0 +1,31 @@
export { default as TooltipProvider } from './TooltipProvider.vue';
export { default as TooltipRoot } from './TooltipRoot.vue';
export { default as TooltipTrigger } from './TooltipTrigger.vue';
export { default as TooltipPortal } from './TooltipPortal.vue';
export { default as TooltipContent } from './TooltipContent.vue';
export { default as TooltipContentHoverable } from './TooltipContentHoverable.vue';
export { default as TooltipArrow } from './TooltipArrow.vue';
export {
useTooltipContext,
useTooltipProviderContext,
type TooltipContext,
type TooltipProviderContext,
} from './context';
export { TOOLTIP_OPEN_EVENT, type TooltipState } from './utils';
export type { TooltipProviderProps } from './TooltipProvider.vue';
export type { TooltipRootProps, TooltipRootEmits } from './TooltipRoot.vue';
export type { TooltipTriggerProps } from './TooltipTrigger.vue';
export type { TooltipPortalProps } from './TooltipPortal.vue';
export type { TooltipContentProps, TooltipContentEmits } from './TooltipContent.vue';
export type {
TooltipContentHoverableProps,
TooltipContentHoverableEmits,
} from './TooltipContentHoverable.vue';
export type {
TooltipContentImplProps,
TooltipContentImplEmits,
} from './TooltipContentImpl.vue';
export type { TooltipArrowProps } from './TooltipArrow.vue';
@@ -0,0 +1,8 @@
/**
* Custom DOM event dispatched on `document` whenever any tooltip opens.
* Other open tooltips listen for it and close themselves so that only one
* tooltip is visible at a time without coupling them via a global registry.
*/
export const TOOLTIP_OPEN_EVENT = 'tooltip.open';
export type TooltipState = 'closed' | 'delayed-open' | 'instant-open';