feat(primitives): media-editor components, category reorg, perf + type cleanup
Reorganize components into category folders (forms/canvas/overlays/etc.); add the media-editor headless family (timeline, curve-editor, waveform, crop, color picker, etc.); apply perf fixes (O(1) collection lookups, plain-object drag state, gesture-leak teardown, shallowRef color state, rect caching) and replace source `any` with proper types.
This commit is contained in:
@@ -0,0 +1,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;
|
||||
@@ -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 (0–1) of the screen each snap point occupies, ordered from least
|
||||
* to most visible — e.g. `[0.2, 0.5, 0.8]`. Px strings (e.g. `'200px'`) are
|
||||
* also accepted and ignore screen height.
|
||||
*/
|
||||
snapPoints?: Array<number | string>;
|
||||
/** Index of the snap point from which the overlay fade begins. Defaults to the last. */
|
||||
fadeFromIndex?: never;
|
||||
}
|
||||
|
||||
export type DrawerRootProps = {
|
||||
/** The active snap point (two-way bindable via `v-model:active-snap-point`). */
|
||||
activeSnapPoint?: number | string | null;
|
||||
/**
|
||||
* Fraction (0–1) of the drawer that must be swiped away before it closes.
|
||||
* @default 0.25
|
||||
*/
|
||||
closeThreshold?: number;
|
||||
/** Scale the page background down while the drawer is open (stacked-card look). */
|
||||
shouldScaleBackground?: boolean;
|
||||
/**
|
||||
* Set the body background to black during the scale effect.
|
||||
* @default true
|
||||
*/
|
||||
setBackgroundColorOnScale?: boolean;
|
||||
/**
|
||||
* How long (ms) dragging is disabled after scrolling content inside the drawer.
|
||||
* @default 100
|
||||
*/
|
||||
scrollLockTimeout?: number;
|
||||
/**
|
||||
* Keep the drawer in place when the keyboard opens, resizing it to stay
|
||||
* scrollable instead of translating it upward.
|
||||
*/
|
||||
fixed?: boolean;
|
||||
/**
|
||||
* When `false`, dragging, clicking outside, and pressing escape will not close
|
||||
* the drawer. Pair with `v-model:open` so you can still control it.
|
||||
* @default true
|
||||
*/
|
||||
dismissible?: boolean;
|
||||
/**
|
||||
* When `false`, the rest of the page stays interactive while the drawer is open.
|
||||
* @default true
|
||||
*/
|
||||
modal?: boolean;
|
||||
/** Controlled open state (`v-model:open`). */
|
||||
open?: boolean;
|
||||
/**
|
||||
* Start opened, skipping the initial enter animation. Still reacts to `open`.
|
||||
* @default false
|
||||
*/
|
||||
defaultOpen?: boolean;
|
||||
/** Marks this drawer as nested inside another (set automatically by DrawerRootNested). */
|
||||
nested?: boolean;
|
||||
/**
|
||||
* The edge the drawer is anchored to.
|
||||
* @default 'bottom'
|
||||
*/
|
||||
direction?: DrawerDirection;
|
||||
/** Skip all body styling that the drawer would otherwise apply. */
|
||||
noBodyStyles?: boolean;
|
||||
/**
|
||||
* Only allow dragging via the DrawerHandle.
|
||||
* @default false
|
||||
*/
|
||||
handleOnly?: boolean;
|
||||
/** Don't restore scroll position when the drawer closes after a navigation. */
|
||||
preventScrollRestoration?: boolean;
|
||||
} & WithoutFadeFromProps;
|
||||
|
||||
export interface UseDrawerProps {
|
||||
open: Ref<boolean>;
|
||||
snapPoints: Ref<Array<number | string> | undefined>;
|
||||
dismissible: Ref<boolean>;
|
||||
nested: Ref<boolean>;
|
||||
fixed: Ref<boolean | undefined>;
|
||||
modal: Ref<boolean>;
|
||||
shouldScaleBackground: Ref<boolean | undefined>;
|
||||
setBackgroundColorOnScale: Ref<boolean | undefined>;
|
||||
activeSnapPoint: Ref<number | string | null | undefined>;
|
||||
fadeFromIndex: Ref<number | undefined>;
|
||||
closeThreshold: Ref<number>;
|
||||
scrollLockTimeout: Ref<number>;
|
||||
direction: Ref<DrawerDirection>;
|
||||
noBodyStyles: Ref<boolean>;
|
||||
preventScrollRestoration: Ref<boolean>;
|
||||
handleOnly: Ref<boolean>;
|
||||
}
|
||||
|
||||
export interface DrawerRootEmits {
|
||||
/** Fired continuously during a drag with the fraction (0–1) dragged. */
|
||||
(e: 'drag', percentageDragged: number): void;
|
||||
/** Fired when the pointer is released, with whether the drawer stays open. */
|
||||
(e: 'release', open: boolean): void;
|
||||
/** Fired when the drawer begins closing. */
|
||||
(e: 'close'): void;
|
||||
/** Two-way binding for the open state. */
|
||||
(e: 'update:open', open: boolean): void;
|
||||
/** Two-way binding for the active snap point. */
|
||||
(e: 'update:activeSnapPoint', val: string | number): void;
|
||||
/** Fired after the open/close animation ends, with the open state at that time. */
|
||||
(e: 'animationEnd', open: boolean): void;
|
||||
}
|
||||
|
||||
export interface DialogEmitHandlers {
|
||||
emitDrag: (percentageDragged: number) => void;
|
||||
emitRelease: (open: boolean) => void;
|
||||
emitClose: () => void;
|
||||
}
|
||||
|
||||
export interface DrawerHandleProps {
|
||||
/** Prevent the handle tap from cycling through snap points. */
|
||||
preventCycle?: boolean;
|
||||
}
|
||||
|
||||
function usePropOrDefaultRef<T>(prop: Ref<T | undefined> | undefined, defaultRef: Ref<T>): Ref<T> {
|
||||
return prop && !!prop.value ? (prop as Ref<T>) : defaultRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* The drawer engine: owns the drag gesture, snap-point settling, background
|
||||
* scaling, and nested-drawer coordination. Returns the value provided as
|
||||
* {@link DrawerRootContext} to the drawer parts.
|
||||
*/
|
||||
export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRootContext {
|
||||
const {
|
||||
emitDrag,
|
||||
emitRelease,
|
||||
emitClose,
|
||||
open,
|
||||
dismissible,
|
||||
nested,
|
||||
modal,
|
||||
shouldScaleBackground,
|
||||
setBackgroundColorOnScale,
|
||||
scrollLockTimeout,
|
||||
closeThreshold,
|
||||
activeSnapPoint,
|
||||
fadeFromIndex,
|
||||
direction,
|
||||
noBodyStyles,
|
||||
handleOnly,
|
||||
preventScrollRestoration,
|
||||
} = props;
|
||||
|
||||
const hasBeenOpened = ref(open.value);
|
||||
const isDragging = ref(false);
|
||||
const justReleased = ref(false);
|
||||
|
||||
const overlayRef = shallowRef<HTMLElement | undefined>(undefined);
|
||||
|
||||
const openTime = ref<Date | null>(null);
|
||||
const dragStartTime = ref<Date | null>(null);
|
||||
const dragEndTime = ref<Date | null>(null);
|
||||
const lastTimeDragPrevented = ref<Date | null>(null);
|
||||
const isAllowedToDrag = ref(false);
|
||||
|
||||
const nestedOpenChangeTimer = ref<number | null>(null);
|
||||
|
||||
const pointerStart = ref(0);
|
||||
const keyboardIsOpen = ref(false);
|
||||
|
||||
const drawerRef = shallowRef<HTMLElement | undefined>(undefined);
|
||||
const drawerHeightRef = computed(() => drawerRef.value?.getBoundingClientRect().height || 0);
|
||||
|
||||
const snapPoints = usePropOrDefaultRef(props.snapPoints, ref<Array<number | string> | undefined>(undefined));
|
||||
|
||||
const hasSnapPoints = computed(() => !!(snapPoints.value?.length ?? 0));
|
||||
|
||||
const handleRef = shallowRef<HTMLElement | undefined>(undefined);
|
||||
|
||||
const {
|
||||
activeSnapPointIndex,
|
||||
onRelease: onReleaseSnapPoints,
|
||||
snapPointsOffset,
|
||||
onDrag: onDragSnapPoints,
|
||||
shouldFade,
|
||||
getPercentageDragged: getSnapPointsPercentageDragged,
|
||||
} = useSnapPoints({
|
||||
snapPoints,
|
||||
activeSnapPoint,
|
||||
drawerRef,
|
||||
fadeFromIndex,
|
||||
overlayRef,
|
||||
onSnapPointChange,
|
||||
direction,
|
||||
});
|
||||
|
||||
function onSnapPointChange(activeSnapPointIndex: number, snapPointsOffset: number[]) {
|
||||
// Refresh openTime when we reach the last snap point so scrollable content
|
||||
// there isn't immediately draggable.
|
||||
if (snapPoints.value && activeSnapPointIndex === snapPointsOffset.length - 1)
|
||||
openTime.value = new Date();
|
||||
}
|
||||
|
||||
usePositionFixed({
|
||||
isOpen: open,
|
||||
modal,
|
||||
nested,
|
||||
hasBeenOpened,
|
||||
noBodyStyles,
|
||||
preventScrollRestoration,
|
||||
});
|
||||
|
||||
function getScale() {
|
||||
return (window.innerWidth - WINDOW_TOP_OFFSET) / window.innerWidth;
|
||||
}
|
||||
|
||||
function shouldDrag(el: EventTarget | null, isDraggingInDirection: boolean) {
|
||||
if (!el)
|
||||
return false;
|
||||
let element = el as HTMLElement;
|
||||
const highlightedText = globalThis.getSelection()?.toString();
|
||||
const swipeAmount = drawerRef.value ? getTranslate(drawerRef.value, isVertical(direction.value) ? 'y' : 'x') : null;
|
||||
const date = new Date();
|
||||
|
||||
if (element.hasAttribute('data-drawer-no-drag') || element.closest('[data-drawer-no-drag]'))
|
||||
return false;
|
||||
|
||||
if (direction.value === 'right' || direction.value === 'left')
|
||||
return true;
|
||||
|
||||
// Allow scrolling during the open animation.
|
||||
if (openTime.value && date.getTime() - openTime.value.getTime() < 500)
|
||||
return false;
|
||||
|
||||
if (swipeAmount !== null) {
|
||||
if (direction.value === 'bottom' ? swipeAmount > 0 : swipeAmount < 0)
|
||||
return true;
|
||||
}
|
||||
|
||||
// Don't drag when text is selected.
|
||||
if (highlightedText && highlightedText.length > 0)
|
||||
return false;
|
||||
|
||||
// Don't drag right after scrolling inside the drawer.
|
||||
if (
|
||||
lastTimeDragPrevented.value
|
||||
&& date.getTime() - lastTimeDragPrevented.value.getTime() < scrollLockTimeout.value
|
||||
&& swipeAmount === 0
|
||||
) {
|
||||
lastTimeDragPrevented.value = date;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isDraggingInDirection) {
|
||||
lastTimeDragPrevented.value = date;
|
||||
// Dragging in the open direction → allow scrolling instead.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Walk up the tree; if a scrollable ancestor isn't at the top, scroll it instead of dragging.
|
||||
while (element) {
|
||||
if (element.scrollHeight > element.clientHeight) {
|
||||
if (element.scrollTop !== 0) {
|
||||
lastTimeDragPrevented.value = new Date();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (element.getAttribute('role') === 'dialog')
|
||||
return true;
|
||||
}
|
||||
|
||||
element = element.parentNode as HTMLElement;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Measured once per gesture in onPress and reused every move — avoids a
|
||||
// per-frame getBoundingClientRect (forced reflow) and document.querySelector.
|
||||
let dragStartHeight = 0;
|
||||
let dragWrapper: HTMLElement | null = null;
|
||||
|
||||
function onPress(event: PointerEvent) {
|
||||
if (!dismissible.value && !snapPoints.value)
|
||||
return;
|
||||
if (drawerRef.value && !drawerRef.value.contains(event.target as Node))
|
||||
return;
|
||||
isDragging.value = true;
|
||||
dragStartTime.value = new Date();
|
||||
dragStartHeight = drawerRef.value?.getBoundingClientRect().height || 0;
|
||||
dragWrapper = getDrawerWrapper();
|
||||
|
||||
(event.target as HTMLElement).setPointerCapture(event.pointerId);
|
||||
pointerStart.value = isVertical(direction.value) ? event.clientY : event.clientX;
|
||||
}
|
||||
|
||||
function onDrag(event: PointerEvent) {
|
||||
if (!drawerRef.value)
|
||||
return;
|
||||
|
||||
if (isDragging.value) {
|
||||
const directionMultiplier = direction.value === 'bottom' || direction.value === 'right' ? 1 : -1;
|
||||
const draggedDistance
|
||||
= (pointerStart.value - (isVertical(direction.value) ? event.clientY : event.clientX)) * directionMultiplier;
|
||||
const isDraggingInDirection = draggedDistance > 0;
|
||||
|
||||
// Don't allow dragging toward close past the first snap point when not dismissible.
|
||||
const noCloseSnapPointsPreCondition = snapPoints.value && !dismissible.value && !isDraggingInDirection;
|
||||
|
||||
if (noCloseSnapPointsPreCondition && activeSnapPointIndex.value === 0)
|
||||
return;
|
||||
|
||||
const absDraggedDistance = Math.abs(draggedDistance);
|
||||
const wrapper = dragWrapper;
|
||||
|
||||
// 1 means the closed position. Height cached at drag start (no reflow).
|
||||
let percentageDragged = absDraggedDistance / (dragStartHeight || 1);
|
||||
const snapPointPercentageDragged = getSnapPointsPercentageDragged(absDraggedDistance, isDraggingInDirection);
|
||||
|
||||
if (snapPointPercentageDragged !== null)
|
||||
percentageDragged = snapPointPercentageDragged;
|
||||
|
||||
if (noCloseSnapPointsPreCondition && percentageDragged >= 1)
|
||||
return;
|
||||
|
||||
// Decide-to-drag gate + one-time gesture setup. Once allowed, stay allowed
|
||||
// for the whole gesture, so the class add + transition writes fire ONCE,
|
||||
// not on every move.
|
||||
if (!isAllowedToDrag.value) {
|
||||
if (!shouldDrag(event.target, isDraggingInDirection))
|
||||
return;
|
||||
isAllowedToDrag.value = true;
|
||||
drawerRef.value.classList.add(DRAG_CLASS);
|
||||
setStyle(drawerRef.value, STYLE_NO_TRANSITION);
|
||||
setStyle(overlayRef.value, STYLE_NO_TRANSITION);
|
||||
}
|
||||
|
||||
if (snapPoints.value)
|
||||
onDragSnapPoints({ draggedDistance });
|
||||
|
||||
// Rubber-band past the open position when there are no snap points.
|
||||
if (isDraggingInDirection && !snapPoints.value) {
|
||||
const dampenedDraggedDistance = dampenValue(draggedDistance);
|
||||
|
||||
const translateValue = Math.min(dampenedDraggedDistance * -1, 0) * directionMultiplier;
|
||||
setStyle(drawerRef.value, {
|
||||
transform: isVertical(direction.value)
|
||||
? `translate3d(0, ${translateValue}px, 0)`
|
||||
: `translate3d(${translateValue}px, 0, 0)`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const opacityValue = 1 - percentageDragged;
|
||||
|
||||
if (shouldFade.value || (fadeFromIndex.value && activeSnapPointIndex.value === fadeFromIndex.value - 1)) {
|
||||
emitDrag(percentageDragged);
|
||||
|
||||
setStyle(overlayRef.value, { opacity: `${opacityValue}`, transition: 'none' }, true);
|
||||
}
|
||||
|
||||
if (wrapper && overlayRef.value && shouldScaleBackground.value) {
|
||||
const scaleValue = Math.min(getScale() + percentageDragged * (1 - getScale()), 1);
|
||||
const borderRadiusValue = 8 - percentageDragged * 8;
|
||||
const translateValue = Math.max(0, 14 - percentageDragged * 14);
|
||||
|
||||
setStyle(
|
||||
wrapper,
|
||||
{
|
||||
borderRadius: `${borderRadiusValue}px`,
|
||||
transform: isVertical(direction.value)
|
||||
? `scale(${scaleValue}) translate3d(0, ${translateValue}px, 0)`
|
||||
: `scale(${scaleValue}) translate3d(${translateValue}px, 0, 0)`,
|
||||
transition: 'none',
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapPoints.value) {
|
||||
const translateValue = absDraggedDistance * directionMultiplier;
|
||||
|
||||
setStyle(drawerRef.value, {
|
||||
transform: isVertical(direction.value)
|
||||
? `translate3d(0, ${translateValue}px, 0)`
|
||||
: `translate3d(${translateValue}px, 0, 0)`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetDrawer() {
|
||||
if (!drawerRef.value)
|
||||
return;
|
||||
const wrapper = getDrawerWrapper();
|
||||
const currentSwipeAmount = getTranslate(drawerRef.value, isVertical(direction.value) ? 'y' : 'x');
|
||||
|
||||
setStyle(drawerRef.value, {
|
||||
transform: 'translate3d(0, 0, 0)',
|
||||
transition: `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`,
|
||||
});
|
||||
|
||||
setStyle(overlayRef.value, {
|
||||
transition: `opacity ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`,
|
||||
opacity: '1',
|
||||
});
|
||||
|
||||
// Keep the background scaled if we didn't swipe back down.
|
||||
if (shouldScaleBackground.value && currentSwipeAmount && currentSwipeAmount > 0 && open.value) {
|
||||
setStyle(
|
||||
wrapper,
|
||||
{
|
||||
borderRadius: `${BORDER_RADIUS}px`,
|
||||
overflow: 'hidden',
|
||||
...(isVertical(direction.value)
|
||||
? {
|
||||
transform: `scale(${getScale()}) translate3d(0, calc(env(safe-area-inset-top) + 14px), 0)`,
|
||||
transformOrigin: 'top',
|
||||
}
|
||||
: {
|
||||
transform: `scale(${getScale()}) translate3d(calc(env(safe-area-inset-top) + 14px), 0, 0)`,
|
||||
transformOrigin: 'left',
|
||||
}),
|
||||
transitionProperty: 'transform, border-radius',
|
||||
transitionDuration: `${TRANSITIONS.DURATION}s`,
|
||||
transitionTimingFunction: `cubic-bezier(${TRANSITIONS.EASE.join(',')})`,
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Flip the shared open state to false; every close side effect (emitClose, the
|
||||
// snap-point reset, update:open) is driven off the `open` transition below, so
|
||||
// this stays the single place that closes — whatever the trigger (drag, handle,
|
||||
// dialog dismissal, or a controlled `v-model:open` flip).
|
||||
function closeDrawer() {
|
||||
if (!drawerRef.value)
|
||||
return;
|
||||
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (!open.value && shouldScaleBackground.value && isClient) {
|
||||
// The component is invisible by the time onAnimationEnd would fire, so use a timeout.
|
||||
const id = setTimeout(() => {
|
||||
resetStyle(document.body);
|
||||
}, 200);
|
||||
|
||||
return () => clearTimeout(id);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
function onRelease(event: PointerEvent) {
|
||||
if (!isDragging.value || !drawerRef.value)
|
||||
return;
|
||||
|
||||
drawerRef.value.classList.remove(DRAG_CLASS);
|
||||
isAllowedToDrag.value = false;
|
||||
isDragging.value = false;
|
||||
dragWrapper = null;
|
||||
dragEndTime.value = new Date();
|
||||
const swipeAmount = getTranslate(drawerRef.value, isVertical(direction.value) ? 'y' : 'x');
|
||||
|
||||
if (!shouldDrag(event.target, false) || !swipeAmount || Number.isNaN(swipeAmount))
|
||||
return;
|
||||
|
||||
if (dragStartTime.value === null)
|
||||
return;
|
||||
|
||||
const timeTaken = dragEndTime.value.getTime() - dragStartTime.value.getTime();
|
||||
const distMoved = pointerStart.value - (isVertical(direction.value) ? event.clientY : event.clientX);
|
||||
const velocity = Math.abs(distMoved) / timeTaken;
|
||||
|
||||
if (velocity > 0.05) {
|
||||
// Prevents the drawer from focusing an input as the drag ends.
|
||||
justReleased.value = true;
|
||||
|
||||
globalThis.setTimeout(() => {
|
||||
justReleased.value = false;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
if (snapPoints.value) {
|
||||
const directionMultiplier = direction.value === 'bottom' || direction.value === 'right' ? 1 : -1;
|
||||
|
||||
onReleaseSnapPoints({
|
||||
draggedDistance: distMoved * directionMultiplier,
|
||||
closeDrawer,
|
||||
velocity,
|
||||
dismissible: dismissible.value,
|
||||
});
|
||||
emitRelease(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Moved in the open direction → settle back.
|
||||
if (direction.value === 'bottom' || direction.value === 'right' ? distMoved > 0 : distMoved < 0) {
|
||||
resetDrawer();
|
||||
emitRelease(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (velocity > VELOCITY_THRESHOLD) {
|
||||
closeDrawer();
|
||||
emitRelease(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleDrawerHeight = Math.min(drawerRef.value.getBoundingClientRect().height ?? 0, window.innerHeight);
|
||||
|
||||
if (swipeAmount >= visibleDrawerHeight * closeThreshold.value) {
|
||||
closeDrawer();
|
||||
emitRelease(false);
|
||||
return;
|
||||
}
|
||||
|
||||
emitRelease(true);
|
||||
resetDrawer();
|
||||
}
|
||||
|
||||
// Single owner of open/close side effects. Reacts to every source that writes
|
||||
// the shared `open` ref: the drag/handle paths (closeDrawer), the dialog's
|
||||
// dismissals (DrawerRoot.handleOpenChange), and a controlled `v-model:open`
|
||||
// flip (DrawerRoot's prop watch). `update:open`/`animationEnd` are emitted by
|
||||
// DrawerRoot's own watch on the same ref.
|
||||
watch(open, (o) => {
|
||||
if (o) {
|
||||
openTime.value = new Date();
|
||||
hasBeenOpened.value = true;
|
||||
}
|
||||
else {
|
||||
emitClose();
|
||||
globalThis.setTimeout(() => {
|
||||
if (snapPoints.value)
|
||||
activeSnapPoint.value = snapPoints.value[0];
|
||||
}, TRANSITIONS.DURATION * 1000);
|
||||
}
|
||||
});
|
||||
|
||||
function onNestedOpenChange(o: boolean) {
|
||||
const scale = o ? (window.innerWidth - NESTED_DISPLACEMENT) / window.innerWidth : 1;
|
||||
const y = o ? -NESTED_DISPLACEMENT : 0;
|
||||
|
||||
if (nestedOpenChangeTimer.value)
|
||||
globalThis.clearTimeout(nestedOpenChangeTimer.value);
|
||||
|
||||
setStyle(drawerRef.value, {
|
||||
transition: `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`,
|
||||
transform: `scale(${scale}) translate3d(0, ${y}px, 0)`,
|
||||
});
|
||||
|
||||
if (!o && drawerRef.value) {
|
||||
nestedOpenChangeTimer.value = globalThis.setTimeout(() => {
|
||||
const translateValue = getTranslate(drawerRef.value!, isVertical(direction.value) ? 'y' : 'x');
|
||||
setStyle(drawerRef.value, {
|
||||
transition: 'none',
|
||||
transform: isVertical(direction.value)
|
||||
? `translate3d(0, ${translateValue}px, 0)`
|
||||
: `translate3d(${translateValue}px, 0, 0)`,
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
function onNestedDrag(percentageDragged: number) {
|
||||
if (percentageDragged < 0)
|
||||
return;
|
||||
|
||||
const initialDim = isVertical(direction.value) ? window.innerHeight : window.innerWidth;
|
||||
const initialScale = (initialDim - NESTED_DISPLACEMENT) / initialDim;
|
||||
const newScale = initialScale + percentageDragged * (1 - initialScale);
|
||||
const newTranslate = -NESTED_DISPLACEMENT + percentageDragged * NESTED_DISPLACEMENT;
|
||||
|
||||
setStyle(drawerRef.value, {
|
||||
transform: isVertical(direction.value)
|
||||
? `scale(${newScale}) translate3d(0, ${newTranslate}px, 0)`
|
||||
: `scale(${newScale}) translate3d(${newTranslate}px, 0, 0)`,
|
||||
transition: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
function onNestedRelease(o: boolean) {
|
||||
const dim = isVertical(direction.value) ? window.innerHeight : window.innerWidth;
|
||||
const scale = o ? (dim - NESTED_DISPLACEMENT) / dim : 1;
|
||||
const translate = o ? -NESTED_DISPLACEMENT : 0;
|
||||
|
||||
if (o) {
|
||||
setStyle(drawerRef.value, {
|
||||
transition: `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`,
|
||||
transform: isVertical(direction.value)
|
||||
? `scale(${scale}) translate3d(0, ${translate}px, 0)`
|
||||
: `scale(${scale}) translate3d(${translate}px, 0, 0)`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
isOpen: open,
|
||||
modal,
|
||||
keyboardIsOpen,
|
||||
hasBeenOpened,
|
||||
drawerRef,
|
||||
drawerHeightRef,
|
||||
overlayRef,
|
||||
handleRef,
|
||||
isDragging,
|
||||
dragStartTime,
|
||||
isAllowedToDrag,
|
||||
snapPoints,
|
||||
activeSnapPoint,
|
||||
hasSnapPoints,
|
||||
pointerStart,
|
||||
dismissible,
|
||||
snapPointsOffset,
|
||||
direction,
|
||||
shouldFade,
|
||||
fadeFromIndex,
|
||||
shouldScaleBackground,
|
||||
setBackgroundColorOnScale,
|
||||
onPress,
|
||||
onDrag,
|
||||
onRelease,
|
||||
closeDrawer,
|
||||
onNestedDrag,
|
||||
onNestedRelease,
|
||||
onNestedOpenChange,
|
||||
emitClose,
|
||||
emitDrag,
|
||||
emitRelease,
|
||||
nested,
|
||||
handleOnly,
|
||||
noBodyStyles,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHandle,
|
||||
DrawerOverlay,
|
||||
DrawerPortal,
|
||||
DrawerRoot,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from '@robonen/primitives';
|
||||
|
||||
const open = ref(false);
|
||||
|
||||
const goals = [
|
||||
{ id: 'focus', label: 'Deep focus', detail: '90 min, no notifications' },
|
||||
{ id: 'move', label: 'Move', detail: 'A short walk after lunch' },
|
||||
{ id: 'read', label: 'Read', detail: '20 pages before bed' },
|
||||
];
|
||||
const selected = ref(goals[0]!.id);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-start gap-3 text-fg">
|
||||
<DrawerRoot v-model:open="open">
|
||||
<DrawerTrigger
|
||||
class="inline-flex items-center rounded-md border border-border bg-bg px-3 py-1.5 text-sm font-medium text-fg transition-colors hover:bg-bg-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
Set today's goal
|
||||
</DrawerTrigger>
|
||||
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay class="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm" />
|
||||
<DrawerContent
|
||||
class="fixed inset-x-0 bottom-0 z-50 mt-24 flex flex-col rounded-t-2xl border-t border-border bg-bg-elevated outline-none"
|
||||
>
|
||||
<DrawerHandle class="mt-3 mb-1" />
|
||||
|
||||
<div class="mx-auto w-full max-w-md px-5 pb-8 pt-2">
|
||||
<DrawerTitle class="text-base font-semibold text-fg">
|
||||
Set today's goal
|
||||
</DrawerTitle>
|
||||
<DrawerDescription class="mt-1 text-sm text-fg-muted">
|
||||
Pick one thing to focus on. Drag the handle down to dismiss.
|
||||
</DrawerDescription>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2">
|
||||
<button
|
||||
v-for="goal in goals"
|
||||
:key="goal.id"
|
||||
type="button"
|
||||
class="flex items-center justify-between rounded-lg border px-3 py-2.5 text-left text-sm transition-colors"
|
||||
:class="selected === goal.id
|
||||
? 'border-accent bg-accent/10 text-fg'
|
||||
: 'border-border bg-bg text-fg hover:bg-bg-subtle'"
|
||||
@click="selected = goal.id"
|
||||
>
|
||||
<span>
|
||||
<span class="font-medium">{{ goal.label }}</span>
|
||||
<span class="block text-xs text-fg-muted">{{ goal.detail }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="selected === goal.id"
|
||||
class="inline-flex size-5 items-center justify-center rounded-full bg-accent text-accent-fg"
|
||||
>
|
||||
<svg class="size-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<DrawerClose
|
||||
class="inline-flex items-center rounded-md border border-border bg-bg px-3 py-1.5 text-sm font-medium text-fg transition-colors hover:bg-bg-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
Cancel
|
||||
</DrawerClose>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition-colors hover:bg-accent-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@click="open = false"
|
||||
>
|
||||
Save goal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</DrawerPortal>
|
||||
</DrawerRoot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { DrawerDirection } from './types';
|
||||
|
||||
/**
|
||||
* Whether a direction runs along the vertical axis (`top`/`bottom`) as opposed
|
||||
* to the horizontal axis (`left`/`right`). Used to pick the axis for translation
|
||||
* reads/writes and window dimension.
|
||||
*/
|
||||
export function isVertical(direction: DrawerDirection): boolean {
|
||||
return direction === 'top' || direction === 'bottom';
|
||||
}
|
||||
|
||||
/**
|
||||
* Logarithmic resistance applied when dragging the drawer past its open
|
||||
* position, so it follows the pointer with diminishing returns (rubber-band).
|
||||
*/
|
||||
export function dampenValue(v: number): number {
|
||||
return 8 * (Math.log(v + 1) - 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the app wrapper that the background-scale effect transforms. Consumers
|
||||
* opt in by adding `data-drawer-wrapper` to the element that holds their page
|
||||
* content (sibling to the portalled drawer).
|
||||
*/
|
||||
export function getDrawerWrapper(): HTMLElement | null {
|
||||
return document.querySelector<HTMLElement>('[data-drawer-wrapper]');
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export { default as DrawerRoot } from './DrawerRoot.vue';
|
||||
export { default as DrawerRootNested } from './DrawerRootNested.vue';
|
||||
export { default as DrawerContent } from './DrawerContent.vue';
|
||||
export { default as DrawerOverlay } from './DrawerOverlay.vue';
|
||||
export { default as DrawerHandle } from './DrawerHandle.vue';
|
||||
|
||||
export type { DrawerRootEmits, DrawerRootProps, DrawerHandleProps } from './controls';
|
||||
export type { DrawerContentEmits, DrawerContentProps } from './DrawerContent.vue';
|
||||
export type { DrawerOverlayProps } from './DrawerOverlay.vue';
|
||||
export type { DrawerDirection, SnapPoint } from './types';
|
||||
|
||||
export { injectDrawerRootContext, provideDrawerRootContext } from './context';
|
||||
export type { DrawerRootContext } from './context';
|
||||
|
||||
// Parts with no drawer-specific behaviour reuse Dialog directly, re-exported
|
||||
// under Drawer names so consumers stay within one namespace.
|
||||
export {
|
||||
DialogClose as DrawerClose,
|
||||
DialogDescription as DrawerDescription,
|
||||
DialogPortal as DrawerPortal,
|
||||
DialogTitle as DrawerTitle,
|
||||
DialogTrigger as DrawerTrigger,
|
||||
} from '../dialog';
|
||||
|
||||
export type {
|
||||
DialogCloseProps as DrawerCloseProps,
|
||||
DialogDescriptionProps as DrawerDescriptionProps,
|
||||
DialogPortalProps as DrawerPortalProps,
|
||||
DialogTitleProps as DrawerTitleProps,
|
||||
DialogTriggerProps as DrawerTriggerProps,
|
||||
} from '../dialog';
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Critical drawer styles — the slide keyframes plus the data-attribute-driven
|
||||
* transforms that the drag engine toggles. Injected once at runtime via
|
||||
* `useStyleTag` from DrawerRoot, so the headless primitive stays self-contained
|
||||
* (no separate CSS file to import). Consumers still bring their own visual
|
||||
* styling (size, colour, padding) on DrawerContent/DrawerOverlay.
|
||||
*
|
||||
* The selectors here mirror the `data-drawer-*` attributes set in the component
|
||||
* templates and {@link ./controls} — keep them in sync.
|
||||
*/
|
||||
export const DRAWER_STYLE_ID = 'robonen-drawer';
|
||||
|
||||
export const DRAWER_STYLES = `
|
||||
[data-drawer] {
|
||||
touch-action: none;
|
||||
will-change: transform;
|
||||
transition: transform 0.5s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
animation-duration: 0.5s;
|
||||
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
[data-drawer][data-drawer-snap-points='false'][data-drawer-direction='bottom'][data-state='open'] {
|
||||
animation-name: slideFromBottom;
|
||||
}
|
||||
[data-drawer][data-drawer-snap-points='false'][data-drawer-direction='bottom'][data-state='closed'] {
|
||||
animation-name: slideToBottom;
|
||||
}
|
||||
|
||||
[data-drawer][data-drawer-snap-points='false'][data-drawer-direction='top'][data-state='open'] {
|
||||
animation-name: slideFromTop;
|
||||
}
|
||||
[data-drawer][data-drawer-snap-points='false'][data-drawer-direction='top'][data-state='closed'] {
|
||||
animation-name: slideToTop;
|
||||
}
|
||||
|
||||
[data-drawer][data-drawer-snap-points='false'][data-drawer-direction='left'][data-state='open'] {
|
||||
animation-name: slideFromLeft;
|
||||
}
|
||||
[data-drawer][data-drawer-snap-points='false'][data-drawer-direction='left'][data-state='closed'] {
|
||||
animation-name: slideToLeft;
|
||||
}
|
||||
|
||||
[data-drawer][data-drawer-snap-points='false'][data-drawer-direction='right'][data-state='open'] {
|
||||
animation-name: slideFromRight;
|
||||
}
|
||||
[data-drawer][data-drawer-snap-points='false'][data-drawer-direction='right'][data-state='closed'] {
|
||||
animation-name: slideToRight;
|
||||
}
|
||||
|
||||
[data-drawer][data-drawer-snap-points='true'][data-drawer-direction='bottom'] {
|
||||
transform: translate3d(0, var(--initial-transform, 100%), 0);
|
||||
}
|
||||
|
||||
[data-drawer][data-drawer-snap-points='true'][data-drawer-direction='top'] {
|
||||
transform: translate3d(0, calc(var(--initial-transform, 100%) * -1), 0);
|
||||
}
|
||||
|
||||
[data-drawer][data-drawer-snap-points='true'][data-drawer-direction='left'] {
|
||||
transform: translate3d(calc(var(--initial-transform, 100%) * -1), 0, 0);
|
||||
}
|
||||
|
||||
[data-drawer][data-drawer-snap-points='true'][data-drawer-direction='right'] {
|
||||
transform: translate3d(var(--initial-transform, 100%), 0, 0);
|
||||
}
|
||||
|
||||
[data-drawer][data-drawer-delayed-snap-points='true'][data-drawer-direction='top'] {
|
||||
transform: translate3d(0, var(--snap-point-height, 0), 0);
|
||||
}
|
||||
|
||||
[data-drawer][data-drawer-delayed-snap-points='true'][data-drawer-direction='bottom'] {
|
||||
transform: translate3d(0, var(--snap-point-height, 0), 0);
|
||||
}
|
||||
|
||||
[data-drawer][data-drawer-delayed-snap-points='true'][data-drawer-direction='left'] {
|
||||
transform: translate3d(var(--snap-point-height, 0), 0, 0);
|
||||
}
|
||||
|
||||
[data-drawer][data-drawer-delayed-snap-points='true'][data-drawer-direction='right'] {
|
||||
transform: translate3d(var(--snap-point-height, 0), 0, 0);
|
||||
}
|
||||
|
||||
[data-drawer-overlay][data-drawer-snap-points='false'] {
|
||||
animation-duration: 0.5s;
|
||||
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
[data-drawer-overlay][data-drawer-snap-points='false'][data-state='open'] {
|
||||
animation-name: fadeIn;
|
||||
}
|
||||
[data-drawer-overlay][data-state='closed'] {
|
||||
animation-name: fadeOut;
|
||||
}
|
||||
|
||||
[data-drawer-animate='false'] {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
[data-drawer-overlay][data-drawer-snap-points='true'] {
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
[data-drawer-overlay][data-drawer-snap-points='true'] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-drawer]:not([data-drawer-custom-container='true'])::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: inherit;
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
[data-drawer][data-drawer-direction='top']::after {
|
||||
top: initial;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 200%;
|
||||
}
|
||||
|
||||
[data-drawer][data-drawer-direction='bottom']::after {
|
||||
top: 100%;
|
||||
bottom: initial;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 200%;
|
||||
}
|
||||
|
||||
[data-drawer][data-drawer-direction='left']::after {
|
||||
left: initial;
|
||||
right: 100%;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 200%;
|
||||
}
|
||||
|
||||
[data-drawer][data-drawer-direction='right']::after {
|
||||
left: 100%;
|
||||
right: initial;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 200%;
|
||||
}
|
||||
|
||||
[data-drawer-overlay][data-drawer-snap-points='true']:not([data-drawer-snap-points-overlay='true']):not(
|
||||
[data-state='closed']
|
||||
) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
[data-drawer-overlay][data-drawer-snap-points-overlay='true'] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-drawer-handle] {
|
||||
display: block;
|
||||
position: relative;
|
||||
opacity: 0.7;
|
||||
background: #e2e2e4;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
height: 5px;
|
||||
width: 32px;
|
||||
border-radius: 1rem;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
[data-drawer-handle]:hover,
|
||||
[data-drawer-handle]:active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-drawer-handle-hitarea] {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: max(100%, 2.75rem); /* 44px */
|
||||
height: max(100%, 2.75rem); /* 44px */
|
||||
touch-action: inherit;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
[data-drawer] {
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer: fine) {
|
||||
[data-drawer-handle-hitarea] {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideFromBottom {
|
||||
from {
|
||||
transform: translate3d(0, var(--initial-transform, 100%), 0);
|
||||
}
|
||||
to {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideToBottom {
|
||||
to {
|
||||
transform: translate3d(0, var(--initial-transform, 100%), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideFromTop {
|
||||
from {
|
||||
transform: translate3d(0, calc(var(--initial-transform, 100%) * -1), 0);
|
||||
}
|
||||
to {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideToTop {
|
||||
to {
|
||||
transform: translate3d(0, calc(var(--initial-transform, 100%) * -1), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideFromLeft {
|
||||
from {
|
||||
transform: translate3d(calc(var(--initial-transform, 100%) * -1), 0, 0);
|
||||
}
|
||||
to {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideToLeft {
|
||||
to {
|
||||
transform: translate3d(calc(var(--initial-transform, 100%) * -1), 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideFromRight {
|
||||
from {
|
||||
transform: translate3d(var(--initial-transform, 100%), 0, 0);
|
||||
}
|
||||
to {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideToRight {
|
||||
to {
|
||||
transform: translate3d(var(--initial-transform, 100%), 0, 0);
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* The edge the drawer is anchored to and slides in from.
|
||||
*/
|
||||
export type DrawerDirection = 'top' | 'bottom' | 'left' | 'right';
|
||||
|
||||
/**
|
||||
* A resolved snap point: the original `fraction` (0–1 of the screen, or a raw
|
||||
* px value) paired with its computed pixel `height`.
|
||||
*/
|
||||
export interface SnapPoint {
|
||||
fraction: number;
|
||||
height: number;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { Ref } from 'vue';
|
||||
import { ref, watch } from 'vue';
|
||||
import { isSafari } from '@robonen/platform/browsers';
|
||||
import { useEventListener, useMediaQuery } from '@robonen/vue';
|
||||
|
||||
interface BodyPosition {
|
||||
position: string;
|
||||
top: string;
|
||||
left: string;
|
||||
height: string;
|
||||
}
|
||||
|
||||
interface PositionFixedOptions {
|
||||
isOpen: Ref<boolean>;
|
||||
modal: Ref<boolean>;
|
||||
nested: Ref<boolean>;
|
||||
hasBeenOpened: Ref<boolean>;
|
||||
preventScrollRestoration: Ref<boolean>;
|
||||
noBodyStyles: Ref<boolean>;
|
||||
}
|
||||
|
||||
// Module-level so a single restoration survives across nested drawers that each
|
||||
// run this composable — only the outermost open/close should touch the body.
|
||||
let previousBodyPosition: BodyPosition | null = null;
|
||||
|
||||
/**
|
||||
* Pins `document.body` with `position: fixed` while the drawer is open on iOS
|
||||
* Safari, where the address bar otherwise causes a jarring viewport shift. A
|
||||
* no-op on every other browser. Restores the scroll position on close.
|
||||
*/
|
||||
export function usePositionFixed(options: PositionFixedOptions) {
|
||||
const { isOpen, modal, nested, hasBeenOpened, preventScrollRestoration, noBodyStyles } = options;
|
||||
const activeUrl = ref(globalThis.window !== undefined ? globalThis.location.href : '');
|
||||
const scrollPos = ref(0);
|
||||
// Standalone PWAs have no address bar to fight (SSR-safe via useMediaQuery).
|
||||
const isStandalone = useMediaQuery('(display-mode: standalone)');
|
||||
|
||||
function setPositionFixed(): void {
|
||||
if (!isSafari())
|
||||
return;
|
||||
|
||||
if (previousBodyPosition === null && isOpen.value && !noBodyStyles.value) {
|
||||
previousBodyPosition = {
|
||||
position: document.body.style.position,
|
||||
top: document.body.style.top,
|
||||
left: document.body.style.left,
|
||||
height: document.body.style.height,
|
||||
};
|
||||
|
||||
const { scrollX, innerHeight } = globalThis;
|
||||
|
||||
document.body.style.setProperty('position', 'fixed', 'important');
|
||||
Object.assign(document.body.style, {
|
||||
top: `${-scrollPos.value}px`,
|
||||
left: `${-scrollX}px`,
|
||||
right: '0px',
|
||||
height: 'auto',
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
requestAnimationFrame(() => {
|
||||
// If a bottom bar appeared after pinning, nudge the content up so it
|
||||
// isn't hidden behind it.
|
||||
const bottomBarHeight = innerHeight - window.innerHeight;
|
||||
if (bottomBarHeight && scrollPos.value >= innerHeight)
|
||||
document.body.style.top = `-${scrollPos.value + bottomBarHeight}px`;
|
||||
});
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
function restorePositionSetting(): void {
|
||||
if (!isSafari())
|
||||
return;
|
||||
|
||||
if (previousBodyPosition !== null && !noBodyStyles.value) {
|
||||
const y = -Number.parseInt(document.body.style.top, 10);
|
||||
const x = -Number.parseInt(document.body.style.left, 10);
|
||||
|
||||
Object.assign(document.body.style, previousBodyPosition);
|
||||
|
||||
globalThis.requestAnimationFrame(() => {
|
||||
if (preventScrollRestoration.value && activeUrl.value !== globalThis.location.href) {
|
||||
activeUrl.value = globalThis.location.href;
|
||||
return;
|
||||
}
|
||||
|
||||
window.scrollTo(x, y);
|
||||
});
|
||||
|
||||
previousBodyPosition = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
scrollPos.value = window.scrollY;
|
||||
}
|
||||
|
||||
// Capture the position at setup (the page may already be scrolled), then keep
|
||||
// it in sync. `useEventListener` defaults to `defaultWindow` and auto-removes.
|
||||
if (globalThis.window !== undefined)
|
||||
onScroll();
|
||||
useEventListener('scroll', onScroll, { passive: true });
|
||||
|
||||
// `activeUrl` is read inside restorePositionSetting's rAF to detect navigation,
|
||||
// but it must NOT drive this watch — it mutates on close and would re-fire the
|
||||
// (idempotent) handler once for nothing.
|
||||
watch([isOpen, hasBeenOpened], () => {
|
||||
if (nested.value || !hasBeenOpened.value)
|
||||
return;
|
||||
|
||||
if (isOpen.value) {
|
||||
if (!isStandalone.value)
|
||||
setPositionFixed();
|
||||
|
||||
if (!modal.value) {
|
||||
setTimeout(() => {
|
||||
restorePositionSetting();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
else {
|
||||
restorePositionSetting();
|
||||
}
|
||||
});
|
||||
|
||||
return { restorePositionSetting };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { onWatcherCleanup, ref, watchEffect } from 'vue';
|
||||
import { isClient } from '@robonen/platform/multi';
|
||||
import { assignStyle } from '@robonen/platform/browsers';
|
||||
import { injectDrawerRootContext } from './context';
|
||||
import { getDrawerWrapper, isVertical } from './helpers';
|
||||
import { BORDER_RADIUS, TRANSITIONS, WINDOW_TOP_OFFSET } from './constants';
|
||||
|
||||
/**
|
||||
* Scales the page background down behind the drawer (the stacked-card effect),
|
||||
* transforming the element marked `data-drawer-wrapper`. Restores everything,
|
||||
* including the body background, when the drawer closes. No-op unless
|
||||
* `shouldScaleBackground` is enabled on the root.
|
||||
*/
|
||||
export function useScaleBackground() {
|
||||
const { direction, isOpen, shouldScaleBackground, setBackgroundColorOnScale, noBodyStyles } = injectDrawerRootContext();
|
||||
const timeoutIdRef = ref<number | null>(null);
|
||||
const initialBackgroundColor = ref(typeof document !== 'undefined' ? document.body.style.backgroundColor : '');
|
||||
|
||||
function getScale() {
|
||||
return (window.innerWidth - WINDOW_TOP_OFFSET) / window.innerWidth;
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
// `flush: 'pre'` watchers run during SSR; this effect touches document/window,
|
||||
// so it must stay client-only.
|
||||
if (isClient && isOpen.value && shouldScaleBackground.value) {
|
||||
if (timeoutIdRef.value)
|
||||
clearTimeout(timeoutIdRef.value);
|
||||
|
||||
const wrapper = getDrawerWrapper();
|
||||
|
||||
if (!wrapper)
|
||||
return;
|
||||
|
||||
if (setBackgroundColorOnScale.value && !noBodyStyles.value)
|
||||
assignStyle(document.body, { background: 'black' });
|
||||
|
||||
assignStyle(wrapper, {
|
||||
transformOrigin: isVertical(direction.value) ? 'top' : 'left',
|
||||
transitionProperty: 'transform, border-radius',
|
||||
transitionDuration: `${TRANSITIONS.DURATION}s`,
|
||||
transitionTimingFunction: `cubic-bezier(${TRANSITIONS.EASE.join(',')})`,
|
||||
});
|
||||
|
||||
const wrapperStylesCleanup = assignStyle(wrapper, {
|
||||
borderRadius: `${BORDER_RADIUS}px`,
|
||||
overflow: 'hidden',
|
||||
...(isVertical(direction.value)
|
||||
? { transform: `scale(${getScale()}) translate3d(0, calc(env(safe-area-inset-top) + 14px), 0)` }
|
||||
: { transform: `scale(${getScale()}) translate3d(calc(env(safe-area-inset-top) + 14px), 0, 0)` }),
|
||||
});
|
||||
|
||||
onWatcherCleanup(() => {
|
||||
wrapperStylesCleanup();
|
||||
timeoutIdRef.value = globalThis.setTimeout(() => {
|
||||
if (initialBackgroundColor.value)
|
||||
document.body.style.background = initialBackgroundColor.value;
|
||||
else
|
||||
document.body.style.removeProperty('background');
|
||||
}, TRANSITIONS.DURATION * 1000);
|
||||
});
|
||||
}
|
||||
}, { flush: 'pre' });
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import type { Ref } from 'vue';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { setStyle } from '@robonen/platform/browsers';
|
||||
import { useEventListener } from '@robonen/vue';
|
||||
import { isVertical } from './helpers';
|
||||
import { TRANSITIONS, VELOCITY_THRESHOLD } from './constants';
|
||||
import type { DrawerDirection } from './types';
|
||||
|
||||
interface UseSnapPointsProps {
|
||||
activeSnapPoint: Ref<number | string | null | undefined>;
|
||||
snapPoints: Ref<Array<number | string> | undefined>;
|
||||
fadeFromIndex: Ref<number | undefined>;
|
||||
drawerRef: Ref<HTMLElement | undefined>;
|
||||
overlayRef: Ref<HTMLElement | undefined>;
|
||||
onSnapPointChange: (activeSnapPointIndex: number, snapPointsOffset: number[]) => void;
|
||||
direction: Ref<DrawerDirection>;
|
||||
}
|
||||
|
||||
const transition = (property: 'transform' | 'opacity') =>
|
||||
`${property} ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
|
||||
|
||||
/**
|
||||
* Drag/release maths for drawers configured with snap points: resolves each
|
||||
* snap point to a pixel offset, animates the drawer between them, and decides
|
||||
* which point to settle on (or whether to close) based on drag distance and
|
||||
* velocity.
|
||||
*/
|
||||
export function useSnapPoints({
|
||||
activeSnapPoint,
|
||||
snapPoints,
|
||||
drawerRef,
|
||||
overlayRef,
|
||||
fadeFromIndex,
|
||||
onSnapPointChange,
|
||||
direction,
|
||||
}: UseSnapPointsProps) {
|
||||
const windowDimensions = ref(globalThis.window !== undefined
|
||||
? { innerWidth: window.innerWidth, innerHeight: window.innerHeight }
|
||||
: undefined);
|
||||
|
||||
function onResize() {
|
||||
const innerWidth = window.innerWidth;
|
||||
const innerHeight = window.innerHeight;
|
||||
const cur = windowDimensions.value;
|
||||
// Skip the ref write (and the snapPointsOffset recompute it would trigger)
|
||||
// when dimensions are unchanged — some resize events report identical sizes.
|
||||
if (!cur || cur.innerWidth !== innerWidth || cur.innerHeight !== innerHeight)
|
||||
windowDimensions.value = { innerWidth, innerHeight };
|
||||
}
|
||||
|
||||
// Defaults to `defaultWindow` (SSR-safe) and auto-removes on scope dispose.
|
||||
useEventListener('resize', onResize);
|
||||
|
||||
const isLastSnapPoint = computed(
|
||||
() => (snapPoints.value && activeSnapPoint.value === snapPoints.value[snapPoints.value.length - 1]) ?? null,
|
||||
);
|
||||
|
||||
const shouldFade = computed(
|
||||
() =>
|
||||
(snapPoints.value
|
||||
&& snapPoints.value.length > 0
|
||||
&& (fadeFromIndex?.value || fadeFromIndex?.value === 0)
|
||||
&& !Number.isNaN(fadeFromIndex?.value)
|
||||
&& snapPoints.value[fadeFromIndex?.value ?? -1] === activeSnapPoint.value)
|
||||
|| !snapPoints.value,
|
||||
);
|
||||
|
||||
const activeSnapPointIndex = computed(
|
||||
() => snapPoints.value?.indexOf(activeSnapPoint.value) ?? null,
|
||||
);
|
||||
|
||||
const snapPointsOffset = computed(
|
||||
() =>
|
||||
snapPoints.value?.map((snapPoint) => {
|
||||
const isPx = typeof snapPoint === 'string';
|
||||
let snapPointAsNumber = 0;
|
||||
|
||||
if (isPx)
|
||||
snapPointAsNumber = Number.parseInt(snapPoint, 10);
|
||||
|
||||
if (isVertical(direction.value)) {
|
||||
const height = isPx
|
||||
? snapPointAsNumber
|
||||
: windowDimensions.value
|
||||
? (snapPoint as number) * windowDimensions.value.innerHeight
|
||||
: 0;
|
||||
|
||||
if (windowDimensions.value)
|
||||
return direction.value === 'bottom' ? windowDimensions.value.innerHeight - height : -windowDimensions.value.innerHeight + height;
|
||||
|
||||
return height;
|
||||
}
|
||||
|
||||
const width = isPx
|
||||
? snapPointAsNumber
|
||||
: windowDimensions.value
|
||||
? (snapPoint as number) * windowDimensions.value.innerWidth
|
||||
: 0;
|
||||
|
||||
if (windowDimensions.value)
|
||||
return direction.value === 'right' ? windowDimensions.value.innerWidth - width : -windowDimensions.value.innerWidth + width;
|
||||
|
||||
return width;
|
||||
}) ?? [],
|
||||
);
|
||||
|
||||
const activeSnapPointOffset = computed(() =>
|
||||
activeSnapPointIndex.value !== null ? snapPointsOffset.value?.[activeSnapPointIndex.value] : null,
|
||||
);
|
||||
|
||||
function snapToPoint(dimension: number) {
|
||||
const newSnapPointIndex = snapPointsOffset.value?.indexOf(dimension) ?? null;
|
||||
|
||||
// Wait for the element to be mounted before transforming it.
|
||||
nextTick(() => {
|
||||
onSnapPointChange(newSnapPointIndex, snapPointsOffset.value);
|
||||
setStyle(drawerRef.value, {
|
||||
transition: transition('transform'),
|
||||
transform: isVertical(direction.value) ? `translate3d(0, ${dimension}px, 0)` : `translate3d(${dimension}px, 0, 0)`,
|
||||
});
|
||||
});
|
||||
|
||||
if (
|
||||
snapPointsOffset.value
|
||||
&& newSnapPointIndex !== snapPointsOffset.value.length - 1
|
||||
&& newSnapPointIndex !== fadeFromIndex?.value
|
||||
) {
|
||||
setStyle(overlayRef.value, { transition: transition('opacity'), opacity: '0' });
|
||||
}
|
||||
else {
|
||||
setStyle(overlayRef.value, { transition: transition('opacity'), opacity: '1' });
|
||||
}
|
||||
|
||||
activeSnapPoint.value = newSnapPointIndex !== null ? snapPoints.value?.[newSnapPointIndex] ?? null : null;
|
||||
}
|
||||
|
||||
watch(
|
||||
[activeSnapPoint, snapPointsOffset, snapPoints],
|
||||
() => {
|
||||
if (activeSnapPoint.value) {
|
||||
const newIndex = snapPoints.value?.indexOf(activeSnapPoint.value) ?? -1;
|
||||
|
||||
if (snapPointsOffset.value && newIndex !== -1 && typeof snapPointsOffset.value[newIndex] === 'number')
|
||||
snapToPoint(snapPointsOffset.value[newIndex]);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function onRelease({
|
||||
draggedDistance,
|
||||
closeDrawer,
|
||||
velocity,
|
||||
dismissible,
|
||||
}: {
|
||||
draggedDistance: number;
|
||||
closeDrawer: () => void;
|
||||
velocity: number;
|
||||
dismissible: boolean;
|
||||
}) {
|
||||
if (fadeFromIndex.value === undefined)
|
||||
return;
|
||||
|
||||
const currentPosition
|
||||
= direction.value === 'bottom' || direction.value === 'right'
|
||||
? (activeSnapPointOffset.value ?? 0) - draggedDistance
|
||||
: (activeSnapPointOffset.value ?? 0) + draggedDistance;
|
||||
const isOverlaySnapPoint = activeSnapPointIndex.value === fadeFromIndex.value - 1;
|
||||
const isFirst = activeSnapPointIndex.value === 0;
|
||||
const hasDraggedUp = draggedDistance > 0;
|
||||
|
||||
if (isOverlaySnapPoint)
|
||||
setStyle(overlayRef.value, { transition: transition('opacity') });
|
||||
|
||||
if (velocity > 2 && !hasDraggedUp) {
|
||||
if (dismissible)
|
||||
closeDrawer();
|
||||
else
|
||||
snapToPoint(snapPointsOffset.value[0]); // snap to initial point
|
||||
return;
|
||||
}
|
||||
|
||||
if (velocity > 2 && hasDraggedUp && snapPointsOffset.value && snapPoints.value) {
|
||||
snapToPoint(snapPointsOffset.value[snapPoints.value.length - 1]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Settle on the snap point closest to where the drag ended.
|
||||
const closestSnapPoint = snapPointsOffset.value?.reduce((prev, curr) => {
|
||||
if (typeof prev !== 'number' || typeof curr !== 'number')
|
||||
return prev;
|
||||
|
||||
return Math.abs(curr - currentPosition) < Math.abs(prev - currentPosition) ? curr : prev;
|
||||
});
|
||||
|
||||
const dim = isVertical(direction.value) ? window.innerHeight : window.innerWidth;
|
||||
if (velocity > VELOCITY_THRESHOLD && Math.abs(draggedDistance) < dim * 0.4) {
|
||||
const dragDirection = hasDraggedUp ? 1 : -1; // 1 = up, -1 = down
|
||||
|
||||
// Ignore an upward flick while already on the last snap point.
|
||||
if (dragDirection > 0 && isLastSnapPoint.value) {
|
||||
snapToPoint(snapPointsOffset.value[(snapPoints.value?.length ?? 0) - 1]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFirst && dragDirection < 0 && dismissible)
|
||||
closeDrawer();
|
||||
|
||||
if (activeSnapPointIndex.value === null)
|
||||
return;
|
||||
|
||||
snapToPoint(snapPointsOffset.value[activeSnapPointIndex.value + dragDirection]);
|
||||
return;
|
||||
}
|
||||
|
||||
snapToPoint(closestSnapPoint);
|
||||
}
|
||||
|
||||
function onDrag({ draggedDistance }: { draggedDistance: number }) {
|
||||
if (activeSnapPointOffset.value === null)
|
||||
return;
|
||||
|
||||
const newValue
|
||||
= direction.value === 'bottom' || direction.value === 'right'
|
||||
? (activeSnapPointOffset.value ?? 0) - draggedDistance
|
||||
: (activeSnapPointOffset.value ?? 0) + draggedDistance;
|
||||
|
||||
// Don't drag past the last (largest) snap point.
|
||||
if ((direction.value === 'bottom' || direction.value === 'right') && newValue < snapPointsOffset.value[snapPointsOffset.value.length - 1])
|
||||
return;
|
||||
|
||||
if ((direction.value === 'top' || direction.value === 'left') && newValue > snapPointsOffset.value[snapPointsOffset.value.length - 1])
|
||||
return;
|
||||
|
||||
setStyle(drawerRef.value, {
|
||||
transform: isVertical(direction.value) ? `translate3d(0, ${newValue}px, 0)` : `translate3d(${newValue}px, 0, 0)`,
|
||||
});
|
||||
}
|
||||
|
||||
function getPercentageDragged(absDraggedDistance: number, isDraggingDown: boolean) {
|
||||
if (
|
||||
!snapPoints.value
|
||||
|| typeof activeSnapPointIndex.value !== 'number'
|
||||
|| !snapPointsOffset.value
|
||||
|| fadeFromIndex.value === undefined
|
||||
)
|
||||
return null;
|
||||
|
||||
// Whether we're dragging toward a snap point that should show the overlay.
|
||||
const isOverlaySnapPoint = activeSnapPointIndex.value === fadeFromIndex.value - 1;
|
||||
const isOverlaySnapPointOrHigher = activeSnapPointIndex.value >= fadeFromIndex.value;
|
||||
|
||||
if (isOverlaySnapPointOrHigher && isDraggingDown)
|
||||
return 0;
|
||||
|
||||
// Don't animate, but still use this one when dragging away from the overlay snap point.
|
||||
if (isOverlaySnapPoint && !isDraggingDown)
|
||||
return 1;
|
||||
if (!shouldFade.value && !isOverlaySnapPoint)
|
||||
return null;
|
||||
|
||||
const targetSnapPointIndex = isOverlaySnapPoint ? activeSnapPointIndex.value + 1 : activeSnapPointIndex.value - 1;
|
||||
|
||||
// Distance between the overlay snap point and its neighbour, used to scale opacity.
|
||||
const snapPointDistance = isOverlaySnapPoint
|
||||
? snapPointsOffset.value[targetSnapPointIndex] - snapPointsOffset.value[targetSnapPointIndex - 1]
|
||||
: snapPointsOffset.value[targetSnapPointIndex + 1] - snapPointsOffset.value[targetSnapPointIndex];
|
||||
|
||||
const percentageDragged = absDraggedDistance / Math.abs(snapPointDistance);
|
||||
|
||||
return isOverlaySnapPoint ? 1 - percentageDragged : percentageDragged;
|
||||
}
|
||||
|
||||
return {
|
||||
isLastSnapPoint,
|
||||
shouldFade,
|
||||
getPercentageDragged,
|
||||
activeSnapPointIndex,
|
||||
onRelease,
|
||||
onDrag,
|
||||
snapPointsOffset,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
BIN
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';
|
||||
Reference in New Issue
Block a user