No CSS shipped. Primitives render the DOM you ask for and expose state via
data attributes, so you bring your own styles — Tailwind, vanilla CSS, anything.
-
-
Accessible out of the box
-
+
+
Accessible out of the box
+
Focus scopes, roving tabindex, visually-hidden labels and correct ARIA roles
are handled for you. The suite is tested against
axe-core in a real browser.
-
-
Controlled or uncontrolled
-
+
+
Controlled or uncontrolled
+
Bind state with v-model when you need control, or set a
defaultValue / defaultOpen and let the primitive
manage itself.
-
-
Composable & polymorphic
-
+
+
Composable & polymorphic
+
Every part takes an as prop, or use as="template"
to merge behavior onto your own element. Floating UI powers positioning for
popovers, tooltips and menus.
diff --git a/vue/primitives/eslint.config.ts b/vue/primitives/eslint.config.ts
index 3f17b52..3298689 100644
--- a/vue/primitives/eslint.config.ts
+++ b/vue/primitives/eslint.config.ts
@@ -1,4 +1,4 @@
-import { base, compose, imports, stylistic, typescript, vue } from '@robonen/eslint';
+import { base, compose, imports, stylistic, tests, typescript, vue } from '@robonen/eslint';
export default compose(base, typescript, vue, imports, stylistic, {
name: 'primitives/overrides',
@@ -6,4 +6,4 @@ export default compose(base, typescript, vue, imports, stylistic, {
rules: {
'@stylistic/no-multiple-empty-lines': 'off',
},
-});
+}, tests);
diff --git a/vue/primitives/playground/src/demos/Accordion.vue b/vue/primitives/playground/src/demos/Accordion.vue
index c5f9156..12075a0 100644
--- a/vue/primitives/playground/src/demos/Accordion.vue
+++ b/vue/primitives/playground/src/demos/Accordion.vue
@@ -5,7 +5,7 @@ import {
AccordionItem,
AccordionRoot,
AccordionTrigger,
-} from '@primitives/accordion';
+} from '@primitives/disclosure/accordion';
const value = ref('a');
const type = ref<'single' | 'multiple'>('single');
diff --git a/vue/primitives/playground/src/demos/Checkbox.vue b/vue/primitives/playground/src/demos/Checkbox.vue
index a7e193e..6fdd066 100644
--- a/vue/primitives/playground/src/demos/Checkbox.vue
+++ b/vue/primitives/playground/src/demos/Checkbox.vue
@@ -1,7 +1,7 @@
-
-
-
-
-
-
-
-
-
-
diff --git a/vue/primitives/src/alert-dialog/AlertDialogCancel.vue b/vue/primitives/src/alert-dialog/AlertDialogCancel.vue
deleted file mode 100644
index 594fa50..0000000
--- a/vue/primitives/src/alert-dialog/AlertDialogCancel.vue
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/vue/primitives/src/alert-dialog/__test__/AlertDialog.test.ts b/vue/primitives/src/alert-dialog/__test__/AlertDialog.test.ts
deleted file mode 100644
index a66c4c7..0000000
--- a/vue/primitives/src/alert-dialog/__test__/AlertDialog.test.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-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> = [];
-
-afterEach(() => {
- while (wrappers.length) wrappers.pop()!.unmount();
- document.body.innerHTML = '';
- document.body.removeAttribute('style');
- delete document.body.dataset['dismissableBlocking'];
-});
-
-function track>(w: T): T {
- wrappers.push(w);
- return w;
-}
-
-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('[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('[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('[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();
- });
-});
diff --git a/vue/primitives/src/aspect-ratio/__test__/AspectRatio.test.ts b/vue/primitives/src/aspect-ratio/__test__/AspectRatio.test.ts
deleted file mode 100644
index 136b94c..0000000
--- a/vue/primitives/src/aspect-ratio/__test__/AspectRatio.test.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { mount } from '@vue/test-utils';
-import { describe, expect, it } from 'vitest';
-import { AspectRatio } from '../index';
-
-describe('AspectRatio', () => {
- it('renders with default 1:1 ratio', () => {
- const wrapper = mount(AspectRatio);
- const outer = wrapper.element as HTMLElement;
- expect(outer.style.paddingBottom).toBe('100%');
- });
-
- it('computes padding-bottom from ratio', () => {
- const wrapper = mount(AspectRatio, { props: { ratio: 16 / 9 } });
- const outer = wrapper.element as HTMLElement;
- expect(outer.style.paddingBottom).toMatch(/^56\.25%$/);
- });
-
- it('updates padding-bottom when ratio prop changes', async () => {
- const wrapper = mount(AspectRatio, { props: { ratio: 16 / 9 } });
- const outer = wrapper.element as HTMLElement;
- expect(outer.style.paddingBottom).toBe('56.25%');
-
- await wrapper.setProps({ ratio: 1 });
- expect(outer.style.paddingBottom).toBe('100%');
-
- await wrapper.setProps({ ratio: 4 / 3 });
- expect(outer.style.paddingBottom).toBe('75%');
- });
-
- it('places inner element absolutely covering the wrapper', () => {
- const wrapper = mount(AspectRatio, { props: { ratio: 4 / 3 }, slots: { default: '' } });
- const inner = wrapper.element.firstElementChild as HTMLElement;
- expect(inner.style.position).toBe('absolute');
- expect(inner.getAttribute('data-aspect-ratio')).toBe('true');
- });
-});
diff --git a/vue/primitives/src/avatar/AvatarImage.vue b/vue/primitives/src/avatar/AvatarImage.vue
deleted file mode 100644
index 83e5564..0000000
--- a/vue/primitives/src/avatar/AvatarImage.vue
+++ /dev/null
@@ -1,89 +0,0 @@
-
-
-
-
-
-
-
diff --git a/vue/primitives/src/avatar/__test__/Avatar.test.ts b/vue/primitives/src/avatar/__test__/Avatar.test.ts
deleted file mode 100644
index f2a15a8..0000000
--- a/vue/primitives/src/avatar/__test__/Avatar.test.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import { mount } from '@vue/test-utils';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { defineComponent, h, nextTick } from 'vue';
-import { AvatarFallback, AvatarImage, AvatarRoot } from '../index';
-
-class MockImage {
- onload: (() => void) | null = null;
- onerror: (() => void) | null = null;
- private _src = '';
- set src(value: string) {
- this._src = value;
- queueMicrotask(() => {
- if (value.includes('broken')) this.onerror?.();
- else this.onload?.();
- });
- }
-
- get src() { return this._src; }
-}
-
-describe('Avatar', () => {
- beforeEach(() => {
- vi.stubGlobal('Image', MockImage as unknown as typeof Image);
- });
- afterEach(() => {
- vi.unstubAllGlobals();
- });
-
- it('renders fallback until image loads', async () => {
- const w = mount(defineComponent({
- setup: () => () => h(AvatarRoot, null, {
- default: () => [
- h(AvatarImage, { src: '/ok.png', alt: 'user' }),
- h(AvatarFallback, { class: 'fb' }, { default: () => 'AB' }),
- ],
- }),
- }));
- expect(w.find('.fb').exists()).toBe(true);
- expect(w.find('img').exists()).toBe(false);
- await new Promise(r => queueMicrotask(() => r(null)));
- await nextTick();
- expect(w.find('img').exists()).toBe(true);
- expect(w.find('img').attributes('src')).toBe('/ok.png');
- expect(w.find('.fb').exists()).toBe(false);
- });
-
- it('keeps fallback visible on error', async () => {
- const w = mount(defineComponent({
- setup: () => () => h(AvatarRoot, null, {
- default: () => [
- h(AvatarImage, { src: '/broken.png' }),
- h(AvatarFallback, { class: 'fb' }, { default: () => 'AB' }),
- ],
- }),
- }));
- await new Promise(r => queueMicrotask(() => r(null)));
- await nextTick();
- expect(w.find('img').exists()).toBe(false);
- expect(w.find('.fb').exists()).toBe(true);
- });
-
- it('delays fallback rendering when delayMs is set', async () => {
- vi.useFakeTimers();
- const w = mount(defineComponent({
- setup: () => () => h(AvatarRoot, null, {
- default: () => [
- h(AvatarFallback, { class: 'fb', delayMs: 500 }, { default: () => 'AB' }),
- ],
- }),
- }));
- expect(w.find('.fb').exists()).toBe(false);
- vi.advanceTimersByTime(500);
- await nextTick();
- expect(w.find('.fb').exists()).toBe(true);
- vi.useRealTimers();
- });
-
- it('sets data-status on the root element', async () => {
- const w = mount(defineComponent({
- setup: () => () => h(AvatarRoot, null, {
- default: () => [
- h(AvatarImage, { src: '/ok.png' }),
- h(AvatarFallback, null, { default: () => '?' }),
- ],
- }),
- }));
- await nextTick();
- expect(w.element.getAttribute('data-status')).toBe('loading');
- await new Promise(r => queueMicrotask(() => r(null)));
- await nextTick();
- expect(w.element.getAttribute('data-status')).toBe('loaded');
- });
-});
diff --git a/vue/primitives/src/calendar/CalendarCellTrigger.vue b/vue/primitives/src/calendar/CalendarCellTrigger.vue
deleted file mode 100644
index 2c20ea3..0000000
--- a/vue/primitives/src/calendar/CalendarCellTrigger.vue
+++ /dev/null
@@ -1,202 +0,0 @@
-
-
-
-
-
-
-
- {{ dayValue }}
-
-
-
diff --git a/vue/primitives/src/calendar/CalendarGridHead.vue b/vue/primitives/src/calendar/CalendarGridHead.vue
deleted file mode 100644
index ce273f7..0000000
--- a/vue/primitives/src/calendar/CalendarGridHead.vue
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/vue/primitives/src/canvas/angle-dial/AngleDialRoot.vue b/vue/primitives/src/canvas/angle-dial/AngleDialRoot.vue
new file mode 100644
index 0000000..0d22f5f
--- /dev/null
+++ b/vue/primitives/src/canvas/angle-dial/AngleDialRoot.vue
@@ -0,0 +1,335 @@
+
+
+
+
+
+
+
+
+
diff --git a/vue/primitives/src/canvas/angle-dial/AngleDialThumb.vue b/vue/primitives/src/canvas/angle-dial/AngleDialThumb.vue
new file mode 100644
index 0000000..b697b6a
--- /dev/null
+++ b/vue/primitives/src/canvas/angle-dial/AngleDialThumb.vue
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+
+
+
diff --git a/vue/primitives/src/canvas/angle-dial/__test__/AngleDial.test.ts b/vue/primitives/src/canvas/angle-dial/__test__/AngleDial.test.ts
new file mode 100644
index 0000000..4142761
--- /dev/null
+++ b/vue/primitives/src/canvas/angle-dial/__test__/AngleDial.test.ts
@@ -0,0 +1,423 @@
+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 { AngleDialRoot, AngleDialThumb } from '../index';
+import {
+ angleToHue,
+ angleToPoint,
+ circularDistance,
+ normalizeDeg,
+ pointToAngle,
+ shortestDelta,
+} from '../utils';
+
+const wrappers: Array> = [];
+
+afterEach(() => {
+ while (wrappers.length) wrappers.pop()!.unmount();
+ document.body.innerHTML = '';
+});
+
+function track>(w: T): T {
+ wrappers.push(w);
+ return w;
+}
+
+type RootOpts = Partial<{
+ min: number;
+ max: number;
+ step: number;
+ largeStep: number;
+ wrap: 'wrap' | 'clamp';
+ snap: number | number[];
+ disabled: boolean;
+ defaultValue: number;
+}>;
+
+function mountDial(opts: RootOpts = {}, thumbProps: Record = {}) {
+ const model = ref(undefined);
+ const Harness = defineComponent({
+ setup() {
+ return () => h(AngleDialRoot, {
+ value: model.value,
+ 'onUpdate:value': (v: number | null | undefined) => { model.value = v ?? undefined; },
+ ...opts,
+ }, {
+ default: () => h(AngleDialThumb, thumbProps),
+ });
+ },
+ });
+ const w = track(mount(Harness, { attachTo: document.body }));
+ return { wrapper: w, model };
+}
+
+function keydown(el: Element, key: string, opts: { shiftKey?: boolean } = {}): void {
+ const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, shiftKey: opts.shiftKey ?? false });
+ el.dispatchEvent(event);
+}
+
+function thumbEl(): HTMLElement {
+ return document.querySelector('[role="slider"]')!;
+}
+
+describe('AngleDialThumb — ARIA', () => {
+ it('role="slider" with aria-valuemin/max/now and NO aria-orientation', async () => {
+ mountDial({ defaultValue: 45, min: 0, max: 360 });
+ await nextTick();
+ const thumb = thumbEl();
+ expect(thumb).toBeTruthy();
+ expect(thumb.getAttribute('role')).toBe('slider');
+ expect(thumb.getAttribute('aria-valuemin')).toBe('0');
+ expect(thumb.getAttribute('aria-valuemax')).toBe('360');
+ expect(thumb.getAttribute('aria-valuenow')).toBe('45');
+ // A radial control has no axis — aria-orientation must be absent.
+ expect(thumb.getAttribute('aria-orientation')).toBeNull();
+ expect(thumb.tabIndex).toBe(0);
+ });
+
+ it('aria-valuetext defaults to "°"', async () => {
+ mountDial({ defaultValue: 45 });
+ await nextTick();
+ expect(thumbEl().getAttribute('aria-valuetext')).toBe('45°');
+ });
+
+ it('rounds the default valuetext degrees', async () => {
+ mountDial({ defaultValue: 33.6, step: 0.1 });
+ await nextTick();
+ expect(thumbEl().getAttribute('aria-valuetext')).toBe('34°');
+ });
+
+ it('a custom valueText formatter overrides the default', async () => {
+ mountDial({ defaultValue: 90 }, { valueText: (deg: number) => `${deg} degrees` });
+ await nextTick();
+ expect(thumbEl().getAttribute('aria-valuetext')).toBe('90 degrees');
+ });
+
+ it('an explicit aria-valuetext attr wins over the formatter', async () => {
+ mountDial({ defaultValue: 90 }, { 'aria-valuetext': 'east' });
+ await nextTick();
+ expect(thumbEl().getAttribute('aria-valuetext')).toBe('east');
+ });
+
+ it('defaults aria-label to "Angle" and lets an explicit label win', async () => {
+ mountDial({ defaultValue: 0 });
+ await nextTick();
+ expect(thumbEl().getAttribute('aria-label')).toBe('Angle');
+ document.body.innerHTML = '';
+ while (wrappers.length) wrappers.pop()!.unmount();
+ mountDial({ defaultValue: 0 }, { 'aria-label': 'Hue' });
+ await nextTick();
+ expect(thumbEl().getAttribute('aria-label')).toBe('Hue');
+ });
+});
+
+describe('AngleDialThumb — keyboard', () => {
+ it('ArrowRight increments by step, ArrowLeft decrements', async () => {
+ const { model } = mountDial({ defaultValue: 50, step: 5, wrap: 'clamp' });
+ await nextTick();
+ const thumb = thumbEl();
+ keydown(thumb, 'ArrowRight');
+ await nextTick();
+ expect(model.value).toBe(55);
+ keydown(thumb, 'ArrowLeft');
+ keydown(thumb, 'ArrowLeft');
+ await nextTick();
+ expect(model.value).toBe(45);
+ });
+
+ it('ArrowUp increments, ArrowDown decrements', async () => {
+ const { model } = mountDial({ defaultValue: 50, step: 5, wrap: 'clamp' });
+ await nextTick();
+ const thumb = thumbEl();
+ keydown(thumb, 'ArrowUp');
+ await nextTick();
+ expect(model.value).toBe(55);
+ keydown(thumb, 'ArrowDown');
+ await nextTick();
+ expect(model.value).toBe(50);
+ });
+
+ it('Shift+Arrow jumps by largeStep', async () => {
+ const { model } = mountDial({ defaultValue: 100, step: 1, largeStep: 15, wrap: 'clamp' });
+ await nextTick();
+ const thumb = thumbEl();
+ keydown(thumb, 'ArrowRight', { shiftKey: true });
+ await nextTick();
+ expect(model.value).toBe(115);
+ keydown(thumb, 'ArrowLeft', { shiftKey: true });
+ await nextTick();
+ expect(model.value).toBe(100);
+ });
+
+ it('PageUp / PageDown jump by largeStep', async () => {
+ const { model } = mountDial({ defaultValue: 100, step: 1, largeStep: 15, wrap: 'clamp' });
+ await nextTick();
+ const thumb = thumbEl();
+ keydown(thumb, 'PageUp');
+ await nextTick();
+ expect(model.value).toBe(115);
+ keydown(thumb, 'PageDown');
+ keydown(thumb, 'PageDown');
+ await nextTick();
+ expect(model.value).toBe(85);
+ });
+
+ it('Home/End jump to the bounds (clamp mode)', async () => {
+ const { model } = mountDial({ defaultValue: 90, min: 0, max: 270, step: 1, wrap: 'clamp' });
+ await nextTick();
+ const thumb = thumbEl();
+ keydown(thumb, 'Home');
+ await nextTick();
+ expect(model.value).toBe(0);
+ keydown(thumb, 'End');
+ await nextTick();
+ expect(model.value).toBe(270);
+ });
+
+ it('Home goes to the seam start and End just before the seam (wrap mode)', async () => {
+ const { model } = mountDial({ defaultValue: 90, min: 0, max: 360, step: 1, wrap: 'wrap' });
+ await nextTick();
+ const thumb = thumbEl();
+ keydown(thumb, 'Home');
+ await nextTick();
+ expect(model.value).toBe(0);
+ keydown(thumb, 'End');
+ await nextTick();
+ // max (360) folds back to 0 in wrap mode, so End lands just before the seam.
+ expect(model.value).toBe(359);
+ });
+
+ it('disabled: tabindex=-1, aria-disabled, keys do nothing', async () => {
+ const { model } = mountDial({ defaultValue: 50, disabled: true });
+ await nextTick();
+ const thumb = thumbEl();
+ expect(thumb.tabIndex).toBe(-1);
+ expect(thumb.getAttribute('aria-disabled')).toBe('true');
+ keydown(thumb, 'ArrowRight');
+ await nextTick();
+ expect(model.value).toBeUndefined();
+ });
+
+ it('preventDefault is called on handled keys', async () => {
+ mountDial({ defaultValue: 50, wrap: 'clamp' });
+ await nextTick();
+ const thumb = thumbEl();
+ const ev = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true });
+ thumb.dispatchEvent(ev);
+ expect(ev.defaultPrevented).toBe(true);
+ });
+});
+
+describe('AngleDialRoot — seam handling', () => {
+ it('wrap mode crosses the 0/360 seam forward without jumping backwards', async () => {
+ const { model } = mountDial({ defaultValue: 359, min: 0, max: 360, step: 1, largeStep: 5, wrap: 'wrap' });
+ await nextTick();
+ const thumb = thumbEl();
+ // 359 + 5 (large) should land at 4 (wrapped through 0), not 354.
+ keydown(thumb, 'PageUp');
+ await nextTick();
+ expect(model.value).toBe(4);
+ });
+
+ it('wrap mode crosses the seam backward (0 → 355)', async () => {
+ const { model } = mountDial({ defaultValue: 0, min: 0, max: 360, step: 1, largeStep: 5, wrap: 'wrap' });
+ await nextTick();
+ const thumb = thumbEl();
+ keydown(thumb, 'PageDown');
+ await nextTick();
+ expect(model.value).toBe(355);
+ });
+
+ it('clamp mode bounds at the arc ends instead of wrapping', async () => {
+ const { model } = mountDial({ defaultValue: 355, min: 0, max: 360, step: 1, largeStep: 15, wrap: 'clamp' });
+ await nextTick();
+ const thumb = thumbEl();
+ keydown(thumb, 'PageUp');
+ await nextTick();
+ expect(model.value).toBe(360);
+ // Pushing further stays clamped.
+ keydown(thumb, 'ArrowRight');
+ await nextTick();
+ expect(model.value).toBe(360);
+ });
+
+ it('clamp mode bounds at the low end', async () => {
+ const { model } = mountDial({ defaultValue: 5, min: 0, max: 360, step: 1, largeStep: 15, wrap: 'clamp' });
+ await nextTick();
+ const thumb = thumbEl();
+ keydown(thumb, 'PageDown');
+ await nextTick();
+ expect(model.value).toBe(0);
+ });
+});
+
+describe('AngleDialRoot — snap', () => {
+ it('a snapped keyboard nudge advances to the next snap target', async () => {
+ const { model } = mountDial({ defaultValue: 0, step: 1, snap: 15, wrap: 'clamp' });
+ await nextTick();
+ const thumb = thumbEl();
+ // From a snap point, one ArrowRight steps to the next reachable snap target
+ // (15) instead of freezing at 0 (snap is coarser than step).
+ keydown(thumb, 'ArrowRight');
+ await nextTick();
+ expect(model.value).toBe(15);
+ keydown(thumb, 'ArrowRight');
+ await nextTick();
+ expect(model.value).toBe(30);
+ keydown(thumb, 'ArrowLeft');
+ await nextTick();
+ expect(model.value).toBe(15);
+ });
+});
+
+describe('AngleDialRoot — pointer drag', () => {
+ function rootEl(): HTMLElement {
+ // The root is the AngleDialRoot's rendered element (the dial container).
+ return document.querySelector('[role="slider"]')!.parentElement!;
+ }
+
+ function sizeRoot(el: HTMLElement): { cx: number; cy: number } {
+ el.style.position = 'fixed';
+ el.style.left = '0px';
+ el.style.top = '0px';
+ el.style.width = '200px';
+ el.style.height = '200px';
+ const rect = el.getBoundingClientRect();
+ return { cx: rect.left + rect.width / 2, cy: rect.top + rect.height / 2 };
+ }
+
+ function pointer(el: HTMLElement, type: string, x: number, y: number): void {
+ const ev = new PointerEvent(type, {
+ pointerId: 1,
+ button: 0,
+ clientX: x,
+ clientY: y,
+ bubbles: true,
+ cancelable: true,
+ });
+ el.dispatchEvent(ev);
+ }
+
+ it('pressing to the right of center sets the value to ~90° (0=up, clockwise)', async () => {
+ const { model } = mountDial({ defaultValue: 0, min: 0, max: 360, step: 1, wrap: 'wrap' });
+ await nextTick();
+ const root = rootEl();
+ const { cx, cy } = sizeRoot(root);
+ // A point straight to the right of center.
+ pointer(root, 'pointerdown', cx + 50, cy);
+ pointer(root, 'pointerup', cx + 50, cy);
+ await nextTick();
+ expect(model.value).toBe(90);
+ });
+
+ it('pressing below center sets the value to ~180°', async () => {
+ const { model } = mountDial({ defaultValue: 0, min: 0, max: 360, step: 1, wrap: 'wrap' });
+ await nextTick();
+ const root = rootEl();
+ const { cx, cy } = sizeRoot(root);
+ pointer(root, 'pointerdown', cx, cy + 50);
+ pointer(root, 'pointerup', cx, cy + 50);
+ await nextTick();
+ expect(model.value).toBe(180);
+ });
+
+ it('a press exactly at center is ignored (no angle)', async () => {
+ const { model } = mountDial({ defaultValue: 42, min: 0, max: 360, step: 1, wrap: 'wrap' });
+ await nextTick();
+ const root = rootEl();
+ const { cx, cy } = sizeRoot(root);
+ pointer(root, 'pointerdown', cx, cy);
+ pointer(root, 'pointerup', cx, cy);
+ await nextTick();
+ expect(model.value).toBeUndefined();
+ });
+
+ it('emits valueCommit on pointerup', async () => {
+ const committed: number[] = [];
+ const model = ref(undefined);
+ const Harness = defineComponent({
+ setup: () => () => h(AngleDialRoot, {
+ value: model.value,
+ wrap: 'wrap',
+ 'onUpdate:value': (v: number | null | undefined) => { model.value = v ?? undefined; },
+ onValueCommit: (v: number) => committed.push(v),
+ }, { default: () => h(AngleDialThumb) }),
+ });
+ track(mount(Harness, { attachTo: document.body }));
+ await nextTick();
+ const root = document.querySelector('[role="slider"]')!.parentElement!;
+ const { cx, cy } = sizeRoot(root);
+ pointer(root, 'pointerdown', cx + 50, cy);
+ pointer(root, 'pointerup', cx + 50, cy);
+ await nextTick();
+ expect(committed).toContain(90);
+ });
+});
+
+describe('utils — polar math', () => {
+ const center = { x: 0, y: 0 };
+
+ it('pointToAngle: up = 0°', () => {
+ // Screen y grows downward, so "up" is negative y.
+ expect(pointToAngle({ x: 0, y: -10 }, center)).toBeCloseTo(0);
+ });
+
+ it('pointToAngle: right = 90°', () => {
+ expect(pointToAngle({ x: 10, y: 0 }, center)).toBeCloseTo(90);
+ });
+
+ it('pointToAngle: down = 180°', () => {
+ expect(pointToAngle({ x: 0, y: 10 }, center)).toBeCloseTo(180);
+ });
+
+ it('pointToAngle: left = 270°', () => {
+ expect(pointToAngle({ x: -10, y: 0 }, center)).toBeCloseTo(270);
+ });
+
+ it('angleToPoint is the inverse of pointToAngle for cardinals', () => {
+ for (const deg of [0, 90, 180, 270]) {
+ const p = angleToPoint(deg, 10, center);
+ expect(pointToAngle(p, center)).toBeCloseTo(deg);
+ }
+ });
+
+ it('angleToPoint: 0° is straight up (negative y)', () => {
+ const p = angleToPoint(0, 10, center);
+ expect(p.x).toBeCloseTo(0);
+ expect(p.y).toBeCloseTo(-10);
+ });
+
+ it('angleToPoint: 90° is to the right (positive x)', () => {
+ const p = angleToPoint(90, 10, center);
+ expect(p.x).toBeCloseTo(10);
+ expect(p.y).toBeCloseTo(0);
+ });
+
+ it('normalizeDeg folds into [0, 360)', () => {
+ expect(normalizeDeg(370)).toBe(10);
+ expect(normalizeDeg(-10)).toBe(350);
+ expect(normalizeDeg(0)).toBe(0);
+ expect(normalizeDeg(360)).toBe(0);
+ });
+
+ it('shortestDelta picks the seam-crossing direction', () => {
+ expect(shortestDelta(350, 10)).toBeCloseTo(20);
+ expect(shortestDelta(10, 350)).toBeCloseTo(-20);
+ expect(shortestDelta(0, 90)).toBeCloseTo(90);
+ expect(shortestDelta(0, 180)).toBeCloseTo(180);
+ });
+
+ it('circularDistance is the shortest arc', () => {
+ expect(circularDistance(350, 10)).toBeCloseTo(20);
+ expect(circularDistance(0, 180)).toBeCloseTo(180);
+ expect(circularDistance(10, 350)).toBeCloseTo(20);
+ });
+
+ it('angleToHue produces an hsl string from the wrapped angle', () => {
+ expect(angleToHue(0)).toBe('hsl(0, 100%, 50%)');
+ expect(angleToHue(120)).toBe('hsl(120, 100%, 50%)');
+ expect(angleToHue(370)).toBe('hsl(10, 100%, 50%)');
+ expect(angleToHue(120, 80, 40)).toBe('hsl(120, 80%, 40%)');
+ });
+});
diff --git a/vue/primitives/src/aspect-ratio/__test__/__screenshots__/AspectRatio.test.ts/AspectRatio-computes-padding-bottom-from-ratio-1.png b/vue/primitives/src/canvas/angle-dial/__test__/__screenshots__/AngleDial.test.ts/AngleDialRoot---snap-snaps-to-a-scalar-increment-via-keyboard-1.png
similarity index 100%
rename from vue/primitives/src/aspect-ratio/__test__/__screenshots__/AspectRatio.test.ts/AspectRatio-computes-padding-bottom-from-ratio-1.png
rename to vue/primitives/src/canvas/angle-dial/__test__/__screenshots__/AngleDial.test.ts/AngleDialRoot---snap-snaps-to-a-scalar-increment-via-keyboard-1.png
diff --git a/vue/primitives/src/canvas/angle-dial/context.ts b/vue/primitives/src/canvas/angle-dial/context.ts
new file mode 100644
index 0000000..abef3d6
--- /dev/null
+++ b/vue/primitives/src/canvas/angle-dial/context.ts
@@ -0,0 +1,72 @@
+import type { Ref } from 'vue';
+import { useContextFactory } from '@robonen/vue';
+
+export type AngleDialDirection = 'ltr' | 'rtl';
+
+/**
+ * How the value behaves at the bounds.
+ * - `'wrap'` — the value lives on a continuous circle; crossing the `0` / `360`
+ * seam (e.g. dragging from `359°` forward) flows through to `0°`+ without
+ * jumping backwards. `min` / `max` are treated as the seam (a full turn).
+ * - `'clamp'` — the value is bounded to the arc `[min, max]`; pushing past an
+ * end stops at that end instead of jumping to the far side.
+ */
+export type AngleDialWrap = 'wrap' | 'clamp';
+
+/**
+ * Snap increments for the angle, in degrees.
+ * - A `number` snaps to every multiple of that increment (e.g. `15` → every 15°).
+ * - A `number[]` snaps to the nearest of an explicit list of angles
+ * (e.g. `[0, 45, 90, 135, 180, 225, 270, 315]`).
+ * - `undefined` disables snapping (only `step` rounding applies).
+ */
+export type AngleDialSnap = number | number[] | undefined;
+
+/**
+ * Formatter turning the raw angle (degrees) into a human-friendly string for
+ * `aria-valuetext`. A bare number on a circular control is ambiguous, so the
+ * thumb strongly defaults this to `` `${Math.round(deg)}°` ``. Return
+ * `undefined` to omit `aria-valuetext`.
+ */
+export type AngleDialValueText = (deg: number) => string | undefined;
+
+/**
+ * Context shared between `AngleDialRoot` and `AngleDialThumb`.
+ *
+ * Scalar props are exposed as plain `Ref` values, but `AngleDialRoot` builds
+ * them with `toRef(() => prop)` — a `GetterRefImpl` that is reactive without
+ * allocating a `ReactiveEffect` / cache (unlike `computed`), mirroring the
+ * slider's identity-passthrough convention.
+ */
+export interface AngleDialContext {
+ /** Current angle in degrees. */
+ value: Ref;
+ min: Ref;
+ max: Ref;
+ step: Ref;
+ /** Large-step increment (Page keys / Shift+Arrow), in degrees. */
+ largeStep: Ref;
+ wrap: Ref;
+ snap: Ref;
+ disabled: Ref;
+ direction: Ref;
+ /**
+ * Commit a new raw angle (degrees). The root applies snap, step rounding, and
+ * wrap/clamp handling before writing the model.
+ */
+ setValue: (deg: number) => void;
+ /**
+ * Nudge the value by a signed delta (degrees) from the keyboard. Honors
+ * wrap/clamp semantics (a wrap dial crosses the seam; a clamp dial stops).
+ */
+ nudge: (delta: number) => void;
+ /** Jump to the canonical low end (Home). */
+ toStart: () => void;
+ /** Jump to the canonical high end (End). */
+ toEnd: () => void;
+}
+
+const ctx = useContextFactory('AngleDialContext');
+
+export const provideAngleDialContext = ctx.provide;
+export const useAngleDialContext = ctx.inject;
diff --git a/vue/primitives/src/canvas/angle-dial/demo.vue b/vue/primitives/src/canvas/angle-dial/demo.vue
new file mode 100644
index 0000000..68dfad5
--- /dev/null
+++ b/vue/primitives/src/canvas/angle-dial/demo.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
+ Rotation
+ {{ Math.round(angle) }}°
+
+
+
+
+
+
+
+
+
+ {{ Math.round(angle) }}°
+ drag the dial
+
+
+
+
+
+
+
+ preview
+
+ ↑
+
+
+
+
diff --git a/vue/primitives/src/canvas/angle-dial/index.ts b/vue/primitives/src/canvas/angle-dial/index.ts
new file mode 100644
index 0000000..5577d2b
--- /dev/null
+++ b/vue/primitives/src/canvas/angle-dial/index.ts
@@ -0,0 +1,21 @@
+export { default as AngleDialRoot } from './AngleDialRoot.vue';
+export { default as AngleDialThumb } from './AngleDialThumb.vue';
+export type { AngleDialRootEmits, AngleDialRootProps } from './AngleDialRoot.vue';
+export type { AngleDialThumbProps } from './AngleDialThumb.vue';
+export type {
+ AngleDialContext,
+ AngleDialDirection,
+ AngleDialSnap,
+ AngleDialValueText,
+ AngleDialWrap,
+} from './context';
+export {
+ angleToHue,
+ angleToPoint,
+ applySnap,
+ circularDistance,
+ normalizeDeg,
+ pointToAngle,
+ shortestDelta,
+ type Point,
+} from './utils';
diff --git a/vue/primitives/src/canvas/angle-dial/utils.ts b/vue/primitives/src/canvas/angle-dial/utils.ts
new file mode 100644
index 0000000..220c2cd
--- /dev/null
+++ b/vue/primitives/src/canvas/angle-dial/utils.ts
@@ -0,0 +1,142 @@
+import type { Point } from '../../internal/utils/geometry';
+import type { AngleDialSnap } from './context';
+
+// Reuse the package-canonical 2D point (internal `utils/geometry`, not in the
+// root barrel) so angle-dial's `Point` is the SAME symbol as spline/pointer-drag/
+// snapping re-export — keeps the root barrel free of a TS2308 `Point` clash.
+/** A 2D point in client (screen) pixels. */
+export type { Point };
+
+/**
+ * ANGLE CONVENTION (load-bearing — every consumer assumes it):
+ *
+ * 0° points UP (12 o'clock) and the angle increases CLOCKWISE.
+ *
+ * up = 0°, right = 90°, down = 180°, left = 270°.
+ *
+ * This matches how rotation/heading dials are read by humans (a compass-style
+ * "12 o'clock is zero, turn right to increase"), not the mathematical
+ * convention (0° = +x, counter-clockwise). All conversions below honor it.
+ */
+
+const TAU = Math.PI * 2;
+const RAD_TO_DEG = 360 / TAU;
+const DEG_TO_RAD = TAU / 360;
+
+/** Wrap an angle (degrees) into the `[0, 360)` range. */
+export function normalizeDeg(deg: number): number {
+ const r = deg % 360;
+ return r < 0 ? r + 360 : r;
+}
+
+/**
+ * Convert a screen-space point into an angle (degrees) about `center`, using
+ * the documented convention (0° = up, clockwise-positive). Returns a value in
+ * `[0, 360)`.
+ *
+ * Screen y grows downward, so a point directly below the center (`dy > 0`)
+ * reads as 180° (down), and a point to the right (`dx > 0`) reads as 90°.
+ */
+export function pointToAngle(point: Point, center: Point): number {
+ const dx = point.x - center.x;
+ const dy = point.y - center.y;
+ // atan2(dx, -dy): rotate the standard math frame so 0° is up and the angle
+ // increases clockwise. `-dy` flips the down-positive screen axis to up.
+ const rad = Math.atan2(dx, -dy);
+ return normalizeDeg(rad * RAD_TO_DEG);
+}
+
+/**
+ * Convert an angle (degrees) into a point on a circle of `radius` about
+ * `center`, using the documented convention (0° = up, clockwise-positive).
+ * The inverse of {@link pointToAngle}.
+ */
+export function angleToPoint(deg: number, radius: number, center: Point): Point {
+ const rad = deg * DEG_TO_RAD;
+ return {
+ x: center.x + Math.sin(rad) * radius,
+ y: center.y - Math.cos(rad) * radius,
+ };
+}
+
+/**
+ * Number of decimal digits in a numeric `step`, mirroring the slider's helper.
+ * Used to compensate floating-point drift after step rounding.
+ */
+export function getStepDecimals(step: number): number {
+ if (!Number.isFinite(step)) return 0;
+ const str = String(step);
+ const dot = str.indexOf('.');
+ if (dot === -1) return 0;
+ return str.length - dot - 1;
+}
+
+/** Snap `value` to the nearest multiple of `step` anchored at `min`. */
+export function roundToStep(value: number, step: number, min: number, decimals: number): number {
+ if (step <= 0) return value;
+ const nearest = Math.round((value - min) / step) * step + min;
+ return decimals > 0 ? Number(nearest.toFixed(decimals)) : nearest;
+}
+
+/**
+ * Snap an angle to the nearest configured snap target.
+ *
+ * - scalar `snap` → nearest multiple of that increment (anchored at 0).
+ * - `snap` array → nearest of the explicit angles. In `wrap` mode the distance
+ * is measured around the circle so a value near 350° can snap to a `0`
+ * target; in clamp/linear mode it is a plain numeric distance.
+ *
+ * Returns `value` unchanged when `snap` is `undefined` or empty.
+ */
+export function applySnap(value: number, snap: AngleDialSnap, wrap: boolean): number {
+ if (snap === undefined) return value;
+ if (typeof snap === 'number') {
+ if (snap <= 0) return value;
+ return Math.round(value / snap) * snap;
+ }
+ if (snap.length === 0) return value;
+ let best = snap[0]!;
+ let bestDist = wrap ? circularDistance(value, best) : Math.abs(value - best);
+ for (let i = 1; i < snap.length; i++) {
+ const target = snap[i]!;
+ const dist = wrap ? circularDistance(value, target) : Math.abs(value - target);
+ if (dist < bestDist) {
+ bestDist = dist;
+ best = target;
+ }
+ }
+ return best;
+}
+
+/** Shortest absolute angular distance (degrees) between two angles on a circle. */
+export function circularDistance(a: number, b: number): number {
+ const d = Math.abs(normalizeDeg(a) - normalizeDeg(b)) % 360;
+ return d > 180 ? 360 - d : d;
+}
+
+/**
+ * Map an angle (degrees) onto the HSL hue wheel, returning a fully-saturated
+ * CSS color string for that hue. Handy when the dial doubles as a hue ring
+ * (`min: 0, max: 360, wrap: 'wrap'`): feed the angle straight in to color the
+ * thumb or a swatch. `saturation` and `lightness` are percentages.
+ *
+ * @param deg The angle in degrees (any range; wrapped to `[0, 360)`).
+ * @param saturation Saturation percentage. @default 100
+ * @param lightness Lightness percentage. @default 50
+ */
+export function angleToHue(deg: number, saturation = 100, lightness = 50): string {
+ return `hsl(${normalizeDeg(deg)}, ${saturation}%, ${lightness}%)`;
+}
+
+/**
+ * Signed shortest angular step (degrees) from `from` to `to` on a circle,
+ * in `(-180, 180]`. Positive is clockwise (increasing angle). Used to track the
+ * frame-to-frame delta in `wrap` mode so crossing the `0` / `360` seam
+ * accumulates smoothly instead of jumping `359 → 0 → 359`.
+ */
+export function shortestDelta(from: number, to: number): number {
+ let d = (normalizeDeg(to) - normalizeDeg(from)) % 360;
+ if (d > 180) d -= 360;
+ else if (d <= -180) d += 360;
+ return d;
+}
diff --git a/vue/primitives/src/canvas/canvas-stage/CanvasStageContent.vue b/vue/primitives/src/canvas/canvas-stage/CanvasStageContent.vue
new file mode 100644
index 0000000..0f3a23f
--- /dev/null
+++ b/vue/primitives/src/canvas/canvas-stage/CanvasStageContent.vue
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vue/primitives/src/canvas/canvas-stage/CanvasStagePane.vue b/vue/primitives/src/canvas/canvas-stage/CanvasStagePane.vue
new file mode 100644
index 0000000..198af4f
--- /dev/null
+++ b/vue/primitives/src/canvas/canvas-stage/CanvasStagePane.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
diff --git a/vue/primitives/src/canvas/canvas-stage/CanvasStageRoot.vue b/vue/primitives/src/canvas/canvas-stage/CanvasStageRoot.vue
new file mode 100644
index 0000000..4e7ec67
--- /dev/null
+++ b/vue/primitives/src/canvas/canvas-stage/CanvasStageRoot.vue
@@ -0,0 +1,351 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vue/primitives/src/canvas/canvas-stage/CanvasStageZoomIndicator.vue b/vue/primitives/src/canvas/canvas-stage/CanvasStageZoomIndicator.vue
new file mode 100644
index 0000000..50b7ef3
--- /dev/null
+++ b/vue/primitives/src/canvas/canvas-stage/CanvasStageZoomIndicator.vue
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+ {{ announced }}
+
+
+
diff --git a/vue/primitives/src/canvas/canvas-stage/__test__/CanvasStage.test.ts b/vue/primitives/src/canvas/canvas-stage/__test__/CanvasStage.test.ts
new file mode 100644
index 0000000..2fd640a
--- /dev/null
+++ b/vue/primitives/src/canvas/canvas-stage/__test__/CanvasStage.test.ts
@@ -0,0 +1,208 @@
+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 { CanvasStageContent, CanvasStagePane, CanvasStageRoot } from '../index';
+
+const wrappers: Array> = [];
+
+afterEach(() => {
+ while (wrappers.length) wrappers.pop()!.unmount();
+ document.body.innerHTML = '';
+});
+
+function track>(w: T): T {
+ wrappers.push(w);
+ return w;
+}
+
+/** Wait for `n` real animation frames so layout + ResizeObserver settle. */
+function raf(n = 1): Promise {
+ return new Promise((resolve) => {
+ let i = 0;
+ const step = (): void => {
+ if (++i >= n) resolve();
+ else requestAnimationFrame(step);
+ };
+ requestAnimationFrame(step);
+ });
+}
+
+function mountStage(props: Record = {}) {
+ const exposed = ref(null);
+ const Harness = defineComponent({
+ setup() {
+ return () => h(CanvasStageRoot, {
+ ref: (r: any) => { exposed.value = r; },
+ style: 'width: 400px; height: 300px;',
+ contentWidth: 800,
+ contentHeight: 600,
+ 'aria-label': 'Photo',
+ ...props,
+ }, {
+ default: () => h('img', { 'data-child': '', src: '', width: 800, height: 600 }),
+ });
+ },
+ });
+ const w = track(mount(Harness, { attachTo: document.body }));
+ return { wrapper: w, exposed };
+}
+
+describe('CanvasStageRoot (mount)', () => {
+ it('renders role=application + roledescription + tabindex 0', async () => {
+ mountStage();
+ await nextTick();
+ const root = document.querySelector('[data-canvas-stage-root]')!;
+ expect(root).toBeTruthy();
+ expect(root.getAttribute('role')).toBe('application');
+ expect(root.getAttribute('aria-roledescription')).toBe('zoomable canvas');
+ expect(root.tabIndex).toBe(0);
+ // Consumer aria-label rides through $attrs.
+ expect(root.getAttribute('aria-label')).toBe('Photo');
+ });
+
+ it('composes ViewportRoot/Surface/Content and renders the child in the transformed layer', async () => {
+ mountStage();
+ await nextTick();
+ expect(document.querySelector('[data-viewport-root]')).toBeTruthy();
+ expect(document.querySelector('[data-canvas-stage-pane][data-viewport-surface]')).toBeTruthy();
+ const content = document.querySelector('[data-canvas-stage-content][data-viewport-content]');
+ expect(content).toBeTruthy();
+ // The child renders INSIDE the transformed content layer.
+ const child = content!.querySelector('[data-child]');
+ expect(child).toBeTruthy();
+ expect(content!.style.transformOrigin).toBe('0px 0px');
+ });
+
+ it('keeps the pane a real clipping box (overflow hidden + touch-action none)', async () => {
+ mountStage();
+ await nextTick();
+ const pane = document.querySelector('[data-canvas-stage-pane]')!;
+ expect(pane.style.overflow).toBe('hidden');
+ expect(pane.style.touchAction).toBe('none');
+ });
+
+ it('exposes the combined api (fitView/zoomToActual/fitFill/reset/...)', async () => {
+ const { exposed } = mountStage();
+ await nextTick();
+ for (const fn of ['getViewport', 'setViewport', 'zoomIn', 'zoomOut', 'zoomTo', 'fitView', 'zoomToActual', 'fitFill', 'center', 'reset']) {
+ expect(typeof exposed.value[fn]).toBe('function');
+ }
+ });
+
+ it('zoomToActual sets zoom to 1 (1:1)', async () => {
+ const { exposed } = mountStage({ defaultViewport: { x: 0, y: 0, zoom: 0.3 } });
+ await raf(2);
+ await nextTick();
+ exposed.value.zoomToActual();
+ await nextTick();
+ expect(exposed.value.getViewport().zoom).toBeCloseTo(1, 5);
+ });
+
+ it('fitFill covers the pane (zoom = max ratio), fitView contains it (smaller)', async () => {
+ // pane 400x300, content 800x600 → contain = 0.5, cover = 0.5 here (same aspect).
+ // Use a non-matching aspect so contain != cover.
+ const { exposed } = mountStage({ contentWidth: 800, contentHeight: 200 });
+ await raf(2);
+ await nextTick();
+
+ exposed.value.fitView();
+ await nextTick();
+ const fitZoom = exposed.value.getViewport().zoom;
+
+ exposed.value.fitFill();
+ await nextTick();
+ const fillZoom = exposed.value.getViewport().zoom;
+
+ // contain uses min ratio (with padding) → smaller; cover uses max ratio → larger.
+ expect(fillZoom).toBeGreaterThan(fitZoom);
+ });
+
+ it('pressing \'0\' calls zoomToActual and \'1\' calls fitView', async () => {
+ const { exposed } = mountStage({ defaultViewport: { x: 0, y: 0, zoom: 4 } });
+ await raf(2);
+ await nextTick();
+ const root = document.querySelector('[data-canvas-stage-root]')!;
+ root.focus();
+
+ root.dispatchEvent(new KeyboardEvent('keydown', { key: '0', bubbles: true }));
+ await nextTick();
+ expect(exposed.value.getViewport().zoom).toBeCloseTo(1, 5);
+
+ root.dispatchEvent(new KeyboardEvent('keydown', { key: '1', bubbles: true }));
+ await nextTick();
+ // fitView (contain, 800x600 into 400x300 with padding) → ~0.45.
+ expect(exposed.value.getViewport().zoom).toBeLessThan(1);
+ });
+
+ it('arrow keys pan by panStep; Shift multiplies it', async () => {
+ const { exposed } = mountStage({ panStep: 40, fitOnReady: false, defaultViewport: { x: 0, y: 0, zoom: 1 } });
+ await raf(2);
+ await nextTick();
+ const root = document.querySelector('[data-canvas-stage-root]')!;
+ root.focus();
+
+ root.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
+ await nextTick();
+ expect(exposed.value.getViewport().x).toBe(-40);
+
+ root.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', shiftKey: true, bubbles: true }));
+ await nextTick();
+ expect(exposed.value.getViewport().x).toBe(-40 - 200);
+ });
+
+ it('disableKeyboardA11y downgrades role to group', async () => {
+ mountStage({ disableKeyboardA11y: true });
+ await nextTick();
+ const root = document.querySelector('[data-canvas-stage-root]')!;
+ expect(root.getAttribute('role')).toBe('group');
+ });
+
+ it('auto-measures the content element when no size props are given', async () => {
+ const exposed = ref(null);
+ const Harness = defineComponent({
+ setup() {
+ return () => h(CanvasStageRoot, {
+ ref: (r: any) => { exposed.value = r; },
+ style: 'width: 400px; height: 300px;',
+ }, {
+ default: () => h('div', { 'data-child': '', style: 'width: 250px; height: 120px;' }, 'x'),
+ });
+ },
+ });
+ track(mount(Harness, { attachTo: document.body }));
+ await raf(3);
+ await nextTick();
+ expect(exposed.value.contentSize.width).toBeCloseTo(250, 0);
+ expect(exposed.value.contentSize.height).toBeCloseTo(120, 0);
+ });
+
+ it('drives a controlled v-model:viewport', async () => {
+ const vp = ref({ x: 0, y: 0, zoom: 1 });
+ const Harness = defineComponent({
+ setup() {
+ return () => h(CanvasStageRoot, {
+ viewport: vp.value,
+ 'onUpdate:viewport': (v: any) => { vp.value = v; },
+ style: 'width: 400px; height: 300px;',
+ contentWidth: 800,
+ contentHeight: 600,
+ fitOnReady: false,
+ }, { default: () => h('div', { 'data-child': '' }, 'x') });
+ },
+ });
+ track(mount(Harness, { attachTo: document.body }));
+ await nextTick();
+ vp.value = { x: 10, y: 20, zoom: 1.5 };
+ await nextTick();
+ const content = document.querySelector('[data-canvas-stage-content]')!;
+ expect(content.style.transform).toContain('translate(10px, 20px)');
+ expect(content.style.transform).toContain('scale(1.5)');
+ });
+
+ it('the parts can be composed manually under a custom subtree', async () => {
+ // Sanity: Pane + Content are exported and mountable inside the Root surface.
+ expect(CanvasStagePane).toBeTruthy();
+ expect(CanvasStageContent).toBeTruthy();
+ });
+});
diff --git a/vue/primitives/src/canvas/canvas-stage/__test__/__screenshots__/CanvasStage.test.ts/CanvasStageRoot--mount--auto-measures-the-content-element-when-no-size-props-are-given-1.png b/vue/primitives/src/canvas/canvas-stage/__test__/__screenshots__/CanvasStage.test.ts/CanvasStageRoot--mount--auto-measures-the-content-element-when-no-size-props-are-given-1.png
new file mode 100644
index 0000000000000000000000000000000000000000..52c754d4f4435399be5c1d9d439456eb6047042e
GIT binary patch
literal 2340
zcmeAS@N?(olHy`uVBq!ia0y~yVDx2RV7kD;1QeOwv~@BA1IJBI7srr_IdAS5`eg-*
zI9z<*FIB2wHqET;+|g?mBJK|)a<*=tD5TLiNpk{&;zMKBHF0IvR=r~>%kwKL((6C|
zm|@9Z=?1I0&hyVL^nZR~dGU+OmS0}V{3_wAwQ9G+5Y}rq{`>D=U46a1o!z}VcXY(M
zw{PFhD6xI>|Ifh>m+yK0`DfPFs)`DUhKts_Kfiwc`ug?j{r&wfOMdhE!%BtyKdb&wuB>kcS_rAs*MOb9fyxbg)w7!
zG)zXrgqYG`G@Xp5lhJfSpvoN0B;;h0_x8Xh&j0_3F<)N*8El|N4l~0V7QdN+|8-J;
OA`G6celF{r5}E+nAP@Ke
literal 0
HcmV?d00001
diff --git a/vue/primitives/src/canvas/canvas-stage/context.ts b/vue/primitives/src/canvas/canvas-stage/context.ts
new file mode 100644
index 0000000..64ae599
--- /dev/null
+++ b/vue/primitives/src/canvas/canvas-stage/context.ts
@@ -0,0 +1,78 @@
+import type { Ref } from 'vue';
+import { useContextFactory } from '@robonen/vue';
+import type { Dimensions, Rect, Viewport, XYPosition } from '../zoom-pan';
+
+/**
+ * The imperative control surface of a {@link CanvasStageRoot}. Wraps the
+ * underlying zoom-pan `ViewportApi` and adds the photo-editing fit modes
+ * (`fitView` / `zoomToActual` / `fitFill`) that depend on the content size. Every
+ * write funnels through the zoom-pan api's `clampViewport`, so the result always
+ * honours `minZoom`/`maxZoom`.
+ */
+export interface CanvasStageApi {
+ /** Current viewport `{x,y,zoom}`. */
+ getViewport: () => Viewport;
+ /** Replace the viewport (clamped). */
+ setViewport: (viewport: Viewport) => void;
+ /** Zoom in, anchored at the pane centre. */
+ zoomIn: (factor?: number) => void;
+ /** Zoom out, anchored at the pane centre. */
+ zoomOut: (factor?: number) => void;
+ /** Zoom to an absolute level, anchored at the pane centre. */
+ zoomTo: (zoom: number) => void;
+ /**
+ * Fit the whole content into the pane, centred with `fitPadding`. The
+ * "contain" mode — the entire image/video is visible. No-op until both the
+ * pane and the content have been measured.
+ */
+ fitView: () => void;
+ /**
+ * Zoom to 1:1 (zoom 1, one content px per screen px), centred on the content
+ * centre. No-op until the pane has been measured.
+ */
+ zoomToActual: () => void;
+ /**
+ * Scale the content to *cover* the pane (the larger of the two fit ratios),
+ * centred — the "fill" mode, no letterboxing. No-op until measured.
+ */
+ fitFill: () => void;
+ /** Centre a content-space point in the pane, keeping the current zoom. */
+ center: (point?: XYPosition) => void;
+ /** Reset to `{ x: 0, y: 0, zoom: 1 }` (clamped). */
+ reset: () => void;
+}
+
+/**
+ * Context shared by every `CanvasStage` part. Exposes the wrapped
+ * {@link CanvasStageApi}, the reactive content size, and the resolved content
+ * extent (a content-space {@link Rect} starting at the origin), plus the surface
+ * measurement flag so descendants can gate behaviour until the pane is ready.
+ */
+export interface CanvasStageContext {
+ /** The combined imperative api (zoom-pan + canvas fit modes). */
+ api: CanvasStageApi;
+ /** Live viewport `{x,y,zoom}` (read-only mirror of the zoom-pan master). */
+ viewport: Ref;
+ /**
+ * The intrinsic content size in content-space px — either the explicit
+ * `contentWidth`/`contentHeight` props or the measured size of the content
+ * element. `{ width: 0, height: 0 }` until known.
+ */
+ contentSize: Ref;
+ /** The content extent `{ x: 0, y: 0, width, height }` used by the fit modes. */
+ contentExtent: Ref;
+ /** False until the pane has reported its first non-zero rect. */
+ measured: Ref;
+ /** Whether the content element should be auto-measured (no explicit size props). */
+ autoMeasure: Ref;
+ /** `CanvasStageContent` reports its measured intrinsic size here. */
+ setMeasuredContentSize: (size: Dimensions) => void;
+}
+
+const context = useContextFactory('CanvasStageContext');
+
+/** Provide the {@link CanvasStageContext} to descendants of `CanvasStageRoot`. */
+export const provideCanvasStageContext = context.provide;
+
+/** Inject the {@link CanvasStageContext}. Throws when no `CanvasStageRoot` ancestor. */
+export const useCanvasStageContext = context.inject;
diff --git a/vue/primitives/src/canvas/canvas-stage/demo.vue b/vue/primitives/src/canvas/canvas-stage/demo.vue
new file mode 100644
index 0000000..f972d27
--- /dev/null
+++ b/vue/primitives/src/canvas/canvas-stage/demo.vue
@@ -0,0 +1,140 @@
+
+
+
+
+
+ Canvas stage
+
+
+
+
+
+
+
+
+
+ 1200 × 800
+
+
+
+
+
+
+ {{ percent }}%
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Drag to pan, scroll to zoom, or focus the stage and use Arrow keys, +/-, and 0 / 1 / 2 for 1:1 / fit / fill.
+
+
+
+
+
diff --git a/vue/primitives/src/canvas/canvas-stage/index.ts b/vue/primitives/src/canvas/canvas-stage/index.ts
new file mode 100644
index 0000000..7fa98fc
--- /dev/null
+++ b/vue/primitives/src/canvas/canvas-stage/index.ts
@@ -0,0 +1,12 @@
+export { default as CanvasStageRoot } from './CanvasStageRoot.vue';
+export { default as CanvasStagePane } from './CanvasStagePane.vue';
+export { default as CanvasStageContent } from './CanvasStageContent.vue';
+export { default as CanvasStageZoomIndicator } from './CanvasStageZoomIndicator.vue';
+
+export { provideCanvasStageContext, useCanvasStageContext } from './context';
+export type { CanvasStageApi, CanvasStageContext } from './context';
+
+export type { CanvasStageRootProps } from './CanvasStageRoot.vue';
+export type { CanvasStagePaneEmits, CanvasStagePaneProps } from './CanvasStagePane.vue';
+export type { CanvasStageContentProps } from './CanvasStageContent.vue';
+export type { CanvasStageZoomIndicatorProps } from './CanvasStageZoomIndicator.vue';
diff --git a/vue/primitives/src/canvas/compare-slider/CompareSliderAfter.vue b/vue/primitives/src/canvas/compare-slider/CompareSliderAfter.vue
new file mode 100644
index 0000000..d79880b
--- /dev/null
+++ b/vue/primitives/src/canvas/compare-slider/CompareSliderAfter.vue
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
diff --git a/vue/primitives/src/canvas/compare-slider/CompareSliderBefore.vue b/vue/primitives/src/canvas/compare-slider/CompareSliderBefore.vue
new file mode 100644
index 0000000..b40e32f
--- /dev/null
+++ b/vue/primitives/src/canvas/compare-slider/CompareSliderBefore.vue
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
diff --git a/vue/primitives/src/canvas/compare-slider/CompareSliderDivider.vue b/vue/primitives/src/canvas/compare-slider/CompareSliderDivider.vue
new file mode 100644
index 0000000..c146e82
--- /dev/null
+++ b/vue/primitives/src/canvas/compare-slider/CompareSliderDivider.vue
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
diff --git a/vue/primitives/src/canvas/compare-slider/CompareSliderHandle.vue b/vue/primitives/src/canvas/compare-slider/CompareSliderHandle.vue
new file mode 100644
index 0000000..f7df1bf
--- /dev/null
+++ b/vue/primitives/src/canvas/compare-slider/CompareSliderHandle.vue
@@ -0,0 +1,153 @@
+
+
+
+
+
+
+
+
+
diff --git a/vue/primitives/src/canvas/compare-slider/CompareSliderRoot.vue b/vue/primitives/src/canvas/compare-slider/CompareSliderRoot.vue
new file mode 100644
index 0000000..f38cc03
--- /dev/null
+++ b/vue/primitives/src/canvas/compare-slider/CompareSliderRoot.vue
@@ -0,0 +1,214 @@
+
+
+
+
+
+
+
+
+
diff --git a/vue/primitives/src/canvas/compare-slider/__test__/CompareSlider.test.ts b/vue/primitives/src/canvas/compare-slider/__test__/CompareSlider.test.ts
new file mode 100644
index 0000000..cab32fb
--- /dev/null
+++ b/vue/primitives/src/canvas/compare-slider/__test__/CompareSlider.test.ts
@@ -0,0 +1,333 @@
+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 { provideConfig } from '../../../utilities/config-provider';
+import {
+ CompareSliderAfter,
+ CompareSliderBefore,
+ CompareSliderDivider,
+ CompareSliderHandle,
+ CompareSliderRoot,
+} from '../index';
+
+// Minimal declarative config wrapper (the package ships no ConfigProvider
+// component; config is provided via `provideConfig` inside a setup).
+const ConfigProvider = defineComponent({
+ props: { dir: { type: String, default: undefined } },
+ setup(props, { slots }) {
+ provideConfig({ dir: () => props.dir as 'ltr' | 'rtl' | undefined });
+ return () => slots.default?.();
+ },
+});
+
+const wrappers: Array> = [];
+
+afterEach(() => {
+ while (wrappers.length) wrappers.pop()!.unmount();
+ document.body.innerHTML = '';
+});
+
+function track>(w: T): T {
+ wrappers.push(w);
+ return w;
+}
+
+type RootOpts = Partial<{
+ defaultPosition: number;
+ disabled: boolean;
+ inverted: boolean;
+ orientation: 'horizontal' | 'vertical';
+ dir: 'ltr' | 'rtl';
+ keyboardStep: number;
+ keyboardLargeStep: number;
+}>;
+
+function mountSlider(opts: RootOpts = {}, wrapInConfig?: { dir?: 'ltr' | 'rtl' }) {
+ const model = ref(undefined);
+ const Harness = defineComponent({
+ setup() {
+ const tree = () => h(CompareSliderRoot, {
+ position: model.value,
+ 'onUpdate:position': (v: number | undefined) => { model.value = v; },
+ ...opts,
+ }, {
+ default: () => [
+ h(CompareSliderBefore),
+ h(CompareSliderAfter, { id: 'after' }),
+ h(CompareSliderDivider),
+ h(CompareSliderHandle, { id: 'handle' }),
+ ],
+ });
+ return () => wrapInConfig
+ ? h(ConfigProvider, { dir: wrapInConfig.dir }, { default: tree })
+ : tree();
+ },
+ });
+ const w = track(mount(Harness, { attachTo: document.body }));
+ return { wrapper: w, model };
+}
+
+function keydown(el: Element, key: string, opts: { shiftKey?: boolean } = {}): void {
+ const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, shiftKey: opts.shiftKey ?? false });
+ el.dispatchEvent(event);
+}
+
+describe('CompareSlider — rendering & ARIA', () => {
+ it('renders the handle as role="slider" with correct aria-value*', async () => {
+ mountSlider({ defaultPosition: 40 });
+ await nextTick();
+ const handle = document.querySelector('[role="slider"]')!;
+ expect(handle).toBeTruthy();
+ expect(handle.getAttribute('aria-valuemin')).toBe('0');
+ expect(handle.getAttribute('aria-valuemax')).toBe('100');
+ expect(handle.getAttribute('aria-valuenow')).toBe('40');
+ expect(handle.getAttribute('aria-orientation')).toBe('horizontal');
+ expect(handle.tabIndex).toBe(0);
+ });
+
+ it('applies the default aria-label "Comparison position"', async () => {
+ mountSlider({ defaultPosition: 50 });
+ await nextTick();
+ const handle = document.querySelector('[role="slider"]')!;
+ expect(handle.getAttribute('aria-label')).toBe('Comparison position');
+ });
+
+ it('an explicit aria-label wins over the default', async () => {
+ const Harness = defineComponent({
+ setup: () => () => h(CompareSliderRoot, { defaultPosition: 50 }, {
+ default: () => [
+ h(CompareSliderBefore),
+ h(CompareSliderAfter),
+ h(CompareSliderHandle, { 'aria-label': 'Reveal' }),
+ ],
+ }),
+ });
+ track(mount(Harness, { attachTo: document.body }));
+ await nextTick();
+ const handle = document.querySelector('[role="slider"]')!;
+ expect(handle.getAttribute('aria-label')).toBe('Reveal');
+ });
+
+ it('sets data-orientation as a literal on the root and handle', async () => {
+ mountSlider({ defaultPosition: 50, orientation: 'vertical' });
+ await nextTick();
+ const handle = document.getElementById('handle')!;
+ expect(handle.getAttribute('data-orientation')).toBe('vertical');
+ expect(handle.getAttribute('aria-orientation')).toBe('vertical');
+ });
+});
+
+describe('CompareSlider — keyboard', () => {
+ it('ArrowRight increments and ArrowLeft decrements the position', async () => {
+ const { model } = mountSlider({ defaultPosition: 50, keyboardStep: 5 });
+ await nextTick();
+ const handle = document.querySelector('[role="slider"]')!;
+ keydown(handle, 'ArrowRight');
+ await nextTick();
+ expect(model.value).toBe(55);
+ keydown(handle, 'ArrowLeft');
+ keydown(handle, 'ArrowLeft');
+ await nextTick();
+ expect(model.value).toBe(45);
+ });
+
+ it('ArrowRight is reversed under dir="rtl"', async () => {
+ const { model } = mountSlider({ defaultPosition: 50, keyboardStep: 5, dir: 'rtl' });
+ await nextTick();
+ const handle = document.querySelector('[role="slider"]')!;
+ keydown(handle, 'ArrowRight');
+ await nextTick();
+ expect(model.value).toBe(45);
+ keydown(handle, 'ArrowLeft');
+ await nextTick();
+ expect(model.value).toBe(50);
+ });
+
+ it('Shift+Arrow and Page keys use the large step', async () => {
+ const { model } = mountSlider({ defaultPosition: 50, keyboardStep: 1, keyboardLargeStep: 10 });
+ await nextTick();
+ const handle = document.querySelector('[role="slider"]')!;
+ keydown(handle, 'ArrowRight', { shiftKey: true });
+ await nextTick();
+ expect(model.value).toBe(60);
+ keydown(handle, 'PageDown');
+ keydown(handle, 'PageDown');
+ await nextTick();
+ expect(model.value).toBe(40);
+ });
+
+ it('Home/End jump to 0/100', async () => {
+ const { model } = mountSlider({ defaultPosition: 50 });
+ await nextTick();
+ const handle = document.querySelector('[role="slider"]')!;
+ keydown(handle, 'Home');
+ await nextTick();
+ expect(model.value).toBe(0);
+ keydown(handle, 'End');
+ await nextTick();
+ expect(model.value).toBe(100);
+ });
+
+ it('clamps at 0 and 100', async () => {
+ const { model } = mountSlider({ defaultPosition: 95, keyboardStep: 10 });
+ await nextTick();
+ const handle = document.querySelector('[role="slider"]')!;
+ keydown(handle, 'ArrowRight');
+ await nextTick();
+ expect(model.value).toBe(100);
+ keydown(handle, 'Home');
+ keydown(handle, 'ArrowLeft');
+ await nextTick();
+ expect(model.value).toBe(0);
+ });
+
+ it('vertical: ArrowDown increases, ArrowUp decreases (no-flip reveals the top)', async () => {
+ const { model } = mountSlider({ defaultPosition: 50, orientation: 'vertical', keyboardStep: 5 });
+ await nextTick();
+ const handle = document.querySelector('[role="slider"]')!;
+ keydown(handle, 'ArrowDown');
+ await nextTick();
+ expect(model.value).toBe(55);
+ keydown(handle, 'ArrowUp');
+ keydown(handle, 'ArrowUp');
+ await nextTick();
+ expect(model.value).toBe(45);
+ });
+
+ it('disabled: tabindex=-1, aria-disabled, and keys do nothing', async () => {
+ const { model } = mountSlider({ defaultPosition: 50, disabled: true });
+ await nextTick();
+ const handle = document.querySelector('[role="slider"]')!;
+ expect(handle.tabIndex).toBe(-1);
+ expect(handle.getAttribute('aria-disabled')).toBe('true');
+ keydown(handle, 'ArrowRight');
+ keydown(handle, 'Home');
+ await nextTick();
+ expect(model.value).toBeUndefined();
+ expect(handle.getAttribute('aria-valuenow')).toBe('50');
+ });
+});
+
+describe('CompareSlider — after-layer clip-path', () => {
+ it('clip-path inset reflects position (horizontal, no flip → clips the right)', async () => {
+ mountSlider({ defaultPosition: 30 });
+ await nextTick();
+ const after = document.getElementById('after')!;
+ // 30% revealed from the left → right edge clipped by 70%.
+ expect(after.style.clipPath).toBe('inset(0% 70% 0% 0%)');
+ });
+
+ it('updates the clip-path when the position changes', async () => {
+ const { model } = mountSlider({ defaultPosition: 50, keyboardStep: 10 });
+ await nextTick();
+ const handle = document.querySelector('[role="slider"]')!;
+ const after = document.getElementById('after')!;
+ keydown(handle, 'ArrowRight');
+ await nextTick();
+ expect(model.value).toBe(60);
+ expect(after.style.clipPath).toBe('inset(0% 40% 0% 0%)');
+ });
+
+ it('inverted flips the clipped side (horizontal → clips the left)', async () => {
+ mountSlider({ defaultPosition: 30, inverted: true });
+ await nextTick();
+ const after = document.getElementById('after')!;
+ expect(after.style.clipPath).toBe('inset(0% 0% 0% 70%)');
+ });
+
+ it('vertical (no flip) clips the bottom edge', async () => {
+ mountSlider({ defaultPosition: 30, orientation: 'vertical' });
+ await nextTick();
+ const after = document.getElementById('after')!;
+ // The browser collapses the trailing `0%` (CSSOM `inset()` serialization,
+ // like `margin`): `inset(0% 0% 70% 0%)` → `inset(0% 0% 70%)`.
+ expect(after.style.clipPath).toBe('inset(0% 0% 70%)');
+ });
+
+ it('0 and 100 produce no sliver (full hide / full show)', async () => {
+ const { model } = mountSlider({ defaultPosition: 50 });
+ await nextTick();
+ const handle = document.querySelector('[role="slider"]')!;
+ const after = document.getElementById('after')!;
+ keydown(handle, 'Home');
+ await nextTick();
+ expect(model.value).toBe(0);
+ // 0% revealed → right edge clipped by the full 100% (nothing shown).
+ expect(after.style.clipPath).toBe('inset(0% 100% 0% 0%)');
+ keydown(handle, 'End');
+ await nextTick();
+ // 100% revealed → no clipping; the browser collapses `inset(0% 0% 0% 0%)`
+ // to the single-value shorthand `inset(0%)`.
+ expect(after.style.clipPath).toBe('inset(0%)');
+ });
+});
+
+describe('CompareSlider — pointer drag', () => {
+ it('maps a pointerdown on the root onto the reveal position', async () => {
+ const { model } = mountSlider({ defaultPosition: 50 });
+ await nextTick();
+ const root = document.querySelector('[data-orientation]')!;
+ // Give the root a deterministic box.
+ root.style.width = '200px';
+ root.style.height = '100px';
+ await nextTick();
+ const rect = root.getBoundingClientRect();
+ const down = new PointerEvent('pointerdown', {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ pointerId: 1,
+ clientX: rect.left + rect.width * 0.25,
+ clientY: rect.top + rect.height / 2,
+ });
+ root.dispatchEvent(down);
+ await nextTick();
+ expect(model.value).toBeCloseTo(25, 0);
+ });
+
+ it('disabled blocks pointer drag', async () => {
+ const { model } = mountSlider({ defaultPosition: 50, disabled: true });
+ await nextTick();
+ const root = document.querySelector('[data-orientation]')!;
+ root.style.width = '200px';
+ const rect = root.getBoundingClientRect();
+ const down = new PointerEvent('pointerdown', {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ pointerId: 1,
+ clientX: rect.left + rect.width * 0.75,
+ clientY: rect.top + 10,
+ });
+ root.dispatchEvent(down);
+ await nextTick();
+ expect(model.value).toBeUndefined();
+ });
+});
+
+describe('CompareSlider — controlled position model', () => {
+ it('reflects an external position update onto aria-valuenow and clip-path', async () => {
+ const model = ref(20);
+ const Harness = defineComponent({
+ setup: () => () => h(CompareSliderRoot, {
+ position: model.value,
+ 'onUpdate:position': (v: number | undefined) => { model.value = v; },
+ }, {
+ default: () => [
+ h(CompareSliderBefore),
+ h(CompareSliderAfter, { id: 'after2' }),
+ h(CompareSliderHandle, { id: 'handle2' }),
+ ],
+ }),
+ });
+ track(mount(Harness, { attachTo: document.body }));
+ await nextTick();
+ const handle = document.getElementById('handle2')!;
+ expect(handle.getAttribute('aria-valuenow')).toBe('20');
+ model.value = 75;
+ await nextTick();
+ expect(handle.getAttribute('aria-valuenow')).toBe('75');
+ expect(document.getElementById('after2')!.style.clipPath).toBe('inset(0% 25% 0% 0%)');
+ });
+});
diff --git a/vue/primitives/src/aspect-ratio/__test__/__screenshots__/AspectRatio.test.ts/AspectRatio-places-inner-element-absolutely-covering-the-wrapper-1.png b/vue/primitives/src/canvas/compare-slider/__test__/__screenshots__/CompareSlider.test.ts/CompareSlider---after-layer-clip-path-0-and-100-produce-no-sliver--full-hide---full-show--1.png
similarity index 100%
rename from vue/primitives/src/aspect-ratio/__test__/__screenshots__/AspectRatio.test.ts/AspectRatio-places-inner-element-absolutely-covering-the-wrapper-1.png
rename to vue/primitives/src/canvas/compare-slider/__test__/__screenshots__/CompareSlider.test.ts/CompareSlider---after-layer-clip-path-0-and-100-produce-no-sliver--full-hide---full-show--1.png
diff --git a/vue/primitives/src/aspect-ratio/__test__/__screenshots__/AspectRatio.test.ts/AspectRatio-renders-with-default-1-1-ratio-1.png b/vue/primitives/src/canvas/compare-slider/__test__/__screenshots__/CompareSlider.test.ts/CompareSlider---after-layer-clip-path-vertical--no-flip--clips-the-bottom-edge-1.png
similarity index 100%
rename from vue/primitives/src/aspect-ratio/__test__/__screenshots__/AspectRatio.test.ts/AspectRatio-renders-with-default-1-1-ratio-1.png
rename to vue/primitives/src/canvas/compare-slider/__test__/__screenshots__/CompareSlider.test.ts/CompareSlider---after-layer-clip-path-vertical--no-flip--clips-the-bottom-edge-1.png
diff --git a/vue/primitives/src/canvas/compare-slider/context.ts b/vue/primitives/src/canvas/compare-slider/context.ts
new file mode 100644
index 0000000..dea28de
--- /dev/null
+++ b/vue/primitives/src/canvas/compare-slider/context.ts
@@ -0,0 +1,54 @@
+import type { Ref } from 'vue';
+import { useContextFactory } from '@robonen/vue';
+
+export type CompareSliderOrientation = 'horizontal' | 'vertical';
+export type CompareSliderDirection = 'ltr' | 'rtl';
+
+/**
+ * Formatter turning the raw reveal position (0–100) into a human-friendly
+ * string for the handle's `aria-valuetext`. Return `undefined` to omit
+ * `aria-valuetext`.
+ */
+export type CompareSliderValueText = (position: number) => string | undefined;
+
+/**
+ * Context shared between `CompareSliderRoot` and its descendants
+ * (`CompareSliderBefore`, `CompareSliderAfter`, `CompareSliderHandle`,
+ * `CompareSliderDivider`).
+ *
+ * Scalar props are exposed as plain `Ref`, but `CompareSliderRoot` builds
+ * them with `toRef(() => prop)` — a `GetterRefImpl` that is reactive without
+ * allocating a `ReactiveEffect` / cache (unlike `computed`). For identity
+ * passthrough of scalar props this avoids redundant effects per instance while
+ * keeping template auto-unwrap and `.value` ergonomics.
+ */
+export interface CompareSliderContext {
+ /** Reveal position, 0–100 (percentage of the after-layer shown). */
+ position: Ref;
+ orientation: Ref;
+ direction: Ref;
+ disabled: Ref;
+ inverted: Ref;
+ /**
+ * Combined flip flag driving BOTH the pointer-offset mapping and the
+ * after-layer clip side. For horizontal: `(dir === 'rtl') !== inverted`; for
+ * vertical: `inverted`. Keeping a single source of truth prevents the divider
+ * and the revealed region from desyncing.
+ */
+ flip: Ref;
+ /** Single keyboard step (Arrow). */
+ keyboardStep: Ref;
+ /** Large keyboard step (Shift+Arrow / Page keys). */
+ keyboardLargeStep: Ref;
+ /** Optional formatter for the handle's `aria-valuetext`; `undefined` when unset. */
+ valueText: Ref;
+ /** Move the reveal position by `delta` (clamped to 0–100). */
+ step: (delta: number) => void;
+ /** Set the reveal position to an absolute value (clamped to 0–100). */
+ setPosition: (next: number) => void;
+}
+
+const ctx = useContextFactory('CompareSliderContext');
+
+export const provideCompareSliderContext = ctx.provide;
+export const useCompareSliderContext = ctx.inject;
diff --git a/vue/primitives/src/canvas/compare-slider/demo.vue b/vue/primitives/src/canvas/compare-slider/demo.vue
new file mode 100644
index 0000000..00927e3
--- /dev/null
+++ b/vue/primitives/src/canvas/compare-slider/demo.vue
@@ -0,0 +1,123 @@
+
+
+
+
+
+ Before / After
+ {{ Math.round(position) }}% after
+