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

Reorganize components into category folders (forms/canvas/overlays/etc.); add the
media-editor headless family (timeline, curve-editor, waveform, crop, color
picker, etc.); apply perf fixes (O(1) collection lookups, plain-object drag
state, gesture-leak teardown, shallowRef color state, rect caching) and replace
source `any` with proper types.
This commit is contained in:
2026-06-15 16:54:29 +07:00
parent 661a55719e
commit eefd7abf83
1029 changed files with 65815 additions and 9449 deletions
@@ -0,0 +1,67 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Displays content within a fixed, responsive width-to-height ratio. The
* element grows to fill its container's width and derives its height from the
* `ratio`, so the box keeps its proportions at any size. Use it to reserve
* layout space for images, video, maps, or embeds and avoid content shift.
*/
export interface AspectRatioProps extends PrimitiveProps {
/**
* Desired width-to-height ratio (e.g. `16 / 9`, `1`, `4 / 3`).
* @default 1
*/
ratio?: number;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
const { forwardRef } = useForwardExpose();
const { ratio = 1, as = 'div' } = defineProps<AspectRatioProps>();
defineSlots<{
/**
* Default content placed inside the fixed-ratio box. Receives the resolved
* geometry so consumers can react to runtime `ratio` changes without
* re-deriving it: `ratio` is the raw width-to-height value and `aspect` is
* the height-as-percent-of-width used for the layout reservation.
*/
default?: (props: {
/** The resolved width-to-height ratio currently in effect. */
ratio: number;
/** Height expressed as a percentage of width (`(1 / ratio) * 100`). */
aspect: number;
}) => unknown;
}>();
// `aspect` is the height-as-percent-of-width; isolated so the slot prop and the
// wrapper style share a single cached derivation that tracks `ratio`.
const aspect = computed(() => (1 / ratio) * 100);
const wrapperStyle = computed(() => ({
position: 'relative' as const,
width: '100%',
paddingBottom: `${aspect.value}%`,
}));
// Hoisted constant — the inner style never depends on props, so a single
// module-level object is reused across all instances.
const INNER_STYLE = {
position: 'absolute' as const,
inset: 0,
};
</script>
<template>
<div :ref="forwardRef" :style="wrapperStyle" data-aspect-ratio-wrapper>
<Primitive :as="as" :style="INNER_STYLE" :data-aspect-ratio="true">
<slot :ratio="ratio" :aspect="aspect" />
</Primitive>
</div>
</template>
@@ -0,0 +1,82 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { nextTick } from 'vue';
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: '<img />' } });
const inner = wrapper.element.firstElementChild as HTMLElement;
expect(inner.style.position).toBe('absolute');
expect(inner.getAttribute('data-aspect-ratio')).toBe('true');
});
it('renders the inner element with a custom `as` tag', () => {
const wrapper = mount(AspectRatio, { props: { as: 'section' } });
const inner = wrapper.element.firstElementChild as HTMLElement;
expect(inner.tagName).toBe('SECTION');
expect(inner.getAttribute('data-aspect-ratio')).toBe('true');
});
it('exposes resolved ratio and aspect to the default slot', () => {
const wrapper = mount(AspectRatio, {
props: { ratio: 16 / 9 },
slots: {
default: `<template #default="{ ratio, aspect }"><span class="slot">{{ ratio }}|{{ aspect }}</span></template>`,
},
});
const span = wrapper.find('.slot');
expect(span.exists()).toBe(true);
const [ratio, aspect] = span.text().split('|').map(Number);
expect(ratio).toBeCloseTo(16 / 9);
expect(aspect).toBeCloseTo(56.25);
});
it('updates the slot props reactively when ratio changes', async () => {
const wrapper = mount(AspectRatio, {
props: { ratio: 1 },
slots: {
default: `<template #default="{ aspect }"><span class="slot">{{ aspect }}</span></template>`,
},
});
expect(wrapper.find('.slot').text()).toBe('100');
await wrapper.setProps({ ratio: 4 });
await nextTick();
expect(wrapper.find('.slot').text()).toBe('25');
});
it('handles extreme ratios without breaking the layout reservation', () => {
const tall = mount(AspectRatio, { props: { ratio: 1 / 4 } });
expect((tall.element as HTMLElement).style.paddingBottom).toBe('400%');
const wide = mount(AspectRatio, { props: { ratio: 100 } });
expect((wide.element as HTMLElement).style.paddingBottom).toBe('1%');
});
});
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import axe from 'axe-core';
import { AspectRatio } from '../index';
async function checkA11y(element: Element) {
const results = await axe.run(element);
return results.violations;
}
function createAspectRatio(props: Record<string, unknown> = {}) {
return mount(
defineComponent({
setup() {
return () =>
h(
AspectRatio,
props,
{
default: () =>
h('img', {
class: 'h-full w-full object-cover',
src: 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==',
alt: 'Decorative placeholder',
}),
},
);
},
}),
{ attachTo: document.body },
);
}
describe('AspectRatio a11y', () => {
it('has no axe violations with the default ratio', async () => {
const wrapper = createAspectRatio();
await nextTick();
const violations = await checkA11y(wrapper.element);
expect(violations).toEqual([]);
wrapper.unmount();
});
it('has no axe violations with a custom ratio', async () => {
const wrapper = createAspectRatio({ ratio: 16 / 9 });
await nextTick();
const violations = await checkA11y(wrapper.element);
expect(violations).toEqual([]);
wrapper.unmount();
});
it('has no axe violations when rendered as a custom element', async () => {
const wrapper = createAspectRatio({ as: 'figure' });
await nextTick();
const violations = await checkA11y(wrapper.element);
expect(violations).toEqual([]);
wrapper.unmount();
});
});
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { AspectRatio } from '@robonen/primitives';
import { ref } from 'vue';
const ratios = [
{ label: '16 / 9', value: 16 / 9 },
{ label: '4 / 3', value: 4 / 3 },
{ label: '1 / 1', value: 1 },
] as const;
const ratio = ref(ratios[0].value);
</script>
<template>
<div class="demo-stack max-w-md text-fg">
<div class="flex items-center gap-1 p-1 rounded-lg bg-bg-inset border border-border w-fit">
<button
v-for="r in ratios"
:key="r.label"
type="button"
class="px-3 py-1 text-sm rounded-md transition-colors"
:class="ratio === r.value
? 'bg-accent text-accent-fg'
: 'text-fg-muted hover:text-fg hover:bg-bg-subtle'"
@click="ratio = r.value"
>
{{ r.label }}
</button>
</div>
<AspectRatio
:ratio="ratio"
class="overflow-hidden rounded-xl border border-border bg-bg-subtle"
>
<img
src="https://images.unsplash.com/photo-1535025183041-0991a977e25b?w=800&q=80"
alt="Mountain landscape at dusk"
class="h-full w-full object-cover"
>
</AspectRatio>
<p class="text-sm text-fg-muted">
The frame keeps a fixed
<span class="font-medium text-fg">{{ ratios.find((r) => r.value === ratio)?.label }}</span>
proportion as the container resizes, so the image never shifts surrounding layout.
</p>
</div>
</template>
@@ -0,0 +1,2 @@
export { default as AspectRatio } from './AspectRatio.vue';
export type { AspectRatioProps } from './AspectRatio.vue';
@@ -0,0 +1,59 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Content shown while the image is loading or when it fails to load — typically
* the user's initials or a generic icon. It renders only when the image is not
* yet `loaded`, and can be delayed to avoid a flash of fallback on fast
* connections.
*/
export interface AvatarFallbackProps extends PrimitiveProps {
/** Delay in ms before rendering the fallback (avoids flicker on fast networks). */
delayMs?: number;
}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { computed, ref, watch } from 'vue';
import { useAvatarContext } from './context';
import { useForwardExpose, useTimeoutFn } from '@robonen/vue';
const { as = 'span', delayMs = 0 } = defineProps<AvatarFallbackProps>();
const { forwardRef } = useForwardExpose();
const ctx = useAvatarContext();
const canShow = ref<boolean>(delayMs === 0);
// Delay rendering the fallback to avoid a flash on fast connections. The timer
// is SSR-safe and torn down automatically on scope dispose — no manual cleanup.
const { start: startDelay, stop: stopDelay } = useTimeoutFn(() => {
canShow.value = true;
}, () => delayMs, { immediate: false });
watch(() => ctx.imageLoadingStatus.value, (status) => {
if (status === 'loaded') {
stopDelay();
canShow.value = false;
return;
}
if (delayMs === 0) {
canShow.value = true;
return;
}
stopDelay();
canShow.value = false;
startDelay();
}, { immediate: true });
const shouldRender = computed(() => canShow.value && ctx.imageLoadingStatus.value !== 'loaded');
</script>
<template>
<Primitive :ref="forwardRef" v-if="shouldRender" :as="as">
<slot />
</Primitive>
</template>
@@ -0,0 +1,129 @@
<script lang="ts">
import type { ImgHTMLAttributes } from 'vue';
import type { PrimitiveProps } from '../../internal/primitive';
import type { AvatarImageLoadingStatus } from './context';
/**
* The image to display. It loads the `src` out of band and only renders once
* the image has successfully loaded, reporting its loading status to the root
* so the fallback can take over while loading or on error.
*
* A browser-cached image is detected synchronously, so an already-loaded
* avatar shows instantly without a fallback flash.
*/
export interface AvatarImageProps extends PrimitiveProps {
/** Image source URL — loaded out of band before the image is shown. */
src?: string;
/** Alternative text describing the image. */
alt?: string;
/**
* Referrer policy applied to both the out-of-band preload and the rendered
* image, so the displayed `<img>` reuses the preload cache entry.
*/
referrerPolicy?: ImgHTMLAttributes['referrerpolicy'];
/**
* CORS setting applied to both the out-of-band preload and the rendered
* image (required for canvas tainting / credentialed CDNs and cache reuse).
*/
crossOrigin?: ImgHTMLAttributes['crossorigin'];
/** Called whenever the image's loading status changes (`idle`/`loading`/`loaded`/`error`). */
onLoadingStatusChange?: (status: AvatarImageLoadingStatus) => void;
}
export interface AvatarImageEmits {
/** Emitted whenever the image's loading status changes. */
loadingStatusChange: [status: AvatarImageLoadingStatus];
}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useAvatarContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'img', src, alt, referrerPolicy, crossOrigin, onLoadingStatusChange } = defineProps<AvatarImageProps>();
const emit = defineEmits<AvatarImageEmits>();
const { forwardRef } = useForwardExpose();
const ctx = useAvatarContext();
const status = ref<AvatarImageLoadingStatus>('idle');
function setStatus(next: AvatarImageLoadingStatus) {
status.value = next;
ctx.onImageLoadingStatusChange(next);
onLoadingStatusChange?.(next);
emit('loadingStatusChange', next);
}
let currentImage: HTMLImageElement | null = null;
function detachCurrent() {
if (currentImage) {
currentImage.onload = null;
currentImage.onerror = null;
currentImage = null;
}
}
// A `load`/`onload` event can still fire for a degenerate (0×0) response, so a
// real image is only the one that actually decoded to non-zero dimensions.
function isDecoded(image: HTMLImageElement) {
return image.complete && image.naturalWidth > 0;
}
function load(nextSrc: string | undefined) {
detachCurrent();
if (!nextSrc) {
setStatus('error');
return;
}
if (globalThis.window === undefined) {
setStatus('loading');
return;
}
const img = new globalThis.Image();
currentImage = img;
// Mirror the rendered element's fetch configuration onto the preload so the
// displayed `<img>` reuses this cache entry instead of issuing a new request.
if (referrerPolicy !== undefined) img.referrerPolicy = referrerPolicy;
if (typeof crossOrigin === 'string') img.crossOrigin = crossOrigin;
img.onload = () => {
if (currentImage === img) setStatus(isDecoded(img) ? 'loaded' : 'error');
};
img.onerror = () => {
if (currentImage === img) setStatus('error');
};
img.src = nextSrc;
// Synchronously surface a browser-cached image to avoid a fallback flash.
if (isDecoded(img)) {
setStatus('loaded');
return;
}
setStatus('loading');
}
watch(() => src, load, { immediate: true });
onBeforeUnmount(detachCurrent);
const shouldRender = computed(() => status.value === 'loaded');
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
v-if="shouldRender"
role="img"
:src="src"
:alt="alt"
:referrerpolicy="referrerPolicy"
:crossorigin="crossOrigin"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,40 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* An image element representing a user, with a graceful text/icon fallback for
* when the image is loading or fails to load. Use it for profile pictures in
* avatars, comment threads, member lists, or anywhere a user identity is shown
* and you need a reliable placeholder.
*
* The root tracks the image's loading status and provides it via context so
* `AvatarImage` and `AvatarFallback` can coordinate which one is rendered. It
* exposes the current status on the `data-status` attribute for styling.
*/
export interface AvatarRootProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import type { AvatarImageLoadingStatus } from './context';
import { Primitive } from '../../internal/primitive';
import { provideAvatarContext } from './context';
import { ref } from 'vue';
import { useForwardExpose } from '@robonen/vue';
const { as = 'span' } = defineProps<AvatarRootProps>();
const { forwardRef } = useForwardExpose();
const imageLoadingStatus = ref<AvatarImageLoadingStatus>('idle');
provideAvatarContext({
imageLoadingStatus,
onImageLoadingStatusChange: (status) => { imageLoadingStatus.value = status; },
});
</script>
<template>
<Primitive :ref="forwardRef" :as="as" :data-status="imageLoadingStatus">
<slot />
</Primitive>
</template>
@@ -0,0 +1,310 @@
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;
complete = false;
naturalWidth = 0;
referrerPolicy = '';
crossOrigin: string | null = null;
private _src = '';
set src(value: string) {
this._src = value;
queueMicrotask(() => {
if (value.includes('broken')) {
this.onerror?.();
}
else if (value.includes('zero')) {
// Fires load but decoded to 0×0 (degenerate response).
this.complete = true;
this.naturalWidth = 0;
this.onload?.();
}
else {
this.complete = true;
this.naturalWidth = 64;
this.onload?.();
}
});
}
get src() { return this._src; }
}
// A synchronously-cached image: `complete`/`naturalWidth` are already truthy
// the moment `src` is assigned, so no async event is needed.
class CachedImage {
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
complete = true;
naturalWidth = 64;
referrerPolicy = '';
crossOrigin: string | null = null;
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' }),
],
}),
}), { attachTo: document.body });
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);
w.unmount();
});
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' }),
],
}),
}), { attachTo: document.body });
await new Promise(r => queueMicrotask(() => r(null)));
await nextTick();
expect(w.find('img').exists()).toBe(false);
expect(w.find('.fb').exists()).toBe(true);
w.unmount();
});
it('treats a zero-dimension (degenerate) onload as error', async () => {
const w = mount(defineComponent({
setup: () => () => h(AvatarRoot, null, {
default: () => [
h(AvatarImage, { src: '/zero.png' }),
h(AvatarFallback, { class: 'fb' }, { default: () => 'AB' }),
],
}),
}), { attachTo: document.body });
await new Promise(r => queueMicrotask(() => r(null)));
await nextTick();
expect(w.find('img').exists()).toBe(false);
expect(w.find('.fb').exists()).toBe(true);
expect(w.element.getAttribute('data-status')).toBe('error');
w.unmount();
});
it('shows a browser-cached image synchronously without a fallback flash', async () => {
vi.stubGlobal('Image', CachedImage as unknown as typeof Image);
const statuses: string[] = [];
const w = mount(defineComponent({
setup: () => () => h(AvatarRoot, null, {
default: () => [
h(AvatarImage, {
src: '/cached.png',
onLoadingStatusChange: (s: string) => statuses.push(s),
}),
h(AvatarFallback, { class: 'fb' }, { default: () => 'AB' }),
],
}),
}), { attachTo: document.body });
await nextTick();
// It jumped straight to loaded — never went through a visible loading/error flash.
expect(statuses).not.toContain('loading');
expect(statuses).not.toContain('error');
expect(statuses.at(-1)).toBe('loaded');
expect(w.find('img').exists()).toBe(true);
expect(w.find('.fb').exists()).toBe(false);
w.unmount();
});
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' }),
],
}),
}), { attachTo: document.body });
expect(w.find('.fb').exists()).toBe(false);
vi.advanceTimersByTime(500);
await nextTick();
expect(w.find('.fb').exists()).toBe(true);
vi.useRealTimers();
w.unmount();
});
it('sets data-status on the root element', async () => {
// A manually-driven image: it never auto-resolves, so the transient
// 'loading' state is observable deterministically before we fire onload.
const created: ManualImage[] = [];
class ManualImage {
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
complete = false;
naturalWidth = 0;
src = '';
constructor() {
created.push(this);
}
resolve() {
this.complete = true;
this.naturalWidth = 64;
this.onload?.();
}
}
vi.stubGlobal('Image', ManualImage as unknown as typeof Image);
const w = mount(defineComponent({
setup: () => () => h(AvatarRoot, null, {
default: () => [
h(AvatarImage, { src: '/ok.png' }),
h(AvatarFallback, null, { default: () => '?' }),
],
}),
}), { attachTo: document.body });
await nextTick();
expect(w.element.getAttribute('data-status')).toBe('loading');
created[0]!.resolve();
await nextTick();
expect(w.element.getAttribute('data-status')).toBe('loaded');
w.unmount();
});
it('shows fallback when src is empty (error status)', async () => {
const w = mount(defineComponent({
setup: () => () => h(AvatarRoot, null, {
default: () => [
h(AvatarImage, {}),
h(AvatarFallback, { class: 'fb' }, { default: () => 'AB' }),
],
}),
}), { attachTo: document.body });
await nextTick();
expect(w.element.getAttribute('data-status')).toBe('error');
expect(w.find('img').exists()).toBe(false);
expect(w.find('.fb').exists()).toBe(true);
w.unmount();
});
it('renders role="img" on the loaded image', async () => {
const w = mount(defineComponent({
setup: () => () => h(AvatarRoot, null, {
default: () => [
h(AvatarImage, { src: '/ok.png', alt: 'user' }),
],
}),
}), { attachTo: document.body });
await new Promise(r => queueMicrotask(() => r(null)));
await nextTick();
const img = w.find('img');
expect(img.exists()).toBe(true);
expect(img.attributes('role')).toBe('img');
expect(img.attributes('alt')).toBe('user');
w.unmount();
});
it('forwards referrerPolicy and crossOrigin to the rendered image', async () => {
const w = mount(defineComponent({
setup: () => () => h(AvatarRoot, null, {
default: () => [
h(AvatarImage, {
src: '/ok.png',
referrerPolicy: 'no-referrer',
crossOrigin: 'anonymous',
}),
],
}),
}), { attachTo: document.body });
await new Promise(r => queueMicrotask(() => r(null)));
await nextTick();
const img = w.find('img');
expect(img.attributes('referrerpolicy')).toBe('no-referrer');
expect(img.attributes('crossorigin')).toBe('anonymous');
w.unmount();
});
it('applies referrerPolicy and crossOrigin to the out-of-band preload', async () => {
const created: MockImage[] = [];
class TrackingImage extends MockImage {
constructor() {
super();
created.push(this);
}
}
vi.stubGlobal('Image', TrackingImage as unknown as typeof Image);
const w = mount(defineComponent({
setup: () => () => h(AvatarRoot, null, {
default: () => [
h(AvatarImage, {
src: '/ok.png',
referrerPolicy: 'origin',
crossOrigin: 'use-credentials',
}),
],
}),
}), { attachTo: document.body });
await nextTick();
expect(created.length).toBe(1);
expect(created[0]!.referrerPolicy).toBe('origin');
expect(created[0]!.crossOrigin).toBe('use-credentials');
w.unmount();
});
it('emits loadingStatusChange as a Vue emit', async () => {
const events: string[] = [];
const w = mount(defineComponent({
setup: () => () => h(AvatarRoot, null, {
default: () => [
h(AvatarImage, {
src: '/ok.png',
onLoadingStatusChange: (s: string) => events.push(s),
}),
],
}),
}), { attachTo: document.body });
await new Promise(r => queueMicrotask(() => r(null)));
await nextTick();
expect(events).toContain('loading');
expect(events).toContain('loaded');
w.unmount();
});
it('guards against a stale in-flight load when src changes', async () => {
const statuses: string[] = [];
const w = mount(defineComponent({
props: { src: { type: String, default: '/broken.png' } },
setup: props => () => h(AvatarRoot, null, {
default: () => [
h(AvatarImage, {
src: props.src,
onLoadingStatusChange: (s: string) => statuses.push(s),
}),
h(AvatarFallback, { class: 'fb' }, { default: () => 'AB' }),
],
}),
}), { attachTo: document.body, props: { src: '/broken.png' } });
// Swap to a good src before the broken one's microtask resolves.
await w.setProps({ src: '/ok.png' });
await new Promise(r => queueMicrotask(() => r(null)));
await new Promise(r => queueMicrotask(() => r(null)));
await nextTick();
// The stale broken load must not clobber the newer good status.
expect(w.element.getAttribute('data-status')).toBe('loaded');
expect(w.find('img').exists()).toBe(true);
w.unmount();
});
});
@@ -0,0 +1,14 @@
import type { Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
export type AvatarImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error';
export interface AvatarContext {
imageLoadingStatus: Ref<AvatarImageLoadingStatus>;
onImageLoadingStatusChange: (status: AvatarImageLoadingStatus) => void;
}
const ctx = useContextFactory<AvatarContext>('AvatarContext');
export const provideAvatarContext = ctx.provide;
export const useAvatarContext = ctx.inject;
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { AvatarFallback, AvatarImage, AvatarRoot } from '@robonen/primitives';
const people = [
{
name: 'Ada Lovelace',
initials: 'AL',
src: 'https://i.pravatar.cc/96?img=47',
},
{
name: 'Alan Turing',
initials: 'AT',
src: 'https://example.com/this-image-does-not-exist.png',
},
{
name: 'Grace Hopper',
initials: 'GH',
src: '',
},
] as const;
</script>
<template>
<div class="flex items-center gap-4">
<div
v-for="person in people"
:key="person.name"
class="flex flex-col items-center gap-2"
>
<AvatarRoot
class="relative inline-flex h-14 w-14 select-none items-center justify-center overflow-hidden rounded-full border border-border bg-bg-subtle align-middle"
>
<AvatarImage
:src="person.src"
:alt="person.name"
class="h-full w-full rounded-[inherit] object-cover"
/>
<AvatarFallback
:delay-ms="200"
class="flex h-full w-full items-center justify-center bg-bg-inset text-sm font-medium text-fg-muted"
>
{{ person.initials }}
</AvatarFallback>
</AvatarRoot>
<span class="text-xs text-fg-subtle">{{ person.name }}</span>
</div>
</div>
</template>
@@ -0,0 +1,8 @@
export { default as AvatarRoot } from './AvatarRoot.vue';
export { default as AvatarImage } from './AvatarImage.vue';
export { default as AvatarFallback } from './AvatarFallback.vue';
export type { AvatarRootProps } from './AvatarRoot.vue';
export type { AvatarImageProps, AvatarImageEmits } from './AvatarImage.vue';
export type { AvatarFallbackProps } from './AvatarFallback.vue';
export { provideAvatarContext, useAvatarContext } from './context';
export type { AvatarContext, AvatarImageLoadingStatus } from './context';
@@ -0,0 +1,54 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A single `role="gridcell"` day container (`<td>`). Reflects the date's state
* (selected, disabled, unavailable, outside-view, today) as `data-*`
* attributes and `aria-*` for styling, and wraps the focusable
* `CalendarCellTrigger`.
*/
export interface CalendarCellProps extends PrimitiveProps {
/** The date this cell represents. */
date: Date;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useCalendarGridContext, useCalendarRootContext } from './context';
const { as = 'td', date } = defineProps<CalendarCellProps>();
const ctx = useCalendarRootContext();
const gridCtx = useCalendarGridContext();
const adapter = ctx.dateAdapter;
const isSelected = computed(() => ctx.isDateSelected(date));
const isOutsideView = computed(() => !adapter.value.isSameMonth(date, gridCtx.month.value));
const isDisabled = computed(() =>
ctx.isDateDisabled(date)
|| (ctx.disableDaysOutsideCurrentView.value && isOutsideView.value),
);
const isUnavailable = computed(() => ctx.isDateUnavailable(date));
const isOutsideVisibleView = computed(() => ctx.isOutsideVisibleView(date));
const isToday = computed(() => adapter.value.isSameDay(date, adapter.value.now()));
</script>
<template>
<Primitive
:as="as"
role="gridcell"
:aria-selected="isSelected ? true : undefined"
:aria-disabled="(isDisabled || isUnavailable) ? true : undefined"
:data-primitives-calendar-cell="''"
:data-selected="isSelected ? '' : undefined"
:data-disabled="isDisabled ? '' : undefined"
:data-unavailable="isUnavailable ? '' : undefined"
:data-outside-view="isOutsideView ? '' : undefined"
:data-outside-visible-view="isOutsideVisibleView ? '' : undefined"
:data-today="isToday ? '' : undefined"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,262 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The focusable, clickable day button inside a `CalendarCell`. Selects its
* `day` on click/Enter/Space, drives roving focus and full arrow-key /
* Home-End / PageUp-Down keyboard navigation (paging the month when focus
* crosses the visible range), and exposes day state through its slot.
*/
export interface CalendarCellTriggerProps extends PrimitiveProps {
/** The day this trigger represents. */
day: Date;
/** The month this trigger's cell belongs to. Defaults to grid context. */
month?: Date;
}
export interface CalendarCellTriggerSlotProps {
dayValue: string;
disabled: boolean;
selected: boolean;
today: boolean;
outsideView: boolean;
outsideVisibleView: boolean;
unavailable: boolean;
}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { computed, nextTick } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useCalendarGridContext, useCalendarRootContext } from './context';
/**
* Cache of `Intl.NumberFormat` instances keyed by locale. `Number#toLocaleString`
* with an explicit locale internally constructs a fresh `Intl.NumberFormat` on
* every call; this trigger renders ~42 day cells per month, so caching one
* immutable formatter per locale at module scope removes that per-cell
* allocation while preserving locale-specific numbering systems.
*/
const dayNumberFormatCache = new Map<string, Intl.NumberFormat>();
function formatDayNumber(day: number, locale: string): string {
let fmt = dayNumberFormatCache.get(locale);
if (fmt === undefined) {
fmt = new Intl.NumberFormat(locale);
dayNumberFormatCache.set(locale, fmt);
}
return fmt.format(day);
}
const { as = 'div', day, month } = defineProps<CalendarCellTriggerProps>();
defineSlots<{
default?: (props: CalendarCellTriggerSlotProps) => unknown;
}>();
const ctx = useCalendarRootContext();
const gridCtx = useCalendarGridContext();
const { forwardRef } = useForwardExpose();
const adapter = ctx.dateAdapter;
const monthValue = computed(() => month ?? gridCtx.month.value);
const isOutsideView = computed(() => !adapter.value.isSameMonth(day, monthValue.value));
const isOutsideVisibleView = computed(() => ctx.isOutsideVisibleView(day));
const isDisabled = computed(() =>
ctx.isDateDisabled(day)
|| (ctx.disableDaysOutsideCurrentView.value && isOutsideView.value),
);
const isUnavailable = computed(() => ctx.isDateUnavailable(day));
const isSelected = computed(() => ctx.isDateSelected(day));
const isToday = computed(() => adapter.value.isSameDay(day, adapter.value.now()));
const dayValue = computed(() => formatDayNumber(adapter.value.getParts(day).day, ctx.locale.value));
const labelText = computed(() => adapter.value.formatFullDate(day, ctx.locale.value));
function selectionInView(): Date | undefined {
const v = ctx.modelValue.value;
if (Array.isArray(v))
return v.find(d => adapter.value.isSameMonth(d, monthValue.value));
return v && adapter.value.isSameMonth(v, monthValue.value) ? v : undefined;
}
const isFocusedDate = computed(() => {
if (isOutsideView.value || isDisabled.value) return false;
if (ctx.focusedDate.value) return adapter.value.isSameDay(day, ctx.focusedDate.value);
// Fallback focusable: selected (in view), else today (if in view), else the
// first actionable (non-disabled) date — never an unfocusable cell.
const selected = selectionInView();
if (selected) return adapter.value.isSameDay(day, selected);
const today = adapter.value.now();
if (adapter.value.isSameMonth(today, monthValue.value) && !ctx.isDateDisabled(today) && !ctx.isDateUnavailable(today))
return adapter.value.isSameDay(day, today);
const first = ctx.firstFocusableDate.value;
return first ? adapter.value.isSameDay(day, first) : false;
});
function selectIfAllowed() {
if (ctx.readonly.value) return;
if (isDisabled.value || isUnavailable.value) return;
ctx.setDate(day);
ctx.focusedDate.value = day;
}
function handleClick() {
selectIfAllowed();
}
function cellFor(target: Date): HTMLElement | null {
const parent = ctx.parentElement.value;
if (!parent) return null;
return parent.querySelector<HTMLElement>(
`[data-primitives-calendar-cell-trigger][data-value="${adapter.value.toIsoDate(target)}"]:not([data-outside-view])`,
);
}
/**
* Move focus to `target`. When `step` is provided (arrow navigation) and the
* resolved cell is disabled, keep stepping in the same direction so focus
* never parks on a disabled day. Home/End/Page jumps pass no step and land
* directly on the target.
*/
function shiftFocus(target: Date, step?: number) {
if (ctx.minValue.value && adapter.value.isBefore(target, ctx.minValue.value)) return;
if (ctx.maxValue.value && adapter.value.isAfter(target, ctx.maxValue.value)) return;
const inViewCell = cellFor(target);
if (inViewCell) {
if (step && inViewCell.hasAttribute('data-disabled')) {
shiftFocus(adapter.value.addDays(target, step), step);
return;
}
ctx.focusedDate.value = target;
// Keep the placeholder in sync with the focused day, but only while it
// stays within the placeholder's own month — `grid` derives its visible
// window from the placeholder, so a cross-month write would shift a
// multi-month view. Cross-view moves are handled by the paging branch.
if (adapter.value.isSameMonth(target, ctx.placeholder.value))
ctx.setPlaceholder(target);
inViewCell.focus();
return;
}
// Crossed visible range — page placeholder and retry.
ctx.focusedDate.value = target;
if (target > ctx.placeholder.value) {
if (ctx.isNextButtonDisabled()) return;
ctx.nextPage();
}
else {
if (ctx.isPrevButtonDisabled()) return;
ctx.prevPage();
}
nextTick(() => {
const el = cellFor(target);
if (!el) return;
if (step && el.hasAttribute('data-disabled')) {
shiftFocus(adapter.value.addDays(target, step), step);
return;
}
el.focus();
});
}
function handleKeyDown(e: KeyboardEvent) {
if (isDisabled.value) return;
const rtl = ctx.dir.value === 'rtl' ? -1 : 1;
switch (e.key) {
case 'ArrowRight':
e.preventDefault();
shiftFocus(adapter.value.addDays(day, rtl), rtl);
break;
case 'ArrowLeft':
e.preventDefault();
shiftFocus(adapter.value.addDays(day, -rtl), -rtl);
break;
case 'ArrowUp':
e.preventDefault();
shiftFocus(adapter.value.addDays(day, -7), -7);
break;
case 'ArrowDown':
e.preventDefault();
shiftFocus(adapter.value.addDays(day, 7), 7);
break;
case 'Home': {
e.preventDefault();
const dow = adapter.value.getDay(day);
const offset = (dow - ctx.weekStartsOn.value + 7) % 7;
shiftFocus(adapter.value.addDays(day, -offset));
break;
}
case 'End': {
e.preventDefault();
const dow = adapter.value.getDay(day);
const offset = (dow - ctx.weekStartsOn.value + 7) % 7;
shiftFocus(adapter.value.addDays(day, 6 - offset));
break;
}
case 'PageUp':
e.preventDefault();
shiftFocus(e.shiftKey ? adapter.value.addYears(day, -1) : adapter.value.addMonths(day, -1));
break;
case 'PageDown':
e.preventDefault();
shiftFocus(e.shiftKey ? adapter.value.addYears(day, 1) : adapter.value.addMonths(day, 1));
break;
case 'Enter':
case ' ':
e.preventDefault();
selectIfAllowed();
break;
}
}
function handleFocus() {
ctx.focusedDate.value = day;
}
const dataValue = computed(() => adapter.value.toIsoDate(day));
const tabindex = computed(() => {
if (isFocusedDate.value) return 0;
if (isOutsideView.value || isDisabled.value) return undefined;
return -1;
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="button"
:aria-label="labelText"
:aria-disabled="(isDisabled || isUnavailable) ? true : undefined"
:aria-selected="isSelected ? true : undefined"
:tabindex="tabindex"
:data-primitives-calendar-cell-trigger="''"
:data-value="dataValue"
:data-selected="isSelected ? '' : undefined"
:data-disabled="isDisabled ? '' : undefined"
:data-unavailable="isUnavailable ? '' : undefined"
:data-outside-view="isOutsideView ? '' : undefined"
:data-outside-visible-view="isOutsideVisibleView ? '' : undefined"
:data-today="isToday ? '' : undefined"
:data-focused="isFocusedDate ? '' : undefined"
@click="handleClick"
@focus="handleFocus"
@keydown="handleKeyDown"
>
<slot
:day-value="dayValue"
:disabled="isDisabled"
:selected="isSelected"
:today="isToday"
:outside-view="isOutsideView"
:outside-visible-view="isOutsideVisibleView"
:unavailable="isUnavailable"
>
{{ dayValue }}
</slot>
</Primitive>
</template>
@@ -0,0 +1,45 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The `role="grid"` table for a single month. Provides grid context (the month
* it renders) to its head/body cells; render one per visible month when
* `numberOfMonths > 1`.
*/
export interface CalendarGridProps extends PrimitiveProps {
/** The month this grid represents. Defaults to the root placeholder's month. */
month?: Date;
}
</script>
<script setup lang="ts">
import { computed, toRef } from 'vue';
import { Primitive } from '../../internal/primitive';
import { provideCalendarGridContext, useCalendarRootContext } from './context';
const { as = 'table', month } = defineProps<CalendarGridProps>();
const ctx = useCalendarRootContext();
const monthRef = toRef(() => month ?? ctx.placeholder.value);
provideCalendarGridContext({ month: monthRef });
const readonly = computed(() => ctx.readonly.value || undefined);
const disabled = computed(() => ctx.disabled.value || undefined);
</script>
<template>
<Primitive
:as="as"
role="grid"
tabindex="-1"
:aria-label="ctx.fullCalendarLabel.value"
:aria-readonly="readonly ? true : undefined"
:aria-disabled="disabled ? true : undefined"
:data-primitives-calendar-grid="''"
:data-readonly="readonly ? '' : undefined"
:data-disabled="disabled ? '' : undefined"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The grid's `<tbody>` wrapper containing the week rows (`CalendarGridRow`) of
* day cells.
*/
export interface CalendarGridBodyProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
const { as = 'tbody' } = defineProps<CalendarGridBodyProps>();
</script>
<template>
<Primitive :as="as" :data-primitives-calendar-grid-body="''">
<slot />
</Primitive>
</template>
@@ -0,0 +1,22 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The grid's `<thead>` wrapper holding the row of weekday `CalendarHeadCell`
* labels. Marked `aria-hidden` since each day cell already carries its full
* accessible label, avoiding a double weekday-column announcement.
*/
export interface CalendarGridHeadProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
const { as = 'thead' } = defineProps<CalendarGridHeadProps>();
</script>
<template>
<Primitive :as="as" aria-hidden="true" :data-primitives-calendar-grid-head="''">
<slot />
</Primitive>
</template>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A single table row (`<tr>`) representing one week of the month, or the
* weekday-label row inside the grid head.
*/
export interface CalendarGridRowProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
const { as = 'tr' } = defineProps<CalendarGridRowProps>();
</script>
<template>
<Primitive :as="as" :data-primitives-calendar-grid-row="''">
<slot />
</Primitive>
</template>
@@ -0,0 +1,36 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A `scope="col"` weekday header cell (`<th>`). Renders the localized short
* label in its slot while exposing the full weekday name as the `aria-label`
* when a `day` is provided.
*/
export interface CalendarHeadCellProps extends PrimitiveProps {
/** The day this header cell represents — used for `aria-label`. */
day?: Date;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useCalendarRootContext } from './context';
const { as = 'th', day } = defineProps<CalendarHeadCellProps>();
const ctx = useCalendarRootContext();
const adapter = ctx.dateAdapter;
const longLabel = computed(() => (day ? adapter.value.formatWeekday(day, ctx.locale.value, 'long') : undefined));
</script>
<template>
<Primitive
:as="as"
scope="col"
:aria-label="longLabel"
:data-primitives-calendar-head-cell="''"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Layout container for the calendar's top bar. Holds the `CalendarPrev`,
* `CalendarHeading`, and `CalendarNext` controls above the month grid(s).
*/
export interface CalendarHeaderProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
const { as = 'div' } = defineProps<CalendarHeaderProps>();
</script>
<template>
<Primitive :as="as" :data-primitives-calendar-header="''">
<slot />
</Primitive>
</template>
@@ -0,0 +1,37 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Displays the currently visible month and year (e.g. "June 2026"), or a range
* when multiple months are shown. Marked `aria-hidden` since the grid already
* carries the full accessible label; expose the value via its default slot to
* customize the rendering.
*/
export interface CalendarHeadingProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { useCalendarRootContext } from './context';
const { as = 'div' } = defineProps<CalendarHeadingProps>();
defineSlots<{
default?: (props: { headingValue: string }) => unknown;
}>();
const ctx = useCalendarRootContext();
</script>
<template>
<Primitive
:as="as"
aria-hidden="true"
:data-primitives-calendar-heading="''"
:data-disabled="ctx.disabled.value ? '' : undefined"
>
<slot :heading-value="ctx.headingValue.value">
{{ ctx.headingValue.value }}
</slot>
</Primitive>
</template>
@@ -0,0 +1,50 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Button that pages the calendar forward (by one month, or by
* `numberOfMonths` when paged navigation is enabled). Auto-disables when the
* next page would fall after `maxValue` or the calendar is disabled.
*/
export interface CalendarNextProps extends PrimitiveProps {
/** Override the root's `nextPage` for just this button. */
nextPage?: (placeholder: Date) => Date;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useCalendarRootContext } from './context';
const { as = 'button', nextPage: nextPageProp } = defineProps<CalendarNextProps>();
defineSlots<{
default?: (props: { disabled: boolean }) => unknown;
}>();
const ctx = useCalendarRootContext();
const disabled = computed(() => ctx.disabled.value || ctx.isNextButtonDisabled(nextPageProp));
function handleClick() {
if (disabled.value) return;
ctx.nextPage(nextPageProp);
}
</script>
<template>
<Primitive
:as="as"
:type="as === 'button' ? 'button' : undefined"
aria-label="Next"
:aria-disabled="disabled || undefined"
:data-primitives-calendar-next="''"
:data-disabled="disabled ? '' : undefined"
:disabled="as === 'button' ? disabled : undefined"
@click="handleClick"
>
<slot :disabled="disabled">
Next
</slot>
</Primitive>
</template>
@@ -0,0 +1,50 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Button that pages the calendar backward (by one month, or by
* `numberOfMonths` when paged navigation is enabled). Auto-disables when the
* previous page would fall before `minValue` or the calendar is disabled.
*/
export interface CalendarPrevProps extends PrimitiveProps {
/** Override the root's `prevPage` for just this button. */
prevPage?: (placeholder: Date) => Date;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useCalendarRootContext } from './context';
const { as = 'button', prevPage: prevPageProp } = defineProps<CalendarPrevProps>();
defineSlots<{
default?: (props: { disabled: boolean }) => unknown;
}>();
const ctx = useCalendarRootContext();
const disabled = computed(() => ctx.disabled.value || ctx.isPrevButtonDisabled(prevPageProp));
function handleClick() {
if (disabled.value) return;
ctx.prevPage(prevPageProp);
}
</script>
<template>
<Primitive
:as="as"
:type="as === 'button' ? 'button' : undefined"
aria-label="Previous"
:aria-disabled="disabled || undefined"
:data-primitives-calendar-prev="''"
:data-disabled="disabled ? '' : undefined"
:disabled="as === 'button' ? disabled : undefined"
@click="handleClick"
>
<slot :disabled="disabled">
Previous
</slot>
</Primitive>
</template>
@@ -0,0 +1,420 @@
<script lang="ts">
import type { DateAdapter } from '../../utilities/config-provider';
import type { PrimitiveProps } from '../../internal/primitive';
import type { CalendarMonth, WeekDayFormat } from './utils';
/**
* A fully accessible, headless date calendar for picking a single day. The
* root owns the selected value and the displayed month ("placeholder"), builds
* the localized month grid(s), and wires up roving keyboard navigation,
* min/max bounds, and disabled/unavailable predicates. Use it to build an
* inline date picker or as the body of a popover/`DatePicker`.
*
* Compose it with `CalendarHeader` (`CalendarPrev` / `CalendarHeading` /
* `CalendarNext`) and one `CalendarGrid` per month. Supports `v-model` for the
* selected date and `v-model:placeholder` for the visible month.
*/
export interface CalendarRootProps extends PrimitiveProps {
/** Uncontrolled default selected date (or dates when `multiple`). */
defaultValue?: Date | Date[];
/** Uncontrolled default placeholder (displayed month). */
defaultPlaceholder?: Date;
/** Minimum selectable date. */
minValue?: Date;
/** Maximum selectable date. */
maxValue?: Date;
/** Predicate marking a date as unavailable (not selectable). */
isDateUnavailable?: (date: Date) => boolean;
/** Predicate marking a date as disabled. */
isDateDisabled?: (date: Date) => boolean;
/** Prev/Next navigate by `numberOfMonths` instead of one month. @default false */
pagedNavigation?: boolean;
/** First day of week (0=Sun ... 6=Sat). @default 0 */
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
/** Width of localized weekday names. @default 'short' */
weekdayFormat?: WeekDayFormat;
/** Always render 6 weeks per month. @default true */
fixedWeeks?: boolean;
/** Number of months displayed simultaneously. @default 1 */
numberOfMonths?: number;
/** Disable the whole calendar. @default false */
disabled?: boolean;
/** Make the calendar read-only. @default false */
readonly?: boolean;
/** Auto-focus the calendar on mount. @default false */
initialFocus?: boolean;
/** Locale for `Intl` formatting. @default 'en' */
locale?: string;
/** Reading direction. */
dir?: 'ltr' | 'rtl';
/** Override "next page" navigation logic. */
nextPage?: (placeholder: Date) => Date;
/** Override "prev page" navigation logic. */
prevPage?: (placeholder: Date) => Date;
/** Calendar accessible label prefix. @default 'Calendar' */
calendarLabel?: string;
/** Allow selecting multiple dates; model becomes a `Date[]`. @default false */
multiple?: boolean;
/** Prevent deselecting the last selected date by re-clicking it. @default false */
preventDeselect?: boolean;
/** Disable days that belong to adjacent months (outside the current view). @default false */
disableDaysOutsideCurrentView?: boolean;
/**
* Pluggable date backend driving all date math/formatting. Falls back to the
* app `ConfigProvider` `dateAdapter` (native `Date`) when omitted.
*/
dateAdapter?: DateAdapter<Date>;
}
export interface CalendarRootEmits {
'update:modelValue': [date: Date | Date[] | undefined];
'update:placeholder': [date: Date];
}
</script>
<script setup lang="ts">
import { useEventListener, useForwardExpose } from '@robonen/vue';
import { computed, onMounted, ref, toRef, watch } from 'vue';
import { useDateAdapter } from '../../utilities/config-provider';
import { Primitive } from '../../internal/primitive';
import { provideCalendarRootContext } from './context';
const {
as = 'div',
defaultValue,
defaultPlaceholder,
minValue,
maxValue,
isDateUnavailable: propsIsDateUnavailable,
isDateDisabled: propsIsDateDisabled,
pagedNavigation = false,
weekStartsOn: weekStartsOnProp,
weekdayFormat = 'short',
fixedWeeks = true,
numberOfMonths = 1,
disabled = false,
readonly = false,
initialFocus = false,
locale = 'en',
dir = 'ltr',
nextPage: propsNextPage,
prevPage: propsPrevPage,
calendarLabel = 'Calendar',
multiple = false,
preventDeselect = false,
disableDaysOutsideCurrentView = false,
dateAdapter,
} = defineProps<CalendarRootProps>();
defineEmits<CalendarRootEmits>();
defineSlots<{
default?: (props: {
date: Date;
grid: CalendarMonth[];
weekDays: string[];
weekStartsOn: number;
locale: string;
modelValue: Date | Date[] | undefined;
}) => unknown;
}>();
// Resolve the effective date backend: per-instance prop wins over the global
// `ConfigProvider` adapter, falling back to the native `Date` adapter.
const adapter = useDateAdapter(() => dateAdapter);
const localValue = ref<Date | Date[] | undefined>(defaultValue);
const modelValue = defineModel<Date | Date[] | undefined>('modelValue', {
default: undefined,
get: v => v ?? localValue.value,
set: (v) => {
localValue.value = v;
return v;
},
});
function lastSelected(v: Date | Date[] | undefined): Date | undefined {
if (Array.isArray(v)) return v.at(-1);
return v;
}
const localPlaceholder = ref<Date>(
adapter.value.toDateOnly(defaultPlaceholder ?? lastSelected(modelValue.value) ?? adapter.value.now()),
);
const placeholder = defineModel<Date>('placeholder', {
default: undefined,
get: v => v ?? localPlaceholder.value,
set: (v) => {
localPlaceholder.value = adapter.value.toDateOnly(v);
return localPlaceholder.value;
},
});
const { forwardRef, currentElement: parentElement } = useForwardExpose();
const focusedDate = ref<Date | undefined>();
const localeRef = toRef(() => locale);
const dirRef = toRef(() => dir);
// Locale-aware default: when `weekStartsOn` is omitted, derive it from the
// locale (e.g. 'en-US' → Sunday, 'fr'/'de' → Monday).
const weekStartsOn = computed<0 | 1 | 2 | 3 | 4 | 5 | 6>(() =>
weekStartsOnProp ?? adapter.value.getLocaleWeekStartsOn(locale),
);
const weekdayFormatRef = toRef(() => weekdayFormat);
const fixedWeeksRef = toRef(() => fixedWeeks);
const numberOfMonthsRef = toRef(() => numberOfMonths);
const disabledRef = toRef(() => disabled);
const readonlyRef = toRef(() => readonly);
const pagedNavigationRef = toRef(() => pagedNavigation);
const multipleRef = toRef(() => multiple);
const preventDeselectRef = toRef(() => preventDeselect);
const disableDaysOutsideCurrentViewRef = toRef(() => disableDaysOutsideCurrentView);
const minValueRef = toRef(() => minValue);
const maxValueRef = toRef(() => maxValue);
const grid = computed<CalendarMonth[]>(() => adapter.value.createMonths({
date: placeholder.value,
numberOfMonths,
weekStartsOn: weekStartsOn.value,
fixedWeeks,
}));
const weekDays = computed(() => adapter.value.getWeekdayLabels(weekStartsOn.value, locale, weekdayFormat));
const headingValue = computed(() => {
const months = grid.value;
if (!months.length) return '';
if (months.length === 1) return adapter.value.formatMonthYear(months[0]!.value, locale);
const first = adapter.value.formatMonthYear(months[0]!.value, locale);
const last = adapter.value.formatMonthYear(months[months.length - 1]!.value, locale);
return `${first} - ${last}`;
});
const fullCalendarLabel = computed(() => `${calendarLabel}, ${headingValue.value}`);
function isDateDisabled(date: Date): boolean {
if (disabled) return true;
if (propsIsDateDisabled?.(date)) return true;
if (minValue && adapter.value.isBefore(date, minValue)) return true;
if (maxValue && adapter.value.isAfter(date, maxValue)) return true;
return false;
}
function isDateUnavailableLocal(date: Date): boolean {
return adapter.value.isDateUnavailable(date, propsIsDateUnavailable, minValue, maxValue);
}
function isDateSelected(date: Date): boolean {
const v = modelValue.value;
if (Array.isArray(v)) return v.some(d => adapter.value.isSameDay(d, date));
return v ? adapter.value.isSameDay(v, date) : false;
}
function isOutsideVisibleView(date: Date): boolean {
return !grid.value.some(m => adapter.value.isSameMonth(m.value, date));
}
const isInvalid = computed(() => {
const v = modelValue.value;
if (Array.isArray(v)) {
if (!v.length) return false;
return v.some(d => isDateDisabled(d) || isDateUnavailableLocal(d));
}
if (!v) return false;
return isDateDisabled(v) || isDateUnavailableLocal(v);
});
function setDate(date: Date | undefined) {
if (readonly) return;
if (date && (isDateDisabled(date) || isDateUnavailableLocal(date))) return;
if (!multiple) {
// Single mode: re-clicking the selected date deselects it unless prevented.
if (date && !preventDeselect && !Array.isArray(modelValue.value)
&& modelValue.value && adapter.value.isSameDay(modelValue.value, date)) {
placeholder.value = date;
modelValue.value = undefined;
return;
}
modelValue.value = date ? adapter.value.toDateOnly(date) : undefined;
return;
}
// Multiple mode: toggle membership, keeping a fresh array reference.
const current = Array.isArray(modelValue.value)
? modelValue.value
: modelValue.value
? [modelValue.value]
: [];
if (!date) {
modelValue.value = [];
return;
}
const normalized = adapter.value.toDateOnly(date);
const idx = current.findIndex(d => adapter.value.isSameDay(d, normalized));
if (idx === -1) {
modelValue.value = [...current, normalized];
return;
}
if (preventDeselect) return;
const next = current.filter((_, i) => i !== idx);
modelValue.value = next.length ? next : undefined;
}
function setPlaceholder(date: Date) {
placeholder.value = adapter.value.clamp(date, minValue, maxValue);
}
function pageStep(): number {
return pagedNavigation ? numberOfMonths : 1;
}
function nextPage(fn?: (placeholder: Date) => Date) {
const fnToUse = fn ?? propsNextPage;
placeholder.value = fnToUse
? adapter.value.toDateOnly(fnToUse(placeholder.value))
: adapter.value.addMonths(placeholder.value, pageStep());
}
function prevPage(fn?: (placeholder: Date) => Date) {
const fnToUse = fn ?? propsPrevPage;
placeholder.value = fnToUse
? adapter.value.toDateOnly(fnToUse(placeholder.value))
: adapter.value.addMonths(placeholder.value, -pageStep());
}
function nextYear() {
placeholder.value = adapter.value.addYears(placeholder.value, 1);
}
function prevYear() {
placeholder.value = adapter.value.addYears(placeholder.value, -1);
}
function isNextButtonDisabled(fn?: (placeholder: Date) => Date): boolean {
if (disabled) return true;
if (!maxValue) return false;
const lastMonth = grid.value[grid.value.length - 1]?.value;
if (!lastMonth) return false;
const fnToUse = fn ?? propsNextPage;
const probe = fnToUse
? adapter.value.toDateOnly(fnToUse(placeholder.value))
: adapter.value.addMonths(lastMonth, 1);
return adapter.value.isAfter(probe, maxValue);
}
function isPrevButtonDisabled(fn?: (placeholder: Date) => Date): boolean {
if (disabled) return true;
if (!minValue) return false;
const firstMonth = grid.value[0]?.value;
if (!firstMonth) return false;
const fnToUse = fn ?? propsPrevPage;
const probe = fnToUse
? adapter.value.toDateOnly(fnToUse(placeholder.value))
: adapter.value.addMonths(firstMonth, -1);
return adapter.value.isBefore(probe, minValue);
}
watch(modelValue, (v) => {
const last = lastSelected(v);
if (last && !adapter.value.isSameMonth(last, placeholder.value))
placeholder.value = adapter.value.toDateOnly(last);
});
const hasSelectedDate = computed(() => {
const v = modelValue.value;
return Array.isArray(v) ? v.length > 0 : !!v;
});
// First in-view, non-disabled, non-unavailable date — the roving-focus fallback
// so the initial tab stop is always actionable.
const firstFocusableDate = computed(() =>
adapter.value.findFirstFocusableDate(grid.value, isDateDisabled, isDateUnavailableLocal),
);
onMounted(() => {
if (!initialFocus || !parentElement.value) return;
const target = parentElement.value.querySelector<HTMLElement>(
'[data-primitives-calendar-cell-trigger][data-selected]'
+ ',[data-primitives-calendar-cell-trigger][data-today]'
+ ',[data-primitives-calendar-cell-trigger]:not([data-outside-view]):not([data-disabled])',
);
target?.focus();
});
useEventListener(parentElement, 'focusout', (e) => {
if (!parentElement.value?.contains(e.relatedTarget as Node | null))
focusedDate.value = undefined;
});
provideCalendarRootContext({
dateAdapter: adapter,
modelValue,
placeholder,
locale: localeRef,
dir: dirRef,
grid,
weekDays,
headingValue,
fullCalendarLabel,
weekStartsOn,
weekdayFormat: weekdayFormatRef,
fixedWeeks: fixedWeeksRef,
numberOfMonths: numberOfMonthsRef,
disabled: disabledRef,
readonly: readonlyRef,
pagedNavigation: pagedNavigationRef,
multiple: multipleRef,
preventDeselect: preventDeselectRef,
disableDaysOutsideCurrentView: disableDaysOutsideCurrentViewRef,
minValue: minValueRef,
maxValue: maxValueRef,
isDateDisabled,
isDateUnavailable: isDateUnavailableLocal,
isDateSelected,
isOutsideVisibleView,
isInvalid,
hasSelectedDate,
firstFocusableDate,
parentElement,
focusedDate,
setDate,
setPlaceholder,
nextPage,
prevPage,
nextYear,
prevYear,
isNextButtonDisabled,
isPrevButtonDisabled,
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="application"
:aria-label="fullCalendarLabel"
:dir="dir"
:data-primitives-calendar-root="''"
:data-disabled="disabled ? '' : undefined"
:data-readonly="readonly ? '' : undefined"
:data-invalid="isInvalid ? '' : undefined"
>
<slot
:date="placeholder"
:grid="grid"
:week-days="weekDays"
:week-starts-on="weekStartsOn"
:locale="locale"
:model-value="modelValue"
/>
<div
:data-primitives-calendar-sr-heading="''"
style="border:0;clip:rect(0 0 0 0);clip-path:inset(50%);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;white-space:nowrap;width:1px;"
>
<div
role="heading"
aria-level="2"
>
{{ fullCalendarLabel }}
</div>
</div>
</Primitive>
</template>
@@ -0,0 +1,107 @@
import type { CalendarMonth } from '../utils';
import { mount } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import {
CalendarCell,
CalendarCellTrigger,
CalendarGrid,
CalendarGridBody,
CalendarGridRow,
CalendarRoot,
} from '../index';
import { createMonths, toIsoDate } from '../utils';
function mountCalendar(
props: Record<string, unknown> = {},
options: Record<string, unknown> = {},
) {
return mount(defineComponent({
setup: () => () => h(CalendarRoot, props, {
default: ({ grid }: { grid: CalendarMonth[] }) => grid.map(month =>
h(CalendarGrid, { key: month.value.toString(), month: month.value }, {
default: () => h(CalendarGridBody, null, {
default: () => month.weeks.map((week, w) =>
h(CalendarGridRow, { key: w }, {
default: () => week.map(day =>
h(CalendarCell, { key: day.toString(), date: day }, {
default: () => h(CalendarCellTrigger, { day, month: month.value }),
})),
})),
}),
})),
}),
}), options);
}
describe('Calendar', () => {
it('forwards consumer attrs (class) to the root element', () => {
const w = mountCalendar({
class: 'my-cal',
'data-x': 'y',
defaultPlaceholder: new Date(2026, 5, 1),
});
const root = w.find('[data-primitives-calendar-root]');
expect(root.classes()).toContain('my-cal');
expect(root.attributes('data-x')).toBe('y');
});
it('mounts cell triggers without "expose() should be called only once" warnings', () => {
const warn = vi.spyOn(console, 'warn');
mountCalendar({ defaultPlaceholder: new Date(2026, 5, 1) });
const exposeWarnings = warn.mock.calls
.filter(args => String(args[0]).includes('expose() should be called only once'));
expect(exposeWarnings).toHaveLength(0);
warn.mockRestore();
});
it('data-value matches the local calendar date of each cell', () => {
const w = mountCalendar({ defaultPlaceholder: new Date(2026, 5, 1) });
const triggers = w.findAll(
'[data-primitives-calendar-cell-trigger]:not([data-outside-view])',
);
expect(triggers).toHaveLength(30); // June 2026
for (const t of triggers)
expect(t.attributes('data-value')).toBe(`2026-06-${t.text().padStart(2, '0')}`);
});
it('renders 6 weeks by default; :fixed-weeks="false" trims trailing outside-month weeks', () => {
// February 2026 starts on Sunday and has 28 days — exactly 4 weeks.
const placeholder = new Date(2026, 1, 1);
const fixed = mountCalendar({ defaultPlaceholder: placeholder });
expect(fixed.findAll('[data-primitives-calendar-grid-row]')).toHaveLength(6);
const trimmed = mountCalendar({ defaultPlaceholder: placeholder, fixedWeeks: false });
expect(trimmed.findAll('[data-primitives-calendar-grid-row]')).toHaveLength(4);
expect(trimmed.findAll('[data-primitives-calendar-cell-trigger]')).toHaveLength(28);
});
it('allows arrow-key focus onto the min-value day when minValue has a time component', async () => {
const w = mountCalendar({
defaultPlaceholder: new Date(2026, 5, 1),
minValue: new Date(2026, 5, 10, 12, 30),
}, { attachTo: document.body });
const from = w.find('[data-value="2026-06-11"]:not([data-outside-view])');
(from.element as HTMLElement).focus();
await from.trigger('keydown', { key: 'ArrowLeft' });
await nextTick();
expect(document.activeElement?.getAttribute('data-value')).toBe('2026-06-10');
w.unmount();
});
});
describe('createMonths', () => {
it('keeps 6 weeks when fixedWeeks (default) and trims trailing outside-month weeks otherwise', () => {
const feb = new Date(2026, 1, 10); // February 2026: exactly 4 in-month weeks
expect(createMonths({ date: feb, numberOfMonths: 1, weekStartsOn: 0 })[0]!.weeks)
.toHaveLength(6);
const trimmed = createMonths({ date: feb, numberOfMonths: 1, weekStartsOn: 0, fixedWeeks: false })[0]!.weeks;
expect(trimmed).toHaveLength(4);
expect(toIsoDate(trimmed[0]![0]!)).toBe('2026-02-01');
expect(toIsoDate(trimmed[3]![6]!)).toBe('2026-02-28');
// August 2026 genuinely spans 6 weeks — nothing to trim.
const aug = createMonths({ date: new Date(2026, 7, 1), numberOfMonths: 1, weekStartsOn: 0, fixedWeeks: false })[0]!.weeks;
expect(aug).toHaveLength(6);
});
});
@@ -0,0 +1,270 @@
import type { CalendarMonth } from '../utils';
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import {
CalendarCell,
CalendarCellTrigger,
CalendarGrid,
CalendarGridBody,
CalendarGridHead,
CalendarGridRow,
CalendarHeadCell,
CalendarRoot,
} from '../index';
import { findFirstFocusableDate, getLocaleWeekStartsOn, toIsoDate } from '../utils';
function mountCalendar(
props: Record<string, unknown> = {},
options: Record<string, unknown> = {},
) {
return mount(defineComponent({
setup: () => () => h(CalendarRoot, props, {
default: ({ grid, weekDays }: { grid: CalendarMonth[]; weekDays: string[] }) =>
grid.map(month =>
h(CalendarGrid, { key: month.value.toString(), month: month.value }, {
default: () => [
h(CalendarGridHead, null, {
default: () => h(CalendarGridRow, null, {
default: () => weekDays.map((wd, i) => h(CalendarHeadCell, { key: i }, () => wd)),
}),
}),
h(CalendarGridBody, null, {
default: () => month.weeks.map((week, w) =>
h(CalendarGridRow, { key: w }, {
default: () => week.map(day =>
h(CalendarCell, { key: day.toString(), date: day }, {
default: () => h(CalendarCellTrigger, { day, month: month.value }),
})),
})),
}),
],
})),
}),
}), options);
}
function press(el: Element, key: string) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
}
describe('Calendar — accessibility skeleton', () => {
it('renders an SR-only role=heading mirroring the calendar label', () => {
const w = mountCalendar({ defaultPlaceholder: new Date(2026, 5, 1) });
const srHeading = w.find('[data-primitives-calendar-sr-heading] [role="heading"]');
expect(srHeading.exists()).toBe(true);
expect(srHeading.attributes('aria-level')).toBe('2');
expect(srHeading.text()).toContain('2026');
w.unmount();
});
it('marks the grid head as aria-hidden', () => {
const w = mountCalendar({ defaultPlaceholder: new Date(2026, 5, 1) });
const head = w.find('[data-primitives-calendar-grid-head]');
expect(head.attributes('aria-hidden')).toBe('true');
w.unmount();
});
});
describe('Calendar — single mode deselect', () => {
it('re-clicking the selected date clears the model (deselect-on-reclick)', async () => {
const selected = new Date(2026, 5, 15);
const w = mountCalendar({ defaultValue: selected, defaultPlaceholder: selected });
const cell = w.find('[data-value="2026-06-15"]:not([data-outside-view])');
await cell.trigger('click');
await nextTick();
expect(w.find('[data-value="2026-06-15"][data-selected]').exists()).toBe(false);
w.unmount();
});
it('preventDeselect keeps the selected date on re-click', async () => {
const selected = new Date(2026, 5, 15);
const w = mountCalendar({ defaultValue: selected, defaultPlaceholder: selected, preventDeselect: true });
const cell = w.find('[data-value="2026-06-15"]:not([data-outside-view])');
await cell.trigger('click');
await nextTick();
expect(w.find('[data-value="2026-06-15"]:not([data-outside-view])').attributes('data-selected')).toBe('');
w.unmount();
});
});
describe('Calendar — multiple mode', () => {
it('toggles membership: click adds, re-click removes', async () => {
const w = mountCalendar({ multiple: true, defaultPlaceholder: new Date(2026, 5, 1) });
const a = () => w.find('[data-value="2026-06-10"]:not([data-outside-view])');
const b = () => w.find('[data-value="2026-06-20"]:not([data-outside-view])');
await a().trigger('click');
await b().trigger('click');
await nextTick();
expect(a().attributes('data-selected')).toBe('');
expect(b().attributes('data-selected')).toBe('');
await a().trigger('click');
await nextTick();
expect(a().attributes('data-selected')).toBeUndefined();
expect(b().attributes('data-selected')).toBe('');
w.unmount();
});
it('accepts an array defaultValue and marks each as selected', async () => {
const w = mountCalendar({
multiple: true,
defaultValue: [new Date(2026, 5, 5), new Date(2026, 5, 12)],
defaultPlaceholder: new Date(2026, 5, 1),
});
await nextTick();
expect(w.find('[data-value="2026-06-05"]:not([data-outside-view])').attributes('data-selected')).toBe('');
expect(w.find('[data-value="2026-06-12"]:not([data-outside-view])').attributes('data-selected')).toBe('');
w.unmount();
});
it('preventDeselect in multiple mode blocks removal but allows addition', async () => {
const w = mountCalendar({
multiple: true,
preventDeselect: true,
defaultValue: [new Date(2026, 5, 5)],
defaultPlaceholder: new Date(2026, 5, 1),
});
const existing = () => w.find('[data-value="2026-06-05"]:not([data-outside-view])');
await existing().trigger('click'); // attempt removal — blocked
await nextTick();
expect(existing().attributes('data-selected')).toBe('');
const next = () => w.find('[data-value="2026-06-09"]:not([data-outside-view])');
await next().trigger('click'); // addition allowed
await nextTick();
expect(next().attributes('data-selected')).toBe('');
w.unmount();
});
});
describe('Calendar — disableDaysOutsideCurrentView', () => {
it('disables adjacent-month days when enabled', () => {
const w = mountCalendar({
disableDaysOutsideCurrentView: true,
defaultPlaceholder: new Date(2026, 5, 1), // June 2026 starts on a Monday → leading outside days
});
const outside = w.findAll('[data-primitives-calendar-cell-trigger][data-outside-view]');
expect(outside.length).toBeGreaterThan(0);
for (const t of outside)
expect(t.attributes('data-disabled')).toBe('');
w.unmount();
});
it('leaves adjacent-month days enabled by default', () => {
const w = mountCalendar({ defaultPlaceholder: new Date(2026, 5, 1) });
const outside = w.findAll('[data-primitives-calendar-cell-trigger][data-outside-view]');
for (const t of outside)
expect(t.attributes('data-disabled')).toBeUndefined();
w.unmount();
});
});
describe('Calendar — keyboard skips disabled days', () => {
it('ArrowRight jumps over a disabled day to the next available one', async () => {
const w = mountCalendar({
defaultPlaceholder: new Date(2026, 5, 1),
isDateDisabled: (d: Date) => d.getDate() === 11, // disable June 11
}, { attachTo: document.body });
const from = w.find('[data-value="2026-06-10"]:not([data-outside-view])');
(from.element as HTMLElement).focus();
press(from.element, 'ArrowRight');
await nextTick();
await nextTick();
expect(document.activeElement?.getAttribute('data-value')).toBe('2026-06-12');
w.unmount();
});
it('intra-month arrow navigation does not shift a multi-month visible window', async () => {
const w = mountCalendar({
defaultPlaceholder: new Date(2026, 5, 1),
numberOfMonths: 2,
}, { attachTo: document.body });
// Both June and July grids are present before navigation.
expect(w.findAll('[data-value="2026-06-15"]:not([data-outside-view])').length).toBe(1);
expect(w.findAll('[data-value="2026-07-15"]:not([data-outside-view])').length).toBe(1);
const from = w.find('[data-value="2026-06-15"]:not([data-outside-view])');
(from.element as HTMLElement).focus();
press(from.element, 'ArrowRight');
await nextTick();
await nextTick();
// Window unchanged: July is still rendered, June 16 received focus.
expect(document.activeElement?.getAttribute('data-value')).toBe('2026-06-16');
expect(w.findAll('[data-value="2026-07-15"]:not([data-outside-view])').length).toBe(1);
w.unmount();
});
});
describe('Calendar — roving fallback tabindex', () => {
it('initial tab stop skips disabled leading days and lands on the first available day', () => {
// Disable the entire first half of June; tab stop should land on the 16th.
const w = mountCalendar({
defaultPlaceholder: new Date(2026, 5, 1),
isDateDisabled: (d: Date) => d.getMonth() === 5 && d.getDate() < 16,
});
const focusable = w.findAll('[data-primitives-calendar-cell-trigger][tabindex="0"]');
expect(focusable).toHaveLength(1);
expect(focusable[0]!.attributes('data-value')).toBe('2026-06-16');
w.unmount();
});
});
describe('Calendar — outside-visible-view marker', () => {
it('marks single-month padding days (from adjacent months) as outside-visible-view', () => {
const w = mountCalendar({ defaultPlaceholder: new Date(2026, 5, 1), numberOfMonths: 1 });
// With one visible month, an adjacent-month padding day is both
// outside-view and outside-visible-view, and every such pair must agree.
const outsideView = w.findAll('[data-primitives-calendar-cell-trigger][data-outside-view]');
const outsideVisible = w.findAll('[data-primitives-calendar-cell-trigger][data-outside-visible-view]');
expect(outsideVisible.length).toBe(outsideView.length);
expect(outsideVisible.length).toBeGreaterThan(0);
w.unmount();
});
it('a padding day belonging to a second visible month is outside-view but not outside-visible-view', () => {
// June + July 2026 visible. July 1 appears as a padding cell in June's grid:
// outside June's view, yet July is a visible month → not outside-visible-view.
const w = mountCalendar({ defaultPlaceholder: new Date(2026, 5, 1), numberOfMonths: 2 });
const julyFirstPadding = w.findAll('[data-value="2026-07-01"][data-outside-view]');
expect(julyFirstPadding.length).toBeGreaterThan(0);
for (const t of julyFirstPadding)
expect(t.attributes('data-outside-visible-view')).toBeUndefined();
w.unmount();
});
});
describe('Calendar — locale-aware week start', () => {
it('defaults weekStartsOn from the locale when the prop is omitted', () => {
expect(getLocaleWeekStartsOn('en-US')).toBe(0);
expect(getLocaleWeekStartsOn('fr-FR')).toBe(1);
expect(getLocaleWeekStartsOn('de-DE')).toBe(1);
});
it('an explicit weekStartsOn overrides the locale default', () => {
const w = mountCalendar({ defaultPlaceholder: new Date(2026, 5, 1), locale: 'fr-FR', weekStartsOn: 0 });
// weekStartsOn=0 (Sunday): first cell of the first week is a Sunday.
const firstTrigger = w.find('[data-primitives-calendar-cell-trigger]');
const iso = firstTrigger.attributes('data-value')!;
expect(new Date(`${iso}T00:00:00`).getDay()).toBe(0);
w.unmount();
});
});
describe('findFirstFocusableDate', () => {
it('returns the first non-disabled, non-unavailable in-view date', () => {
const months = [{ value: new Date(2026, 5, 1), weeks: [] }];
const first = findFirstFocusableDate(
months,
d => d.getDate() < 5,
() => false,
);
expect(first && toIsoDate(first)).toBe('2026-06-05');
});
it('returns undefined when every day is blocked', () => {
const months = [{ value: new Date(2026, 5, 1), weeks: [] }];
expect(findFirstFocusableDate(months, () => true, () => false)).toBeUndefined();
});
});
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import {
addMonths,
getWeeks,
isDateUnavailable,
isSameDay,
startOfWeek,
toIsoDate,
} from '../date-utils';
describe('date-utils', () => {
it('getWeeks returns 6 rows × 7 cols', () => {
const weeks = getWeeks(new Date(2024, 0, 15), 0);
expect(weeks).toHaveLength(6);
for (const row of weeks)
expect(row).toHaveLength(7);
});
it('startOfWeek respects weekStartsOn', () => {
// 2024-01-10 is a Wednesday.
const wed = new Date(2024, 0, 10);
expect(startOfWeek(wed, 0).getDay()).toBe(0);
expect(startOfWeek(wed, 1).getDay()).toBe(1);
});
it('addMonths clamps Jan 31 → Feb 28/29', () => {
const r = addMonths(new Date(2023, 0, 31), 1);
expect(r.getMonth()).toBe(1);
expect(r.getDate()).toBe(28);
});
it('isSameDay ignores time component', () => {
const a = new Date(2024, 5, 1, 1, 2, 3);
const b = new Date(2024, 5, 1, 23, 59);
expect(isSameDay(a, b)).toBe(true);
expect(isSameDay(a, new Date(2024, 5, 2))).toBe(false);
});
it('toIsoDate formats from local date fields, regardless of timezone', () => {
// toISOString would shift local midnight to the previous UTC day east of UTC.
expect(toIsoDate(new Date(2026, 5, 15))).toBe('2026-06-15');
expect(toIsoDate(new Date(2026, 0, 5))).toBe('2026-01-05');
expect(toIsoDate(new Date(2026, 5, 15, 23, 59, 59))).toBe('2026-06-15');
});
it('isDateUnavailable honors min/max and predicate', () => {
const min = new Date(2024, 0, 5);
const max = new Date(2024, 0, 25);
expect(isDateUnavailable(new Date(2024, 0, 1), undefined, min, max)).toBe(true);
expect(isDateUnavailable(new Date(2024, 0, 31), undefined, min, max)).toBe(true);
expect(isDateUnavailable(new Date(2024, 0, 10), undefined, min, max)).toBe(false);
expect(isDateUnavailable(new Date(2024, 0, 10), d => d.getDate() === 10)).toBe(true);
});
});
@@ -0,0 +1,80 @@
import type { ComputedRef, Ref } from 'vue';
import type { DateAdapter } from '../../utilities/config-provider';
import type { CalendarMonth, WeekDayFormat } from './utils';
import { useContextFactory } from '@robonen/vue';
export interface CalendarRootContext {
/** Resolved date backend (root `dateAdapter` prop or the global `ConfigProvider`). */
dateAdapter: ComputedRef<DateAdapter<Date>>;
/** Currently selected date, dates (when `multiple`), or undefined. */
modelValue: Ref<Date | Date[] | undefined>;
/** Displayed month anchor. */
placeholder: Ref<Date>;
/** Locale identifier for `Intl` formatting. */
locale: Ref<string>;
/** Reading direction. */
dir: Ref<'ltr' | 'rtl'>;
/** Computed grid of months (each with 6×7 weeks). */
grid: ComputedRef<CalendarMonth[]>;
/** Localized weekday labels (length 7). */
weekDays: ComputedRef<string[]>;
/** Heading text (month + year). */
headingValue: ComputedRef<string>;
/** Full aria-label for the calendar region. */
fullCalendarLabel: ComputedRef<string>;
weekStartsOn: Ref<0 | 1 | 2 | 3 | 4 | 5 | 6>;
weekdayFormat: Ref<WeekDayFormat>;
fixedWeeks: Ref<boolean>;
numberOfMonths: Ref<number>;
disabled: Ref<boolean>;
readonly: Ref<boolean>;
pagedNavigation: Ref<boolean>;
/** Whether multiple dates may be selected. */
multiple: Ref<boolean>;
/** Whether re-clicking the last selected date is prevented from deselecting it. */
preventDeselect: Ref<boolean>;
/** Whether adjacent-month days (outside the current view) are disabled. */
disableDaysOutsideCurrentView: Ref<boolean>;
minValue: Ref<Date | undefined>;
maxValue: Ref<Date | undefined>;
isDateDisabled: (date: Date) => boolean;
isDateUnavailable: (date: Date) => boolean;
isDateSelected: (date: Date) => boolean;
isOutsideVisibleView: (date: Date) => boolean;
isInvalid: ComputedRef<boolean>;
/** Whether at least one date is currently selected. */
hasSelectedDate: ComputedRef<boolean>;
/** First in-view, actionable date — the roving-focus fallback target. */
firstFocusableDate: ComputedRef<Date | undefined>;
/** Element hosting the calendar grid(s); used for keyboard focus shifting. */
parentElement: Ref<HTMLElement | undefined>;
/** Currently focused day, drives `tabindex`. */
focusedDate: Ref<Date | undefined>;
setDate: (date: Date | undefined) => void;
setPlaceholder: (date: Date) => void;
nextPage: (fn?: (placeholder: Date) => Date) => void;
prevPage: (fn?: (placeholder: Date) => Date) => void;
nextYear: () => void;
prevYear: () => void;
isNextButtonDisabled: (fn?: (placeholder: Date) => Date) => boolean;
isPrevButtonDisabled: (fn?: (placeholder: Date) => Date) => boolean;
}
const ctx = useContextFactory<CalendarRootContext>('CalendarRoot');
export const provideCalendarRootContext = ctx.provide;
export const useCalendarRootContext = ctx.inject;
export interface CalendarGridContext {
/** The month this `<table>` is rendering. */
month: Ref<Date>;
}
const gridCtx = useContextFactory<CalendarGridContext>('CalendarGrid');
export const provideCalendarGridContext = gridCtx.provide;
export const useCalendarGridContext = gridCtx.inject;
@@ -0,0 +1,193 @@
export type WeekDayFormat = 'narrow' | 'short' | 'long';
export interface DateRange {
start?: Date;
end?: Date;
}
export function toDateOnly(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
}
/**
* `YYYY-MM-DD` from local date fields — unlike `toISOString`, which shifts
* local-midnight Dates to the previous UTC day in positive-offset timezones.
*/
export function toIsoDate(d: Date): string {
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${month}-${day}`;
}
export function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate();
}
export function isSameMonth(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
}
export function isBefore(a: Date, b: Date): boolean {
return toDateOnly(a).getTime() < toDateOnly(b).getTime();
}
export function isAfter(a: Date, b: Date): boolean {
return toDateOnly(a).getTime() > toDateOnly(b).getTime();
}
export function addDays(d: Date, n: number): Date {
const r = toDateOnly(d);
r.setDate(r.getDate() + n);
return r;
}
export function addMonths(d: Date, n: number): Date {
const r = toDateOnly(d);
const day = r.getDate();
// Move to first of month, shift, then clamp day to month length.
r.setDate(1);
r.setMonth(r.getMonth() + n);
const lastDay = new Date(r.getFullYear(), r.getMonth() + 1, 0).getDate();
r.setDate(Math.min(day, lastDay));
return r;
}
export function addYears(d: Date, n: number): Date {
return addMonths(d, n * 12);
}
export function startOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), 1, 0, 0, 0, 0);
}
export function endOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth() + 1, 0, 0, 0, 0, 0);
}
export function getDaysInMonth(d: Date): number {
return endOfMonth(d).getDate();
}
export function startOfWeek(d: Date, weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6 = 0): Date {
const r = toDateOnly(d);
const day = r.getDay();
const diff = (day - weekStartsOn + 7) % 7;
r.setDate(r.getDate() - diff);
return r;
}
/**
* The locale's first day of week (0=Sun … 6=Sat). Uses `Intl.Locale.weekInfo`
* when available, otherwise probes a known Monday's index via `Intl` so that
* e.g. `'en-US'` resolves to Sunday and `'fr'`/`'de'` to Monday. Falls back to
* Sunday for unparseable locales.
*/
export function getLocaleWeekStartsOn(locale: string): 0 | 1 | 2 | 3 | 4 | 5 | 6 {
try {
const info = new Intl.Locale(locale) as Intl.Locale & {
weekInfo?: { firstDay?: number };
getWeekInfo?: () => { firstDay?: number };
};
const firstDay = info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay;
if (typeof firstDay === 'number')
// `weekInfo.firstDay` is 1=Mon … 7=Sun; convert to 0=Sun … 6=Sat.
return (firstDay % 7) as 0 | 1 | 2 | 3 | 4 | 5 | 6;
}
catch {
// Ignore — fall through to the Intl probe below.
}
// Probe: 2025-01-06 is a Monday. Determine its position in the locale week.
try {
const monday = new Date(2025, 0, 6);
const parts = new Intl.DateTimeFormat(locale, { weekday: 'short' });
// Compare against an anchored Sunday to derive the offset.
const sundayShort = parts.format(new Date(2025, 0, 5));
const mondayShort = parts.format(monday);
if (sundayShort === mondayShort)
return 0;
return 1;
}
catch {
return 0;
}
}
/**
* Returns a 6×7 matrix of dates for the month containing `month`,
* padded with leading/trailing days from adjacent months.
*/
export function getWeeks(month: Date, weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6 = 0): Date[][] {
const first = startOfMonth(month);
const gridStart = startOfWeek(first, weekStartsOn);
const weeks: Date[][] = [];
for (let w = 0; w < 6; w++) {
const row: Date[] = [];
for (let i = 0; i < 7; i++)
row.push(addDays(gridStart, w * 7 + i));
weeks.push(row);
}
return weeks;
}
export function clamp(date: Date, min?: Date, max?: Date): Date {
if (min && isBefore(date, min))
return toDateOnly(min);
if (max && isAfter(date, max))
return toDateOnly(max);
return toDateOnly(date);
}
export function isDateUnavailable(
d: Date,
predicate?: (d: Date) => boolean,
min?: Date,
max?: Date,
): boolean {
if (min && isBefore(d, min))
return true;
if (max && isAfter(d, max))
return true;
if (predicate?.(d))
return true;
return false;
}
/**
* Cache of `Intl.DateTimeFormat` instances keyed by `locale|JSON(options)`.
* Formatter construction is ~1-2 orders of magnitude costlier than `.format()`
* on an existing instance, and instances are immutable/reusable. The set of
* distinct (locale, options) pairs a calendar uses is tiny (a handful), so the
* map stays bounded across the page lifetime.
*/
const dateTimeFormatCache = new Map<string, Intl.DateTimeFormat>();
function getDateTimeFormat(
locale: string,
opts: Intl.DateTimeFormatOptions,
): Intl.DateTimeFormat {
const key = `${locale}|${JSON.stringify(opts)}`;
let fmt = dateTimeFormatCache.get(key);
if (fmt === undefined) {
fmt = new Intl.DateTimeFormat(locale, opts);
dateTimeFormatCache.set(key, fmt);
}
return fmt;
}
export function formatDate(
d: Date,
opts: Intl.DateTimeFormatOptions,
locale: string,
): string {
return getDateTimeFormat(locale, opts).format(d);
}
export function formatWeekday(
d: Date,
locale: string,
width: WeekDayFormat = 'short',
): string {
return getDateTimeFormat(locale, { weekday: width }).format(d);
}
@@ -0,0 +1,110 @@
<script setup lang="ts">
import {
CalendarCell,
CalendarCellTrigger,
CalendarGrid,
CalendarGridBody,
CalendarGridHead,
CalendarGridRow,
CalendarHeadCell,
CalendarHeader,
CalendarHeading,
CalendarNext,
CalendarPrev,
CalendarRoot,
} from '@robonen/primitives';
import { ref } from 'vue';
const value = ref<Date>(new Date());
function formatSelected(date: Date | undefined) {
if (!date) return 'None';
return date.toLocaleDateString('en', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
}
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-3">
<CalendarRoot
v-slot="{ grid, weekDays }"
v-model="value"
class="demo-card p-4 text-fg shadow-sm"
>
<CalendarHeader class="mb-3 flex items-center justify-between gap-2">
<CalendarPrev
aria-label="Previous month"
class="inline-flex size-8 items-center justify-center rounded-lg border border-border bg-bg text-fg-muted transition hover:bg-bg-inset hover:text-fg active:scale-95 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
>
</CalendarPrev>
<CalendarHeading class="text-sm font-semibold tracking-tight" />
<CalendarNext
aria-label="Next month"
class="inline-flex size-8 items-center justify-center rounded-lg border border-border bg-bg text-fg-muted transition hover:bg-bg-inset hover:text-fg active:scale-95 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
>
</CalendarNext>
</CalendarHeader>
<CalendarGrid
v-for="month in grid"
:key="month.value.toString()"
:month="month.value"
class="w-full border-collapse select-none"
>
<CalendarGridHead>
<CalendarGridRow class="mb-1 grid grid-cols-7">
<CalendarHeadCell
v-for="(weekday, i) in weekDays"
:key="weekday + i"
class="text-center text-xs font-medium text-fg-subtle"
>
{{ weekday }}
</CalendarHeadCell>
</CalendarGridRow>
</CalendarGridHead>
<CalendarGridBody>
<CalendarGridRow
v-for="(week, w) in month.weeks"
:key="w"
class="grid grid-cols-7"
>
<CalendarCell
v-for="day in week"
:key="day.toString()"
:date="day"
class="flex justify-center p-0.5"
>
<CalendarCellTrigger
v-slot="{ dayValue, selected, today }"
:day="day"
:month="month.value"
class="flex size-8 items-center justify-center rounded-lg text-sm tabular-nums transition outline-none cursor-pointer
focus-visible:ring-2 focus-visible:ring-ring
hover:bg-bg-inset
data-[selected]:bg-accent data-[selected]:font-semibold data-[selected]:text-accent-fg data-[selected]:hover:bg-accent-hover
data-[outside-view]:text-fg-subtle data-[outside-view]:opacity-50
data-[unavailable]:cursor-not-allowed data-[unavailable]:text-red-500 data-[unavailable]:line-through data-[unavailable]:hover:bg-transparent
data-[disabled]:cursor-not-allowed data-[disabled]:opacity-30"
>
<span
:class="[
today && !selected ? 'relative after:absolute after:bottom-1 after:left-1/2 after:size-1 after:-translate-x-1/2 after:rounded-full after:bg-accent' : '',
]"
>
{{ dayValue }}
</span>
</CalendarCellTrigger>
</CalendarCell>
</CalendarGridRow>
</CalendarGridBody>
</CalendarGrid>
</CalendarRoot>
<p class="text-xs text-fg-muted">
Selected:
<span class="font-medium text-fg">{{ formatSelected(value) }}</span>
</p>
</div>
</template>
@@ -0,0 +1,42 @@
export { default as CalendarRoot } from './CalendarRoot.vue';
export { default as CalendarHeader } from './CalendarHeader.vue';
export { default as CalendarHeading } from './CalendarHeading.vue';
export { default as CalendarPrev } from './CalendarPrev.vue';
export { default as CalendarNext } from './CalendarNext.vue';
export { default as CalendarGrid } from './CalendarGrid.vue';
export { default as CalendarGridHead } from './CalendarGridHead.vue';
export { default as CalendarGridBody } from './CalendarGridBody.vue';
export { default as CalendarGridRow } from './CalendarGridRow.vue';
export { default as CalendarHeadCell } from './CalendarHeadCell.vue';
export { default as CalendarCell } from './CalendarCell.vue';
export { default as CalendarCellTrigger } from './CalendarCellTrigger.vue';
export {
provideCalendarRootContext,
useCalendarRootContext,
provideCalendarGridContext,
useCalendarGridContext,
} from './context';
export type {
CalendarRootContext,
CalendarGridContext,
} from './context';
export * from './utils';
export type { CalendarRootEmits, CalendarRootProps } from './CalendarRoot.vue';
export type { CalendarHeaderProps } from './CalendarHeader.vue';
export type { CalendarHeadingProps } from './CalendarHeading.vue';
export type { CalendarPrevProps } from './CalendarPrev.vue';
export type { CalendarNextProps } from './CalendarNext.vue';
export type { CalendarGridProps } from './CalendarGrid.vue';
export type { CalendarGridHeadProps } from './CalendarGridHead.vue';
export type { CalendarGridBodyProps } from './CalendarGridBody.vue';
export type { CalendarGridRowProps } from './CalendarGridRow.vue';
export type { CalendarHeadCellProps } from './CalendarHeadCell.vue';
export type { CalendarCellProps } from './CalendarCell.vue';
export type {
CalendarCellTriggerProps,
CalendarCellTriggerSlotProps,
} from './CalendarCellTrigger.vue';
@@ -0,0 +1,98 @@
import type { WeekDayFormat } from './date-utils';
import {
addMonths,
formatDate,
formatWeekday,
getDaysInMonth,
getWeeks,
isSameMonth,
startOfMonth,
startOfWeek,
} from './date-utils';
export * from './date-utils';
export interface CalendarMonth<TDate = Date> {
/** First day of this month (date-only). */
value: TDate;
/** N×7 grid of dates including leading/trailing adjacent-month days. */
weeks: TDate[][];
}
export interface CreateMonthsOptions<TDate = Date> {
date: TDate;
numberOfMonths: number;
weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6;
/** Always render 6 weeks per month. @default true */
fixedWeeks?: boolean;
}
/** Build N consecutive months starting from `date`'s month. */
export function createMonths(opts: CreateMonthsOptions): CalendarMonth[] {
const { fixedWeeks = true } = opts;
const months: CalendarMonth[] = [];
for (let i = 0; i < opts.numberOfMonths; i++) {
const m = startOfMonth(addMonths(opts.date, i));
let weeks = getWeeks(m, opts.weekStartsOn);
// Only trailing weeks can be entirely outside the month — the first week
// always contains the 1st.
if (!fixedWeeks)
weeks = weeks.filter(week => week.some(d => isSameMonth(d, m)));
months.push({ value: m, weeks });
}
return months;
}
/** Localized short/narrow/long weekday names starting from `weekStartsOn`. */
export function getWeekdayLabels(
weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6,
locale: string,
width: WeekDayFormat,
): string[] {
// Pick any known Sunday (1970-01-04 is a Sunday) as anchor.
const anchorSunday = new Date(1970, 0, 4);
const start = startOfWeek(anchorSunday, weekStartsOn);
const labels: string[] = [];
for (let i = 0; i < 7; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
labels.push(formatWeekday(d, locale, width));
}
return labels;
}
/**
* The first in-view date that is neither disabled nor unavailable, scanning
* months in order and honoring `minValue`. Used as the roving-focus fallback
* so the initial tab stop is always actionable. Returns `undefined` when every
* visible day is blocked.
*/
export function findFirstFocusableDate(
months: CalendarMonth[],
isDisabled: (d: Date) => boolean,
isUnavailable: (d: Date) => boolean,
): Date | undefined {
for (const month of months) {
const start = startOfMonth(month.value);
const total = getDaysInMonth(month.value);
for (let day = 1; day <= total; day++) {
const date = new Date(start.getFullYear(), start.getMonth(), day);
if (isDisabled(date) || isUnavailable(date))
continue;
return date;
}
}
return undefined;
}
export function formatMonthYear(d: Date, locale: string): string {
return formatDate(d, { month: 'long', year: 'numeric' }, locale);
}
export function formatFullDate(d: Date, locale: string): string {
return formatDate(
d,
{ weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' },
locale,
);
}
@@ -0,0 +1,33 @@
<script lang="ts">
import type { PopperAnchorProps } from '../../overlays/popper';
/**
* Optional custom anchor for positioning the popover against an element other
* than the trigger (e.g. a field or input group). When present, the trigger
* stops acting as the anchor and the content is positioned relative to this.
*/
export interface DatePickerAnchorProps extends PopperAnchorProps {}
</script>
<script setup lang="ts">
import { onBeforeMount, onUnmounted } from 'vue';
import { PopperAnchor } from '../../overlays/popper';
import { useDatePickerRootContext } from './context';
const props = defineProps<DatePickerAnchorProps>();
const ctx = useDatePickerRootContext();
onBeforeMount(() => {
ctx.hasCustomAnchor.value = true;
});
onUnmounted(() => {
ctx.hasCustomAnchor.value = false;
});
</script>
<template>
<PopperAnchor v-bind="props">
<slot />
</PopperAnchor>
</template>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { PopperArrowProps } from '../../overlays/popper';
/**
* An optional arrow rendered inside `DatePickerContent` that points back at the
* trigger/anchor. Purely decorative; place it as a child of the content.
*/
export interface DatePickerArrowProps extends PopperArrowProps {}
</script>
<script setup lang="ts">
import { PopperArrow } from '../../overlays/popper';
const { width = 10, height = 5 } = defineProps<DatePickerArrowProps>();
</script>
<template>
<PopperArrow :width="width" :height="height">
<slot />
</PopperArrow>
</template>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A styling wrapper for the calendar grid rendered inside the popover. The
* calendar subparts (`DatePickerGrid`, `DatePickerCell`, etc.) consume the
* calendar context provided by `DatePickerRoot`; this element just groups and
* labels them with a `data-primitives-date-picker-calendar` hook.
*/
export interface DatePickerCalendarProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
const { as = 'div' } = defineProps<DatePickerCalendarProps>();
</script>
<template>
<Primitive :as="as" :data-primitives-date-picker-calendar="''">
<slot />
</Primitive>
</template>
@@ -0,0 +1,29 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A button that closes the picker popover when clicked. Render it inside
* `DatePickerContent` (e.g. a "Done" or dismiss action).
*/
export interface DatePickerCloseProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { useDatePickerRootContext } from './context';
const { as = 'button' } = defineProps<DatePickerCloseProps>();
const ctx = useDatePickerRootContext();
</script>
<template>
<Primitive
:as="as"
:type="as === 'button' ? 'button' : undefined"
:data-state="ctx.open.value ? 'open' : 'closed'"
@click="ctx.onOpenChange(false)"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,81 @@
<script lang="ts">
import type { DismissableLayerEmits } from '../../utilities/dismissable-layer';
import type { FocusScopeEmits } from '../../utilities/focus-scope';
import type { PopperContentProps } from '../../overlays/popper';
/**
* The popover panel that holds the calendar. Handles Popper positioning,
* presence (mount/unmount on open), focus trapping/restoration, and dismissal
* via Escape or outside interaction. Renders only while open unless `forceMount`
* is set.
*/
export interface DatePickerContentProps extends PopperContentProps {
/** Keep mounted for CSS exit animations. */
forceMount?: boolean;
}
export interface DatePickerContentEmits {
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 { DismissableLayer } from '../../utilities/dismissable-layer';
import { FocusScope } from '../../utilities/focus-scope';
import { PopperContent } from '../../overlays/popper';
import { Presence } from '../../utilities/presence';
import { useDatePickerRootContext } from './context';
const {
forceMount = false,
as = 'div',
...popperProps
} = defineProps<DatePickerContentProps>();
const emit = defineEmits<DatePickerContentEmits>();
const ctx = useDatePickerRootContext();
</script>
<template>
<Presence :present="ctx.open.value" :force-mount="forceMount">
<FocusScope
as="template"
:loop="true"
:trapped="ctx.modal.value"
@mount-auto-focus.prevent="emit('openAutoFocus', $event)"
@unmount-auto-focus="(event: Event) => {
emit('closeAutoFocus', event);
if (!event.defaultPrevented) ctx.triggerElement.value?.focus();
}"
>
<DismissableLayer
as="template"
:disable-outside-pointer-events="ctx.modal.value"
@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); emit('dismiss'); }"
>
<PopperContent
:id="ctx.contentId.value"
:as="as"
v-bind="popperProps"
role="dialog"
:aria-labelledby="ctx.triggerId.value"
:data-state="ctx.open.value ? 'open' : 'closed'"
:data-primitives-date-picker-content="''"
>
<slot />
</PopperContent>
</DismissableLayer>
</FocusScope>
</Presence>
</template>
@@ -0,0 +1,72 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A text input that renders the selected date and, when `editable`, lets users
* type a date that is parsed and committed back to the picker on blur/Enter.
* Aliased as `DatePickerInput`; defaults to a read-only display of the value.
*/
export interface DatePickerFieldProps extends PrimitiveProps {
/** Allow typing into the field. @default false (read-only display) */
editable?: boolean;
/** Display format for the rendered value. */
format?: Intl.DateTimeFormatOptions;
/** Placeholder text shown when no value is selected. */
placeholderText?: string;
}
</script>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useDatePickerRootContext } from './context';
const {
as: _as = 'input',
editable = false,
format = { year: 'numeric', month: '2-digit', day: '2-digit' },
placeholderText,
} = defineProps<DatePickerFieldProps>();
const ctx = useDatePickerRootContext();
const adapter = ctx.dateAdapter;
const displayValue = computed(() => {
if (!ctx.modelValue.value) return '';
return adapter.value.format(ctx.modelValue.value, format, ctx.locale.value);
});
const draft = ref(displayValue.value);
watch(displayValue, (v) => {
draft.value = v;
});
function commit() {
if (!editable) return;
const text = draft.value.trim();
if (!text) {
ctx.modelValue.value = undefined;
return;
}
const parsed = adapter.value.parse(text);
if (parsed)
ctx.modelValue.value = adapter.value.toDateOnly(parsed);
else
draft.value = displayValue.value;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') commit();
}
</script>
<template>
<input
:value="editable ? draft : displayValue"
:readonly="!editable"
:placeholder="placeholderText"
:data-primitives-date-picker-field="''"
@input="(e) => { if (editable) draft = (e.target as HTMLInputElement).value; }"
@blur="commit"
@keydown="handleKeydown"
>
</template>
@@ -0,0 +1,168 @@
<script lang="ts">
import type { SegmentContent, SegmentPart, SegmentValues } from './use-date-field';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A segmented date field: a `role="group"` of individually-focusable
* `role="spinbutton"` segments (`DatePickerFieldSegment`) that edit one part of
* the date each. It reads the picker's value, placeholder, locale, granularity,
* and hour cycle from `DatePickerRoot`, and commits a complete date back to the
* picker. This is the accessible, keyboard-driven alternative to the plain
* `DatePickerField` text input.
*
* The default slot receives the ordered `segments` descriptors (including
* literals) and the current `modelValue`, so the consumer renders a
* `DatePickerFieldSegment` per segment.
*/
export interface DatePickerFieldRootProps extends PrimitiveProps {}
export interface DatePickerFieldRootSlot {
default?: (props: {
segments: SegmentContent[];
modelValue: Date | undefined;
isInvalid: boolean;
}) => unknown;
}
</script>
<script setup lang="ts">
import { computed, markRaw, shallowRef, triggerRef, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { useDatePickerRootContext } from './context';
import { provideDatePickerFieldContext } from './field-context';
import {
createSegmentContents,
initializeSegmentValues,
isSegmentValuesComplete,
segmentValuesToDate,
syncSegmentValues,
} from './use-date-field';
const { as = 'div' } = defineProps<DatePickerFieldRootProps>();
defineSlots<DatePickerFieldRootSlot>();
defineOptions({ inheritAttrs: false });
const ctx = useDatePickerRootContext();
const { forwardRef, currentElement } = useForwardExpose();
const adapter = ctx.dateAdapter;
const granularity = ctx.granularity;
const hourCycle = ctx.hourCycle;
const segmentValues = shallowRef<SegmentValues>(
ctx.modelValue.value
? syncSegmentValues(adapter.value, ctx.modelValue.value, granularity.value)
: initializeSegmentValues(granularity.value),
);
// Re-seed when the model or granularity changes from the outside.
watch([() => ctx.modelValue.value, granularity], ([value, gran]) => {
if (value) {
segmentValues.value = syncSegmentValues(adapter.value, value, gran);
}
else if (Object.values(segmentValues.value).every(v => v !== null)) {
// Only reset when the field was fully populated; preserve mid-edit state.
segmentValues.value = initializeSegmentValues(gran);
}
});
const segmentContents = computed<SegmentContent[]>(() => createSegmentContents(
segmentValues.value,
ctx.placeholder.value,
granularity.value,
hourCycle.value,
ctx.locale.value,
));
// Ordered registry of focusable segment elements (DOM order via querySelectorAll).
// `shallowRef` so element keys stay raw (a deep `ref` would proxy the Map and
// its entries); mutated in place, so `triggerRef` after each change.
const segmentMap = shallowRef<Map<HTMLElement, SegmentPart>>(new Map());
function registerSegment(el: HTMLElement, part: SegmentPart): () => void {
const key = markRaw(el);
segmentMap.value.set(key, part);
triggerRef(segmentMap);
return () => {
segmentMap.value.delete(key);
triggerRef(segmentMap);
};
}
function orderedSegments(): HTMLElement[] {
const root = currentElement.value;
if (!root) return [];
return Array.from(
root.querySelectorAll<HTMLElement>('[data-primitives-date-picker-segment]:not([data-readonly])'),
);
}
function focusSegment(from: HTMLElement, direction: 1 | -1) {
const sign = ctx.dir.value === 'rtl' ? -direction : direction;
const els = orderedSegments();
const index = els.indexOf(from);
if (index < 0) return;
const next = els[index + sign];
next?.focus();
}
function focusNext(from: HTMLElement) {
const els = orderedSegments();
const index = els.indexOf(from);
if (index < 0) return;
els[index + 1]?.focus();
}
function commit() {
if (ctx.readonly.value || ctx.disabled.value) return;
if (!isSegmentValuesComplete(segmentValues.value, granularity.value)) return;
ctx.onDateChange(segmentValuesToDate(adapter.value, segmentValues.value, granularity.value));
}
function updateSegment(part: SegmentPart, value: number | string | null) {
// Replace wholesale so shallowRef triggers without deep tracking.
segmentValues.value = { ...segmentValues.value, [part]: value };
}
provideDatePickerFieldContext({
dateAdapter: adapter,
locale: ctx.locale,
dir: ctx.dir,
placeholder: ctx.placeholder,
disabled: ctx.disabled,
readonly: ctx.readonly,
isInvalid: ctx.isInvalid,
hourCycle,
granularity,
segmentValues,
segmentContents,
registerSegment,
focusSegment,
focusNext,
updateSegment,
commit,
});
</script>
<template>
<Primitive
:ref="forwardRef"
v-bind="$attrs"
:as="as"
role="group"
:data-primitives-date-picker-field-root="''"
:aria-disabled="ctx.disabled.value ? true : undefined"
:aria-invalid="ctx.isInvalid.value ? true : undefined"
:data-disabled="ctx.disabled.value ? '' : undefined"
:data-readonly="ctx.readonly.value ? '' : undefined"
:data-invalid="ctx.isInvalid.value ? '' : undefined"
:dir="ctx.dir.value"
>
<slot
:segments="segmentContents"
:model-value="ctx.modelValue.value"
:is-invalid="ctx.isInvalid.value"
/>
</Primitive>
</template>
@@ -0,0 +1,191 @@
<script lang="ts">
import type { DayPeriod, SegmentPart } from './use-date-field';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A single segment of a `DatePickerFieldRoot`. Editable parts (`day`, `month`,
* `year`, `hour`, `minute`, `second`, `dayPeriod`) render as a focusable
* `role="spinbutton"` with `aria-valuemin/max/now/valuetext`; `literal` parts
* render as inert separators. Supports arrow increment/decrement, numeric
* type-ahead with auto-advance, Backspace to clear, and `a`/`p` for AM/PM.
*/
export interface DatePickerFieldSegmentProps extends PrimitiveProps {
/** The date part this segment edits. */
part: SegmentPart;
}
</script>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { useDatePickerFieldContext } from './field-context';
import {
applySegmentKeydown,
isEditableSegmentPart,
resolveHourCycle,
} from './use-date-field';
const { part, as = 'div' } = defineProps<DatePickerFieldSegmentProps>();
const ctx = useDatePickerFieldContext();
const adapter = ctx.dateAdapter;
const { forwardRef, currentElement } = useForwardExpose();
const isLiteral = computed(() => part === 'literal');
const isEditable = computed(() => isEditableSegmentPart(part));
const displayValue = computed(() => {
// Find this part's current content. For repeated literals we just show value.
const match = ctx.segmentContents.value.find(s => s.part === part);
return match?.value ?? '';
});
const isEmpty = computed(() => {
if (!isEditable.value || part === 'dayPeriod') return false;
const v = (ctx.segmentValues.value as Record<string, unknown>)[part];
return v === null || v === undefined;
});
// Type-ahead state lives per segment instance.
const typeState = { hasLeftFocus: true, lastKeyZero: false };
const ariaValues = computed(() => {
const values = ctx.segmentValues.value;
switch (part) {
case 'day': {
// Use the placeholder's real year so `aria-valuemax` matches the editing
// cap in `applySegmentKeydown` (February differs across leap/non-leap years).
const year = adapter.value.getParts(ctx.placeholder.value).year;
const monthDays = values.month
? adapter.value.getDaysInMonth(adapter.value.fromParts({ year, month: values.month, day: 1 }))
: 31;
return { min: 1, max: monthDays, now: values.day ?? undefined, label: 'day' };
}
case 'month':
return { min: 1, max: 12, now: values.month ?? undefined, label: 'month' };
case 'year':
return { min: 1, max: 9999, now: values.year ?? undefined, label: 'year' };
case 'hour': {
const is12 = resolveHourCycle(ctx.hourCycle.value, ctx.locale.value) === 12;
return {
min: is12 ? 1 : 0,
max: is12 ? 12 : 23,
now: values.hour ?? undefined,
label: 'hour',
};
}
case 'minute':
return { min: 0, max: 59, now: values.minute ?? undefined, label: 'minute' };
case 'second':
return { min: 0, max: 59, now: values.second ?? undefined, label: 'second' };
case 'dayPeriod':
return { min: 0, max: 12, now: (values.hour ?? 0) % 12, label: 'AM/PM' };
default:
return { min: undefined, max: undefined, now: undefined, label: undefined };
}
});
const ariaValueText = computed(() => {
if (isEmpty.value) return 'Empty';
if (part === 'dayPeriod') return ctx.segmentValues.value.dayPeriod ?? 'AM';
return displayValue.value;
});
let cleanup: (() => void) | undefined;
onMounted(() => {
if (isEditable.value && !ctx.readonly.value && currentElement.value)
cleanup = ctx.registerSegment(currentElement.value, part);
});
onBeforeUnmount(() => cleanup?.());
const disabled = computed(() => ctx.disabled.value);
const readonly = computed(() => ctx.readonly.value);
function handleKeydown(e: KeyboardEvent) {
if (!isEditable.value) return;
if (disabled.value || readonly.value) return;
if (e.key === 'ArrowLeft') {
e.preventDefault();
ctx.focusSegment(currentElement.value!, -1);
return;
}
if (e.key === 'ArrowRight') {
e.preventDefault();
ctx.focusSegment(currentElement.value!, 1);
return;
}
if (e.key === 'Tab' || e.key === 'Shift')
return;
e.preventDefault();
typeState.hasLeftFocus = false;
const result = applySegmentKeydown(e, {
adapter: adapter.value,
part: part as Exclude<SegmentPart, 'literal'>,
values: ctx.segmentValues.value,
placeholder: ctx.placeholder.value,
granularity: ctx.granularity.value,
hourCycle: ctx.hourCycle.value,
locale: ctx.locale.value,
state: typeState,
focusNext: () => ctx.focusNext(currentElement.value!),
});
if (!result) return;
ctx.updateSegment(result.part, result.value);
if ('dayPeriod' in result && result.dayPeriod !== undefined)
ctx.updateSegment('dayPeriod', result.dayPeriod as DayPeriod);
if ('hour' in result && typeof (result as { hour?: number }).hour === 'number')
ctx.updateSegment('hour', (result as { hour: number }).hour);
ctx.commit();
}
function handleFocusOut() {
typeState.hasLeftFocus = true;
}
</script>
<template>
<Primitive
v-if="isLiteral"
:as="as"
aria-hidden="true"
:data-primitives-date-picker-segment="part"
data-readonly=""
>
<slot :value="displayValue">{{ displayValue }}</slot>
</Primitive>
<Primitive
v-else
:ref="forwardRef"
:as="as"
role="spinbutton"
:contenteditable="false"
:tabindex="disabled ? undefined : 0"
:aria-label="ariaValues.label"
:aria-valuemin="ariaValues.min"
:aria-valuemax="ariaValues.max"
:aria-valuenow="ariaValues.now"
:aria-valuetext="ariaValueText"
:aria-disabled="disabled ? true : undefined"
:aria-readonly="readonly ? true : undefined"
:aria-invalid="ctx.isInvalid.value ? true : undefined"
:data-primitives-date-picker-segment="part"
:data-placeholder="isEmpty ? '' : undefined"
:data-disabled="disabled ? '' : undefined"
:data-readonly="readonly ? '' : undefined"
:data-invalid="ctx.isInvalid.value ? '' : undefined"
spellcheck="false"
autocorrect="off"
inputmode="numeric"
@keydown="handleKeydown"
@focusout="handleFocusOut"
>
<slot :value="displayValue">{{ displayValue }}</slot>
</Primitive>
</template>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { TeleportPrimitiveProps } from '../../utilities/teleport';
/**
* Teleports the popover content into a different part of the DOM (the body by
* default) so it escapes overflow/stacking-context clipping. Wrap
* `DatePickerContent` with it when the picker lives inside a scrolled or
* transformed container.
*/
export interface DatePickerPortalProps extends TeleportPrimitiveProps {}
</script>
<script setup lang="ts">
import PortalPrimitive from '../../utilities/teleport/Teleport.vue';
const props = defineProps<DatePickerPortalProps>();
</script>
<template>
<PortalPrimitive v-bind="props">
<slot />
</PortalPrimitive>
</template>
@@ -0,0 +1,479 @@
<script lang="ts">
import type { CalendarMonth, CalendarRootProps, WeekDayFormat } from '../calendar';
import type { PrimitiveProps } from '../../internal/primitive';
import type { Granularity, HourCycle } from './use-date-field';
/**
* A single-date picker that pairs a popover-anchored calendar with an optional
* trigger, field, and hidden form input. Owns the selected date, placeholder
* month, and open state, and provides both date-picker and calendar context to
* its parts. Use it when you need a compact, accessible "pick one date" control
* (e.g. a form field) rather than an always-visible `Calendar`.
*/
export interface DatePickerRootProps extends PrimitiveProps,
Omit<CalendarRootProps, 'as' | 'asChild'> {
/** Uncontrolled initial open state. */
defaultOpen?: boolean;
/** Modal popover (traps focus + blocks outside pointer). @default false */
modal?: boolean;
/** Hidden form input name for submission. */
name?: string;
/** Id forwarded to the focusable form control / first segment. */
id?: string;
/** Marks the form control as required for native constraint validation. @default false */
required?: boolean;
/** Format used to serialize the hidden input value. @default 'iso' */
valueFormat?: 'iso' | ((d: Date) => string);
/** Close popover on selection. @default true */
closeOnSelect?: boolean;
/**
* Keep the current value selected when the already-selected date is picked
* again (otherwise re-selecting clears it). @default false
*/
preventDeselect?: boolean;
/**
* Smallest unit the field edits. `'day'` is date-only; `'hour'`/`'minute'`/
* `'second'` add time segments and preserve the time-of-day. @default 'day'
*/
granularity?: Granularity;
/** Hour cycle for the time segments (12 or 24). Inferred from locale if omitted. */
hourCycle?: HourCycle;
}
export interface DatePickerRootEmits {
'update:modelValue': [date: Date | undefined];
'update:placeholder': [date: Date];
'update:open': [open: boolean];
}
</script>
<script setup lang="ts">
import { useEventListener, useForwardExpose } from '@robonen/vue';
import { computed, onMounted, ref, shallowRef, toRef, watch } from 'vue';
import { provideCalendarRootContext } from '../calendar';
import { useDateAdapter, useId } from '../../utilities/config-provider';
import { PopperRoot } from '../../overlays/popper';
import { Primitive } from '../../internal/primitive';
import { VisuallyHidden } from '../../utilities/visually-hidden';
import { provideDatePickerRootContext } from './context';
import { hasTimeGranularity } from './use-date-field';
defineOptions({ inheritAttrs: false });
const {
as = 'div',
defaultOpen = false,
modal = false,
name,
id,
required = false,
valueFormat = 'iso',
closeOnSelect = true,
preventDeselect = false,
granularity: propsGranularity = 'day',
hourCycle: propsHourCycle,
defaultValue,
defaultPlaceholder,
minValue,
maxValue,
isDateUnavailable: propsIsDateUnavailable,
isDateDisabled: propsIsDateDisabled,
pagedNavigation = false,
weekStartsOn = 0,
weekdayFormat = 'short',
fixedWeeks = true,
numberOfMonths = 1,
disableDaysOutsideCurrentView = false,
disabled = false,
readonly = false,
initialFocus = false,
locale = 'en',
dir = 'ltr',
nextPage: propsNextPage,
prevPage: propsPrevPage,
calendarLabel = 'Calendar',
dateAdapter,
} = defineProps<DatePickerRootProps>();
defineEmits<DatePickerRootEmits>();
const { forwardRef, currentElement: parentElement } = useForwardExpose();
// Resolve the effective date backend: per-instance prop wins over the global
// `ConfigProvider` adapter, falling back to the native `Date` adapter.
const adapter = useDateAdapter(() => dateAdapter);
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 localValue = ref<Date | undefined>(defaultValue);
const modelValue = defineModel<Date | undefined>('modelValue', {
default: undefined,
get: v => v ?? localValue.value,
set: (v) => {
localValue.value = v;
return v;
},
});
const localPlaceholder = ref<Date>(
adapter.value.toDateOnly(defaultPlaceholder ?? modelValue.value ?? adapter.value.now()),
);
const placeholder = defineModel<Date>('placeholder', {
default: undefined,
get: v => v ?? localPlaceholder.value,
set: (v) => {
localPlaceholder.value = adapter.value.toDateOnly(v);
return localPlaceholder.value;
},
});
const triggerId = useId(undefined, 'date-picker-trigger');
const contentId = useId(undefined, 'date-picker-content');
const generatedFieldId = useId(undefined, 'date-picker-field');
const fieldId = computed(() => id ?? generatedFieldId.value);
const triggerElement = shallowRef<HTMLElement>();
const hasCustomAnchor = ref(false);
const focusedDate = ref<Date | undefined>();
const localeRef = toRef(() => locale);
const dirRef = toRef(() => dir);
const modalRef = toRef(() => modal);
const nameRef = toRef(() => name);
const weekStartsOnRef = toRef(() => weekStartsOn);
const weekdayFormatRef = toRef(() => weekdayFormat as WeekDayFormat);
const fixedWeeksRef = toRef(() => fixedWeeks);
const numberOfMonthsRef = toRef(() => numberOfMonths);
const disabledRef = toRef(() => disabled);
const readonlyRef = toRef(() => readonly);
const pagedNavigationRef = toRef(() => pagedNavigation);
const minValueRef = toRef(() => minValue);
const maxValueRef = toRef(() => maxValue);
const requiredRef = toRef(() => required);
const granularityRef = computed<Granularity>(() => propsGranularity);
const hourCycleRef = toRef(() => propsHourCycle);
const preventDeselectRef = toRef(() => preventDeselect);
const multipleRef = toRef(() => false);
const disableDaysOutsideCurrentViewRef = toRef(() => disableDaysOutsideCurrentView);
/** Strip time for `day` granularity; preserve full time-of-day otherwise. */
function normalizeValue(date: Date): Date {
return hasTimeGranularity(propsGranularity)
? adapter.value.clone(date)
: adapter.value.toDateOnly(date);
}
const grid = computed<CalendarMonth[]>(() => adapter.value.createMonths({
date: placeholder.value,
numberOfMonths,
weekStartsOn,
}));
const weekDays = computed(() => adapter.value.getWeekdayLabels(weekStartsOn, locale, weekdayFormat));
const headingValue = computed(() => {
const months = grid.value;
if (!months.length) return '';
if (months.length === 1) return adapter.value.formatMonthYear(months[0]!.value, locale);
const first = adapter.value.formatMonthYear(months[0]!.value, locale);
const last = adapter.value.formatMonthYear(months[months.length - 1]!.value, locale);
return `${first} - ${last}`;
});
const fullCalendarLabel = computed(() => `${calendarLabel}, ${headingValue.value}`);
function isDateDisabled(date: Date): boolean {
if (disabled) return true;
if (propsIsDateDisabled?.(date)) return true;
if (minValue && adapter.value.isBefore(date, minValue)) return true;
if (maxValue && adapter.value.isAfter(date, maxValue)) return true;
return false;
}
function isDateUnavailableLocal(date: Date): boolean {
return adapter.value.isDateUnavailable(date, propsIsDateUnavailable, minValue, maxValue);
}
function isDateSelected(date: Date): boolean {
return modelValue.value ? adapter.value.isSameDay(modelValue.value, date) : false;
}
const hasSelectedDate = computed(() => modelValue.value !== undefined);
const firstFocusableDate = computed(() =>
adapter.value.findFirstFocusableDate(grid.value, isDateDisabled, isDateUnavailableLocal),
);
function isOutsideVisibleView(date: Date): boolean {
return !grid.value.some(m => adapter.value.isSameMonth(m.value, date));
}
const isInvalid = computed(() => {
if (!modelValue.value) return false;
return isDateDisabled(modelValue.value) || isDateUnavailableLocal(modelValue.value);
});
/**
* Unified commit path for any selection source. Honors readonly/disabled,
* disabled-date guards, and `preventDeselect` toggle-off. When `keepTime` is set
* (calendar day pick under a time granularity) the existing time-of-day is
* carried onto the picked day; the segmented field passes a full datetime and
* commits it verbatim.
*/
function onDateChange(date: Date | undefined, options?: { keepTime?: boolean }) {
if (readonly || disabled) return;
if (!date) {
modelValue.value = undefined;
return;
}
if (isDateDisabled(date) || isDateUnavailableLocal(date)) return;
let next = date;
if (options?.keepTime && hasTimeGranularity(propsGranularity) && modelValue.value) {
const day = adapter.value.getParts(date);
const time = adapter.value.getParts(modelValue.value);
next = adapter.value.fromParts({
year: day.year,
month: day.month,
day: day.day,
hour: time.hour,
minute: time.minute,
second: time.second,
});
}
else if (!hasTimeGranularity(propsGranularity)) {
next = normalizeValue(next);
}
if (!preventDeselect && modelValue.value
&& adapter.value.compare(modelValue.value, next) === 0) {
modelValue.value = undefined;
return;
}
modelValue.value = next;
if (closeOnSelect) open.value = false;
}
function setDate(date: Date | undefined) {
onDateChange(date, { keepTime: true });
}
function onPlaceholderChange(date: Date) {
placeholder.value = date;
}
function setPlaceholder(date: Date) {
placeholder.value = adapter.value.clamp(date, minValue, maxValue);
}
function pageStep(): number {
return pagedNavigation ? numberOfMonths : 1;
}
function nextPage(fn?: (placeholder: Date) => Date) {
const fnToUse = fn ?? propsNextPage;
placeholder.value = fnToUse
? adapter.value.toDateOnly(fnToUse(placeholder.value))
: adapter.value.addMonths(placeholder.value, pageStep());
}
function prevPage(fn?: (placeholder: Date) => Date) {
const fnToUse = fn ?? propsPrevPage;
placeholder.value = fnToUse
? adapter.value.toDateOnly(fnToUse(placeholder.value))
: adapter.value.addMonths(placeholder.value, -pageStep());
}
function nextYear() {
placeholder.value = adapter.value.addYears(placeholder.value, 1);
}
function prevYear() {
placeholder.value = adapter.value.addYears(placeholder.value, -1);
}
function isNextButtonDisabled(fn?: (placeholder: Date) => Date): boolean {
if (disabled) return true;
if (!maxValue) return false;
const lastMonth = grid.value[grid.value.length - 1]?.value;
if (!lastMonth) return false;
const fnToUse = fn ?? propsNextPage;
const probe = fnToUse
? adapter.value.toDateOnly(fnToUse(placeholder.value))
: adapter.value.addMonths(lastMonth, 1);
return adapter.value.isAfter(probe, maxValue);
}
function isPrevButtonDisabled(fn?: (placeholder: Date) => Date): boolean {
if (disabled) return true;
if (!minValue) return false;
const firstMonth = grid.value[0]?.value;
if (!firstMonth) return false;
const fnToUse = fn ?? propsPrevPage;
const probe = fnToUse
? adapter.value.toDateOnly(fnToUse(placeholder.value))
: adapter.value.addMonths(firstMonth, -1);
return adapter.value.isBefore(probe, minValue);
}
watch(modelValue, (v) => {
if (v && !adapter.value.isSameMonth(v, placeholder.value))
placeholder.value = adapter.value.toDateOnly(v);
});
onMounted(() => {
if (!initialFocus || !open.value || !parentElement.value) return;
const target = parentElement.value.querySelector<HTMLElement>(
'[data-primitives-calendar-cell-trigger][data-selected]'
+ ',[data-primitives-calendar-cell-trigger][data-today]'
+ ',[data-primitives-calendar-cell-trigger]:not([data-outside-view]):not([data-disabled])',
);
target?.focus();
});
useEventListener(parentElement, 'focusout', (e) => {
if (!parentElement.value?.contains(e.relatedTarget as Node | null))
focusedDate.value = undefined;
});
const hiddenValue = computed(() => {
if (!modelValue.value) return '';
if (typeof valueFormat === 'function') return valueFormat(modelValue.value);
return adapter.value.toISO(modelValue.value).slice(0, 10);
});
const hasTime = computed(() => hasTimeGranularity(propsGranularity));
const nativeInputType = computed(() => hasTime.value ? 'datetime-local' : 'date');
/** Local (not UTC) value string for the native validation input. */
function toNativeInputValue(d: Date | undefined): string {
if (!d) return '';
const pad = (n: number, len = 2) => String(n).padStart(len, '0');
const p = adapter.value.getParts(d);
const date = `${pad(p.year, 4)}-${pad(p.month)}-${pad(p.day)}`;
if (!hasTime.value) return date;
const time = propsGranularity === 'second'
? `${pad(p.hour)}:${pad(p.minute)}:${pad(p.second)}`
: `${pad(p.hour)}:${pad(p.minute)}`;
return `${date}T${time}`;
}
const nativeValue = computed(() => toNativeInputValue(modelValue.value));
const nativeMin = computed(() => minValue ? toNativeInputValue(minValue) : undefined);
const nativeMax = computed(() => maxValue ? toNativeInputValue(maxValue) : undefined);
function focusFirstSegment() {
if (disabled || readonly) return;
const first = parentElement.value?.querySelector<HTMLElement>('[data-primitives-date-picker-segment]:not([data-readonly])');
first?.focus();
}
provideDatePickerRootContext({
dateAdapter: adapter,
open,
modal: modalRef,
name: nameRef,
modelValue,
placeholder,
locale: localeRef,
dir: dirRef,
disabled: disabledRef,
readonly: readonlyRef,
required: requiredRef,
isInvalid,
granularity: granularityRef,
hourCycle: hourCycleRef,
minValue: minValueRef,
maxValue: maxValueRef,
triggerId,
contentId,
fieldId,
triggerElement,
hasCustomAnchor,
onDateChange,
onPlaceholderChange,
onOpenChange: (v) => { open.value = v; },
onOpenToggle: () => { open.value = !open.value; },
});
provideCalendarRootContext({
dateAdapter: adapter,
modelValue,
placeholder,
locale: localeRef,
dir: dirRef,
grid,
weekDays,
headingValue,
fullCalendarLabel,
weekStartsOn: weekStartsOnRef,
weekdayFormat: weekdayFormatRef,
fixedWeeks: fixedWeeksRef,
numberOfMonths: numberOfMonthsRef,
disabled: disabledRef,
readonly: readonlyRef,
pagedNavigation: pagedNavigationRef,
multiple: multipleRef,
preventDeselect: preventDeselectRef,
disableDaysOutsideCurrentView: disableDaysOutsideCurrentViewRef,
minValue: minValueRef,
maxValue: maxValueRef,
isDateDisabled,
isDateUnavailable: isDateUnavailableLocal,
isDateSelected,
isOutsideVisibleView,
isInvalid,
hasSelectedDate,
firstFocusableDate,
parentElement,
focusedDate,
setDate,
setPlaceholder,
nextPage,
prevPage,
nextYear,
prevYear,
isNextButtonDisabled,
isPrevButtonDisabled,
});
</script>
<template>
<PopperRoot>
<Primitive
:ref="forwardRef"
:as="as"
:data-primitives-date-picker-root="''"
:data-state="open ? 'open' : 'closed'"
:data-disabled="disabled ? '' : undefined"
>
<slot :open="open" :model-value="modelValue" />
<input
v-if="name"
type="hidden"
:name="name"
:value="hiddenValue"
:disabled="disabled"
aria-hidden="true"
tabindex="-1"
style="display: none"
>
<VisuallyHidden
v-if="required || minValue || maxValue"
:id="fieldId"
as="input"
feature="focusable"
tabindex="-1"
:type="nativeInputType"
:value="nativeValue"
:required="required"
:min="nativeMin"
:max="nativeMax"
:disabled="disabled"
@focus="focusFirstSegment"
/>
</Primitive>
</PopperRoot>
</template>
@@ -0,0 +1,56 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The button that toggles the picker popover open and closed. Acts as the
* Popper anchor (unless a custom `DatePickerAnchor` is present) and carries the
* dialog-related ARIA wiring (`aria-haspopup`, `aria-expanded`, `aria-controls`).
*/
export interface DatePickerTriggerProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { computed, onMounted } from 'vue';
import { PopperAnchor } from '../../overlays/popper';
import { Primitive } from '../../internal/primitive';
import { useDatePickerRootContext } from './context';
const { as = 'button' } = defineProps<DatePickerTriggerProps>();
const ctx = useDatePickerRootContext();
const { forwardRef, currentElement } = useForwardExpose();
const disabled = computed(() => ctx.disabled.value);
function onClick() {
if (disabled.value) return;
ctx.onOpenToggle();
}
onMounted(() => {
ctx.triggerElement.value = currentElement.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"
:disabled="as === 'button' && disabled ? true : undefined"
:aria-disabled="disabled ? true : undefined"
:data-state="ctx.open.value ? 'open' : 'closed'"
:data-disabled="disabled ? '' : undefined"
:data-primitives-date-picker-trigger="''"
@click="onClick"
>
<slot />
</Primitive>
</component>
</template>
@@ -0,0 +1,406 @@
import type { SegmentContent } from '../use-date-field';
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import {
DatePickerCalendar,
DatePickerCell,
DatePickerCellTrigger,
DatePickerContent,
DatePickerFieldRoot,
DatePickerFieldSegment,
DatePickerGrid,
DatePickerGridBody,
DatePickerGridRow,
DatePickerRoot,
DatePickerTrigger,
} from '../index';
function press(el: Element, key: string) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
}
function mountField(rootProps: Record<string, unknown> = {}, options: Record<string, unknown> = {}) {
return mount(defineComponent({
setup: () => () => h(DatePickerRoot, rootProps, {
default: () => h(DatePickerFieldRoot, null, {
default: ({ segments }: { segments: SegmentContent[] }) =>
segments.map((seg, i) => h(DatePickerFieldSegment, { key: i, part: seg.part }, {
default: () => seg.value,
})),
}),
}),
}), { attachTo: document.body, ...options });
}
function segments(wrapper: ReturnType<typeof mount>, part?: string) {
const sel = part
? `[data-primitives-date-picker-segment="${part}"]`
: '[role="spinbutton"]';
return Array.from(wrapper.element.querySelectorAll<HTMLElement>(sel));
}
describe('DatePicker field ARIA skeleton', () => {
let w: ReturnType<typeof mount> | undefined;
afterEach(() => {
w?.unmount();
w = undefined;
});
it('renders a role=group with role=spinbutton segments', () => {
w = mountField();
expect(w.element.querySelector('[role="group"]')).toBeTruthy();
const spin = segments(w);
// day/month/year for default day granularity
expect(spin.length).toBe(3);
for (const s of spin) {
expect(s.getAttribute('role')).toBe('spinbutton');
expect(s.getAttribute('tabindex')).toBe('0');
}
});
it('marks empty segments with data-placeholder and aria-valuetext=Empty', () => {
w = mountField();
const day = segments(w, 'day')[0]!;
expect(day.getAttribute('data-placeholder')).toBe('');
expect(day.getAttribute('aria-valuetext')).toBe('Empty');
expect(day.getAttribute('aria-valuemin')).toBe('1');
});
it('reflects the controlled value into segment aria-valuenow', async () => {
w = mountField({ modelValue: new Date(2024, 2, 15) });
await nextTick();
const day = segments(w, 'day')[0]!;
const month = segments(w, 'month')[0]!;
const year = segments(w, 'year')[0]!;
expect(day.getAttribute('aria-valuenow')).toBe('15');
expect(month.getAttribute('aria-valuenow')).toBe('3');
expect(year.getAttribute('aria-valuenow')).toBe('2024');
});
});
describe('DatePicker segment keyboard editing', () => {
let w: ReturnType<typeof mount> | undefined;
afterEach(() => {
w?.unmount();
w = undefined;
});
it('ArrowUp increments a segment, ArrowDown decrements', async () => {
w = mountField({ modelValue: new Date(2024, 2, 15) });
await nextTick();
const day = segments(w, 'day')[0]!;
press(day, 'ArrowUp');
await nextTick();
expect(segments(w, 'day')[0]!.getAttribute('aria-valuenow')).toBe('16');
press(segments(w, 'day')[0]!, 'ArrowDown');
press(segments(w, 'day')[0]!, 'ArrowDown');
await nextTick();
expect(segments(w, 'day')[0]!.getAttribute('aria-valuenow')).toBe('14');
});
it('ArrowUp on an empty segment seeds a sensible value', async () => {
w = mountField();
await nextTick();
const month = segments(w, 'month')[0]!;
press(month, 'ArrowUp');
await nextTick();
expect(segments(w, 'month')[0]!.getAttribute('aria-valuenow')).toBe('1');
});
it('numeric type-ahead fills a segment and auto-advances to the next', async () => {
w = mountField();
await nextTick();
const order = segments(w);
const monthSeg = segments(w, 'month')[0]!;
monthSeg.focus();
// typing 2 for month — month max 12, maxStart=1, so 2 > 1 → completes and advances
press(monthSeg, '2');
await nextTick();
expect(segments(w, 'month')[0]!.getAttribute('aria-valuenow')).toBe('2');
// focus advanced to the next focusable segment
expect(document.activeElement).not.toBe(monthSeg);
expect(order.length).toBeGreaterThan(1);
});
it('typing two digits builds a two-digit value before advancing', async () => {
w = mountField();
await nextTick();
const year = segments(w, 'year')[0]!;
year.focus();
press(year, '2');
press(segments(w, 'year')[0]!, '0');
press(segments(w, 'year')[0]!, '2');
press(segments(w, 'year')[0]!, '4');
await nextTick();
expect(segments(w, 'year')[0]!.getAttribute('aria-valuenow')).toBe('2024');
});
it('Backspace clears a digit / empties the segment', async () => {
w = mountField({ modelValue: new Date(2024, 2, 5) });
await nextTick();
const day = segments(w, 'day')[0]!;
// day=5 single digit → backspace empties it
press(day, 'Backspace');
await nextTick();
expect(segments(w, 'day')[0]!.getAttribute('aria-valuetext')).toBe('Empty');
});
it('commits a full date once all segments are filled', async () => {
w = mountField();
await nextTick();
const month = segments(w, 'month')[0]!;
const day = segments(w, 'day')[0]!;
const year = segments(w, 'year')[0]!;
// Fill in any order; commit fires when complete.
press(month, '5');
press(day, '6');
press(year, '2');
press(segments(w, 'year')[0]!, '0');
press(segments(w, 'year')[0]!, '2');
press(segments(w, 'year')[0]!, '0');
await nextTick();
const emitted = w.findComponent(DatePickerRoot).emitted('update:modelValue');
expect(emitted).toBeTruthy();
const last = emitted!.at(-1)![0] as Date;
expect(last.getFullYear()).toBe(2020);
expect(last.getMonth()).toBe(4);
expect(last.getDate()).toBe(6);
});
});
describe('DatePicker RTL-aware segment navigation', () => {
let w: ReturnType<typeof mount> | undefined;
afterEach(() => {
w?.unmount();
w = undefined;
});
it('ArrowRight moves to the next segment in LTR', async () => {
w = mountField({ locale: 'en-US' });
await nextTick();
const order = segments(w);
const first = order[0]!;
first.focus();
press(first, 'ArrowRight');
await nextTick();
expect(document.activeElement).toBe(order[1]);
});
it('ArrowRight moves to the previous segment in RTL', async () => {
w = mountField({ locale: 'en-US', dir: 'rtl' });
await nextTick();
const order = segments(w);
const second = order[1]!;
second.focus();
press(second, 'ArrowRight');
await nextTick();
expect(document.activeElement).toBe(order[0]);
});
});
describe('DatePicker time granularity', () => {
let w: ReturnType<typeof mount> | undefined;
afterEach(() => {
w?.unmount();
w = undefined;
});
it('renders hour/minute segments for granularity=minute', async () => {
w = mountField({ granularity: 'minute', hourCycle: 24, modelValue: new Date(2024, 0, 1, 13, 30) });
await nextTick();
expect(segments(w, 'hour').length).toBe(1);
expect(segments(w, 'minute').length).toBe(1);
expect(segments(w, 'hour')[0]!.getAttribute('aria-valuenow')).toBe('13');
expect(segments(w, 'minute')[0]!.getAttribute('aria-valuenow')).toBe('30');
});
it('renders a dayPeriod segment for a 12-hour cycle and toggles with a/p', async () => {
w = mountField({ granularity: 'minute', hourCycle: 12, modelValue: new Date(2024, 0, 1, 9, 0) });
await nextTick();
const period = segments(w, 'dayPeriod')[0]!;
expect(period).toBeTruthy();
expect(period.getAttribute('aria-valuetext')).toBe('AM');
press(period, 'p');
await nextTick();
expect(segments(w, 'dayPeriod')[0]!.getAttribute('aria-valuetext')).toBe('PM');
expect(segments(w, 'hour')[0]!.getAttribute('aria-valuenow')).toBe('21');
});
it('preserves time-of-day when picking a calendar day', async () => {
w = mountField({ granularity: 'minute', hourCycle: 24, modelValue: new Date(2024, 0, 10, 8, 45) });
await nextTick();
// bump the hour segment then ensure minute kept
const hour = segments(w, 'hour')[0]!;
press(hour, 'ArrowUp');
await nextTick();
const emitted = w.findComponent(DatePickerRoot).emitted('update:modelValue');
const last = emitted!.at(-1)![0] as Date;
expect(last.getHours()).toBe(9);
expect(last.getMinutes()).toBe(45);
});
});
describe('DatePicker disabled / readonly guards', () => {
let w: ReturnType<typeof mount> | undefined;
afterEach(() => {
w?.unmount();
w = undefined;
});
it('disabled blocks segment mutation and marks aria-disabled', async () => {
w = mountField({ disabled: true, modelValue: new Date(2024, 2, 15) });
await nextTick();
const day = segments(w, 'day')[0]!;
expect(day.getAttribute('aria-disabled')).toBe('true');
press(day, 'ArrowUp');
await nextTick();
expect(segments(w, 'day')[0]!.getAttribute('aria-valuenow')).toBe('15');
});
it('readonly blocks segment mutation', async () => {
w = mountField({ readonly: true, modelValue: new Date(2024, 2, 15) });
await nextTick();
const day = segments(w, 'day')[0]!;
press(day, 'ArrowUp');
await nextTick();
expect(segments(w, 'day')[0]!.getAttribute('aria-valuenow')).toBe('15');
});
});
describe('DatePicker trigger honors disabled', () => {
let w: ReturnType<typeof mount> | undefined;
afterEach(() => {
w?.unmount();
w = undefined;
});
function mountTrigger(rootProps: Record<string, unknown> = {}) {
return mount(defineComponent({
setup: () => () => h(DatePickerRoot, rootProps, {
default: () => [
h(DatePickerTrigger, null, { default: () => 'open' }),
h(DatePickerContent, null, { default: () => h(DatePickerCalendar) }),
],
}),
}), { attachTo: document.body });
}
it('does not open when disabled', async () => {
w = mountTrigger({ disabled: true });
await nextTick();
const trigger = w.element.querySelector<HTMLElement>('[data-primitives-date-picker-trigger]')!;
expect(trigger.getAttribute('data-disabled')).toBe('');
expect((trigger as HTMLButtonElement).disabled).toBe(true);
trigger.click();
await nextTick();
expect(w.findComponent(DatePickerRoot).emitted('update:open')).toBeFalsy();
});
it('opens normally when enabled', async () => {
w = mountTrigger();
await nextTick();
const trigger = w.element.querySelector<HTMLElement>('[data-primitives-date-picker-trigger]')!;
trigger.click();
await nextTick();
const emitted = w.findComponent(DatePickerRoot).emitted('update:open');
expect(emitted).toBeTruthy();
expect(emitted!.at(-1)![0]).toBe(true);
});
});
describe('DatePicker preventDeselect', () => {
let w: ReturnType<typeof mount> | undefined;
afterEach(() => {
w?.unmount();
w = undefined;
});
function findCell(wrapper: ReturnType<typeof mount>, date: Date): HTMLElement {
const iso = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const triggers = Array.from(
wrapper.element.querySelectorAll<HTMLElement>('[data-primitives-calendar-cell-trigger][data-value]'),
);
const match = triggers.find(t => t.getAttribute('data-value') === iso);
return match ?? triggers[0]!;
}
function mountWithCalendar(rootProps: Record<string, unknown> = {}) {
return mount(defineComponent({
setup: () => () => h(DatePickerRoot, rootProps, {
default: () => h(DatePickerCalendar, null, {
default: () => h(DatePickerGrid, { month: new Date(2024, 2, 1) }, {
default: () => h(DatePickerGridBody, null, {
default: () => h(DatePickerGridRow, null, {
default: () => h(DatePickerCell, { date: new Date(2024, 2, 15) }, {
default: () => h(DatePickerCellTrigger, {
day: new Date(2024, 2, 15),
month: new Date(2024, 2, 1),
}),
}),
}),
}),
}),
}),
}),
}), { attachTo: document.body });
}
it('re-selecting the same date clears it by default', async () => {
w = mountWithCalendar({ modelValue: new Date(2024, 2, 15), closeOnSelect: false });
await nextTick();
const cell = findCell(w, new Date(2024, 2, 15));
cell.click();
await nextTick();
const emitted = w.findComponent(DatePickerRoot).emitted('update:modelValue');
expect(emitted).toBeTruthy();
expect(emitted!.at(-1)![0]).toBeUndefined();
});
it('keeps the date selected when preventDeselect is set', async () => {
w = mountWithCalendar({
modelValue: new Date(2024, 2, 15),
preventDeselect: true,
closeOnSelect: false,
});
await nextTick();
const cell = findCell(w, new Date(2024, 2, 15));
cell.click();
await nextTick();
const emitted = w.findComponent(DatePickerRoot).emitted('update:modelValue');
if (emitted) {
const last = emitted.at(-1)![0] as Date | undefined;
expect(last).toBeInstanceOf(Date);
}
});
});
describe('DatePicker form validation input', () => {
let w: ReturnType<typeof mount> | undefined;
afterEach(() => {
w?.unmount();
w = undefined;
});
it('renders a focusable native input when required', async () => {
w = mountField({ required: true, name: 'date' });
await nextTick();
const input = w.element.querySelector<HTMLInputElement>('input[type="date"]');
expect(input).toBeTruthy();
expect(input!.required).toBe(true);
});
it('uses datetime-local type for time granularity with min/max', async () => {
w = mountField({
granularity: 'minute',
hourCycle: 24,
minValue: new Date(2024, 0, 1, 8, 0),
maxValue: new Date(2024, 11, 31, 18, 0),
});
await nextTick();
const input = w.element.querySelector<HTMLInputElement>('input[type="datetime-local"]');
expect(input).toBeTruthy();
expect(input!.min).toBe('2024-01-01T08:00');
expect(input!.max).toBe('2024-12-31T18:00');
});
});
@@ -0,0 +1,38 @@
import type { ComputedRef, Ref } from 'vue';
import type { DateAdapter } from '../../utilities/config-provider';
import type { Granularity, HourCycle } from './use-date-field';
import { useContextFactory } from '@robonen/vue';
export interface DatePickerRootContext {
/** Resolved date backend (root `dateAdapter` prop or the global `ConfigProvider`). */
dateAdapter: ComputedRef<DateAdapter<Date>>;
open: Ref<boolean>;
modal: Ref<boolean>;
name: Ref<string | undefined>;
modelValue: Ref<Date | undefined>;
placeholder: Ref<Date>;
locale: Ref<string>;
dir: Ref<'ltr' | 'rtl'>;
disabled: Ref<boolean>;
readonly: Ref<boolean>;
required: Ref<boolean>;
isInvalid: ComputedRef<boolean>;
granularity: ComputedRef<Granularity>;
hourCycle: Ref<HourCycle>;
minValue: Ref<Date | undefined>;
maxValue: Ref<Date | undefined>;
triggerId: ComputedRef<string>;
contentId: ComputedRef<string>;
fieldId: ComputedRef<string>;
triggerElement: Ref<HTMLElement | undefined>;
hasCustomAnchor: Ref<boolean>;
/** Commit a date from any source (calendar cell or field), honoring readonly/granularity/preventDeselect. */
onDateChange: (date: Date | undefined) => void;
onPlaceholderChange: (date: Date) => void;
onOpenChange: (value: boolean) => void;
onOpenToggle: () => void;
}
const ctx = useContextFactory<DatePickerRootContext>('DatePickerRoot');
export const provideDatePickerRootContext = ctx.provide;
export const useDatePickerRootContext = ctx.inject;
@@ -0,0 +1,158 @@
<script lang="ts">
import { defineComponent, h } from 'vue';
import {
DatePickerCell,
DatePickerCellTrigger,
DatePickerGrid,
DatePickerGridBody,
DatePickerGridHead,
DatePickerGridRow,
DatePickerHeadCell,
useCalendarRootContext,
} from '@robonen/primitives';
// Reads the calendar context provided by DatePickerRoot. Defined as a child so
// the injection resolves (the demo's own <script setup> is the Root's parent).
const CalendarBody = defineComponent({
name: 'CalendarBody',
setup() {
const ctx = useCalendarRootContext();
return () => ctx.grid.value.map(month => h(
DatePickerGrid,
{ key: month.value.toString(), month: month.value, class: 'w-full border-collapse select-none' },
() => [
h(DatePickerGridHead, null, () => h(
DatePickerGridRow,
{ class: 'mb-1 flex' },
() => ctx.weekDays.value.map((weekday, i) => h(
DatePickerHeadCell,
{ key: weekday + i, class: 'w-9 text-center text-xs font-medium text-fg-subtle' },
() => weekday,
)),
)),
h(DatePickerGridBody, null, () => month.weeks.map((week, w) => h(
DatePickerGridRow,
{ key: w, class: 'flex w-full' },
() => week.map(day => h(
DatePickerCell,
{ key: day.toString(), date: day, class: 'p-0.5' },
() => h(
DatePickerCellTrigger,
{
day,
month: month.value,
class: `flex size-8 items-center justify-center rounded-lg text-sm tabular-nums transition outline-none cursor-pointer
focus-visible:ring-2 focus-visible:ring-ring
hover:bg-bg-inset
data-[selected]:bg-accent data-[selected]:font-semibold data-[selected]:text-accent-fg data-[selected]:hover:bg-accent-hover
data-[outside-view]:text-fg-subtle data-[outside-view]:opacity-50
data-[disabled]:cursor-not-allowed data-[disabled]:opacity-30`,
},
),
)),
))),
],
));
},
});
export default CalendarBody;
</script>
<script setup lang="ts">
import {
DatePickerCalendar,
DatePickerClose,
DatePickerContent,
DatePickerField,
DatePickerHeading,
DatePickerNext,
DatePickerPrev,
DatePickerRoot,
DatePickerTrigger,
} from '@robonen/primitives';
import { ref } from 'vue';
const value = ref<Date>();
</script>
<template>
<div class="flex w-full max-w-xs flex-col gap-2">
<span class="text-xs font-medium text-fg-muted">Departure date</span>
<DatePickerRoot v-slot="{ open }" v-model="value" :close-on-select="true">
<div class="flex items-stretch gap-1.5">
<DatePickerField
:format="{ weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }"
placeholder-text="Select a date"
class="min-w-0 flex-1 rounded-lg border border-border bg-bg px-3 py-2 text-sm text-fg outline-none placeholder:text-fg-subtle focus-visible:ring-2 focus-visible:ring-ring"
/>
<DatePickerTrigger
aria-label="Open calendar"
class="inline-flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-bg text-fg-muted transition hover:bg-bg-inset hover:text-fg active:scale-95 cursor-pointer data-[state=open]:bg-bg-inset data-[state=open]:text-fg"
>
<svg
class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" />
</svg>
</DatePickerTrigger>
</div>
<DatePickerContent
:side-offset="6"
class="demo-card z-50 p-3 text-fg shadow-lg data-[state=closed]:opacity-0"
>
<DatePickerCalendar>
<div class="mb-3 flex items-center justify-between gap-2">
<DatePickerPrev
aria-label="Previous month"
class="inline-flex size-8 items-center justify-center rounded-lg border border-border bg-bg text-fg-muted transition hover:bg-bg-inset hover:text-fg active:scale-95 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
>
</DatePickerPrev>
<DatePickerHeading class="text-sm font-semibold tracking-tight" />
<DatePickerNext
aria-label="Next month"
class="inline-flex size-8 items-center justify-center rounded-lg border border-border bg-bg text-fg-muted transition hover:bg-bg-inset hover:text-fg active:scale-95 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
>
</DatePickerNext>
</div>
<CalendarBody />
</DatePickerCalendar>
<div class="mt-3 flex items-center justify-between border-t border-border pt-3">
<button
type="button"
class="rounded-md px-2 py-1 text-xs font-medium text-fg-muted transition hover:bg-bg-inset hover:text-fg cursor-pointer"
@click="value = undefined"
>
Clear
</button>
<DatePickerClose
class="rounded-md bg-accent px-3 py-1 text-xs font-medium text-accent-fg transition hover:bg-accent-hover active:scale-95 cursor-pointer"
>
Done
</DatePickerClose>
</div>
</DatePickerContent>
<p v-if="false">{{ open }}</p>
</DatePickerRoot>
<p class="text-xs text-fg-subtle">
<template v-if="value">
Selected
<span class="font-medium text-fg-muted">{{ value.toLocaleDateString('en', { dateStyle: 'medium' }) }}</span>
</template>
<template v-else>
No date selected yet
</template>
</p>
</div>
</template>
@@ -0,0 +1,34 @@
import type { ComputedRef, Ref } from 'vue';
import type { DateAdapter } from '../../utilities/config-provider';
import type { Granularity, HourCycle, SegmentContent, SegmentPart, SegmentValues } from './use-date-field';
import { useContextFactory } from '@robonen/vue';
export interface DatePickerFieldContext {
/** Resolved date backend, inherited from `DatePickerRoot`. */
dateAdapter: ComputedRef<DateAdapter<Date>>;
locale: Ref<string>;
dir: Ref<'ltr' | 'rtl'>;
placeholder: Ref<Date>;
disabled: Ref<boolean>;
readonly: Ref<boolean>;
isInvalid: Ref<boolean>;
hourCycle: Ref<HourCycle>;
granularity: ComputedRef<Granularity>;
/** Live per-part numeric/string values (null when empty). */
segmentValues: Ref<SegmentValues>;
/** Ordered, formatted segment descriptors (incl. literals) for rendering. */
segmentContents: ComputedRef<SegmentContent[]>;
/** Registered focusable segment elements in DOM order. */
registerSegment: (el: HTMLElement, part: SegmentPart) => () => void;
/** Move focus to the next/previous focusable segment (RTL-aware). */
focusSegment: (from: HTMLElement, direction: 1 | -1) => void;
focusNext: (from: HTMLElement) => void;
/** Mutate a single part value and recompute the committed model value. */
updateSegment: (part: SegmentPart, value: number | string | null) => void;
/** Commit current segment values into the picker model if complete. */
commit: () => void;
}
const ctx = useContextFactory<DatePickerFieldContext>('date-picker-field');
export const provideDatePickerFieldContext = ctx.provide;
export const useDatePickerFieldContext = ctx.inject;
@@ -0,0 +1,54 @@
export { default as DatePickerRoot } from './DatePickerRoot.vue';
export { default as DatePickerTrigger } from './DatePickerTrigger.vue';
export { default as DatePickerAnchor } from './DatePickerAnchor.vue';
export { default as DatePickerPortal } from './DatePickerPortal.vue';
export { default as DatePickerContent } from './DatePickerContent.vue';
export { default as DatePickerArrow } from './DatePickerArrow.vue';
export { default as DatePickerClose } from './DatePickerClose.vue';
export { default as DatePickerCalendar } from './DatePickerCalendar.vue';
export { default as DatePickerField } from './DatePickerField.vue';
export { default as DatePickerInput } from './DatePickerField.vue';
export { default as DatePickerFieldRoot } from './DatePickerFieldRoot.vue';
export { default as DatePickerFieldSegment } from './DatePickerFieldSegment.vue';
// Calendar subparts re-exported as DatePicker* aliases (share CalendarRootContext provided by DatePickerRoot).
export { default as DatePickerHeader } from '../calendar/CalendarHeader.vue';
export { default as DatePickerHeading } from '../calendar/CalendarHeading.vue';
export { default as DatePickerPrev } from '../calendar/CalendarPrev.vue';
export { default as DatePickerNext } from '../calendar/CalendarNext.vue';
export { default as DatePickerGrid } from '../calendar/CalendarGrid.vue';
export { default as DatePickerGridHead } from '../calendar/CalendarGridHead.vue';
export { default as DatePickerGridBody } from '../calendar/CalendarGridBody.vue';
export { default as DatePickerGridRow } from '../calendar/CalendarGridRow.vue';
export { default as DatePickerHeadCell } from '../calendar/CalendarHeadCell.vue';
export { default as DatePickerCell } from '../calendar/CalendarCell.vue';
export { default as DatePickerCellTrigger } from '../calendar/CalendarCellTrigger.vue';
export { provideDatePickerRootContext, useDatePickerRootContext } from './context';
export type { DatePickerRootContext } from './context';
export { provideDatePickerFieldContext, useDatePickerFieldContext } from './field-context';
export type { DatePickerFieldContext } from './field-context';
export type {
DateSegmentPart,
EditableSegmentPart,
Granularity,
HourCycle,
SegmentContent,
SegmentPart,
SegmentValues,
TimeSegmentPart,
} from './use-date-field';
export type { DatePickerRootEmits, DatePickerRootProps } from './DatePickerRoot.vue';
export type { DatePickerTriggerProps } from './DatePickerTrigger.vue';
export type { DatePickerAnchorProps } from './DatePickerAnchor.vue';
export type { DatePickerPortalProps } from './DatePickerPortal.vue';
export type { DatePickerContentEmits, DatePickerContentProps } from './DatePickerContent.vue';
export type { DatePickerArrowProps } from './DatePickerArrow.vue';
export type { DatePickerCloseProps } from './DatePickerClose.vue';
export type { DatePickerCalendarProps } from './DatePickerCalendar.vue';
export type { DatePickerFieldProps } from './DatePickerField.vue';
export type { DatePickerFieldRootProps, DatePickerFieldRootSlot } from './DatePickerFieldRoot.vue';
export type { DatePickerFieldSegmentProps } from './DatePickerFieldSegment.vue';
@@ -0,0 +1,510 @@
import type { DateAdapter } from '../../utilities/config-provider';
export type Granularity = 'day' | 'hour' | 'minute' | 'second';
export type HourCycle = 12 | 24 | undefined;
export type DateSegmentPart = 'day' | 'month' | 'year';
export type TimeSegmentPart = 'hour' | 'minute' | 'second' | 'dayPeriod';
export type EditableSegmentPart = DateSegmentPart | TimeSegmentPart;
export type SegmentPart = EditableSegmentPart | 'literal';
export type DayPeriod = 'AM' | 'PM';
export interface SegmentValues {
day: number | null;
month: number | null;
year: number | null;
hour?: number | null;
minute?: number | null;
second?: number | null;
dayPeriod?: DayPeriod;
}
export interface SegmentContent {
part: SegmentPart;
value: string;
}
export const DATE_SEGMENT_PARTS: DateSegmentPart[] = ['day', 'month', 'year'];
export const TIME_SEGMENT_PARTS: TimeSegmentPart[] = ['hour', 'minute', 'second', 'dayPeriod'];
export const EDITABLE_SEGMENT_PARTS: EditableSegmentPart[] = [...DATE_SEGMENT_PARTS, ...TIME_SEGMENT_PARTS];
export function isEditableSegmentPart(part: string): part is EditableSegmentPart {
return (EDITABLE_SEGMENT_PARTS as string[]).includes(part);
}
export function hasTimeGranularity(granularity: Granularity): boolean {
return granularity === 'hour' || granularity === 'minute' || granularity === 'second';
}
export function isSegmentNavigationKey(key: string): boolean {
return key === 'ArrowLeft' || key === 'ArrowRight';
}
export function isNumberKey(key: string): boolean {
return key.length === 1 && key >= '0' && key <= '9';
}
export function isAcceptableSegmentKey(key: string): boolean {
if (isNumberKey(key))
return true;
switch (key) {
case 'Enter':
case 'ArrowUp':
case 'ArrowDown':
case 'ArrowLeft':
case 'ArrowRight':
case 'Backspace':
case 'Delete':
case ' ':
case 'a':
case 'A':
case 'p':
case 'P':
return true;
default:
return false;
}
}
/** Empty value set for the requested granularity. */
export function initializeSegmentValues(granularity: Granularity): SegmentValues {
const base: SegmentValues = { day: null, month: null, year: null };
if (!hasTimeGranularity(granularity))
return base;
base.hour = null;
base.dayPeriod = 'AM';
if (granularity === 'minute' || granularity === 'second')
base.minute = null;
if (granularity === 'second')
base.second = null;
return base;
}
/** Extract segment values from a concrete date for the requested granularity. */
export function syncSegmentValues(
adapter: DateAdapter<Date>,
date: Date,
granularity: Granularity,
): SegmentValues {
const parts = adapter.getParts(date);
const values: SegmentValues = {
day: parts.day,
month: parts.month,
year: parts.year,
};
if (hasTimeGranularity(granularity)) {
const h = parts.hour;
values.hour = h;
values.dayPeriod = h >= 12 ? 'PM' : 'AM';
if (granularity === 'minute' || granularity === 'second')
values.minute = parts.minute;
if (granularity === 'second')
values.second = parts.second;
}
return values;
}
/** True when every editable part for the granularity has a value. */
export function isSegmentValuesComplete(values: SegmentValues, granularity: Granularity): boolean {
if (values.day === null || values.month === null || values.year === null)
return false;
if (!hasTimeGranularity(granularity))
return true;
if (values.hour === null || values.hour === undefined)
return false;
if ((granularity === 'minute' || granularity === 'second') && (values.minute === null || values.minute === undefined))
return false;
if (granularity === 'second' && (values.second === null || values.second === undefined))
return false;
return true;
}
/** Build a date from complete segment values (caller guarantees completeness). */
export function segmentValuesToDate(
adapter: DateAdapter<Date>,
values: SegmentValues,
granularity: Granularity,
): Date {
const year = values.year as number;
const month = values.month as number;
const day = values.day as number;
if (!hasTimeGranularity(granularity))
return adapter.fromParts({ year, month, day });
const hour = (values.hour as number) ?? 0;
const minute = (granularity === 'minute' || granularity === 'second') ? ((values.minute as number) ?? 0) : 0;
const second = granularity === 'second' ? ((values.second as number) ?? 0) : 0;
return adapter.fromParts({ year, month, day, hour, minute, second });
}
interface FormatPartOptions {
hourCycle: HourCycle;
locale: string;
}
/** Render the live string for a single segment, falling back to a placeholder. */
export function formatSegment(
part: SegmentPart,
values: SegmentValues,
placeholder: Date,
opts: FormatPartOptions,
): string {
switch (part) {
case 'day':
return values.day === null ? 'dd' : String(values.day).padStart(2, '0');
case 'month':
return values.month === null ? 'mm' : String(values.month).padStart(2, '0');
case 'year':
return values.year === null ? 'yyyy' : String(values.year).padStart(4, '0');
case 'hour': {
if (values.hour === null || values.hour === undefined)
return 'hh';
const is12 = resolveHourCycle(opts.hourCycle, opts.locale) === 12;
if (!is12)
return String(values.hour).padStart(2, '0');
const h = values.hour % 12 === 0 ? 12 : values.hour % 12;
return String(h).padStart(2, '0');
}
case 'minute':
return values.minute === null || values.minute === undefined ? 'mm' : String(values.minute).padStart(2, '0');
case 'second':
return values.second === null || values.second === undefined ? 'ss' : String(values.second).padStart(2, '0');
case 'dayPeriod':
return values.dayPeriod ?? 'AM';
default:
return '';
}
}
let cachedLocale: string | undefined;
let cachedHourCycleIs12: boolean | undefined;
/** Resolve the effective hour cycle: explicit prop wins, else infer from locale. */
export function resolveHourCycle(hourCycle: HourCycle, locale: string): 12 | 24 {
if (hourCycle === 12 || hourCycle === 24)
return hourCycle;
if (cachedLocale !== locale) {
cachedLocale = locale;
try {
const resolved = new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions().hourCycle;
cachedHourCycleIs12 = resolved === 'h11' || resolved === 'h12';
}
catch {
cachedHourCycleIs12 = false;
}
}
return cachedHourCycleIs12 ? 12 : 24;
}
/**
* Ordered list of segment descriptors (incl. literals) honoring locale order
* for the date parts and a fixed time order, mirroring native formatting.
*/
export function createSegmentContents(
values: SegmentValues,
placeholder: Date,
granularity: Granularity,
hourCycle: HourCycle,
locale: string,
): SegmentContent[] {
const dateOrder = resolveDatePartOrder(locale);
const dateLiteral = resolveDateLiteral(locale);
const out: SegmentContent[] = [];
dateOrder.forEach((part, index) => {
if (index > 0)
out.push({ part: 'literal', value: dateLiteral });
out.push({ part, value: formatSegment(part, values, placeholder, { hourCycle, locale }) });
});
if (hasTimeGranularity(granularity)) {
out.push({ part: 'literal', value: ', ' });
out.push({ part: 'hour', value: formatSegment('hour', values, placeholder, { hourCycle, locale }) });
if (granularity === 'minute' || granularity === 'second') {
out.push({ part: 'literal', value: ':' });
out.push({ part: 'minute', value: formatSegment('minute', values, placeholder, { hourCycle, locale }) });
}
if (granularity === 'second') {
out.push({ part: 'literal', value: ':' });
out.push({ part: 'second', value: formatSegment('second', values, placeholder, { hourCycle, locale }) });
}
if (resolveHourCycle(hourCycle, locale) === 12) {
out.push({ part: 'literal', value: ' ' });
out.push({ part: 'dayPeriod', value: formatSegment('dayPeriod', values, placeholder, { hourCycle, locale }) });
}
}
return out;
}
const datePartOrderCache = new Map<string, DateSegmentPart[]>();
/** Derive `[day, month, year]` order from the locale's numeric format. */
export function resolveDatePartOrder(locale: string): DateSegmentPart[] {
const cached = datePartOrderCache.get(locale);
if (cached)
return cached;
let order: DateSegmentPart[] = ['month', 'day', 'year'];
try {
const parts = new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
.formatToParts(new Date(2000, 0, 2));
const derived = parts
.map(p => p.type)
.filter((t): t is DateSegmentPart => t === 'day' || t === 'month' || t === 'year');
if (derived.length === 3)
order = derived;
}
catch {
// keep default
}
datePartOrderCache.set(locale, order);
return order;
}
function resolveDateLiteral(locale: string): string {
try {
const parts = new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
.formatToParts(new Date(2000, 0, 2));
const literal = parts.find(p => p.type === 'literal');
if (literal && literal.value.trim().length <= 1)
return literal.value;
}
catch {
// keep default
}
return '/';
}
interface TypeAheadState {
hasLeftFocus: boolean;
lastKeyZero: boolean;
}
interface UpdateResult {
value: number | null;
moveToNext: boolean;
}
/** Numeric type-ahead for capped two-digit fields (day/month/hour/minute/second). */
function updateCappedField(
max: number,
num: number,
prev: number | null,
state: TypeAheadState,
allowZeroValue: boolean,
): UpdateResult {
const maxStart = Math.floor(max / 10);
if (state.hasLeftFocus) {
state.hasLeftFocus = false;
state.lastKeyZero = false;
prev = null;
}
if (prev === null || prev === undefined) {
if (num === 0) {
state.lastKeyZero = true;
return { value: allowZeroValue ? 0 : null, moveToNext: false };
}
const moveToNext = state.lastKeyZero || num > maxStart;
state.lastKeyZero = false;
return { value: num, moveToNext };
}
const digits = prev.toString().length;
const total = Number.parseInt(prev.toString() + num.toString(), 10);
if (digits === 2 || total > max) {
const moveToNext = num > maxStart || total > max;
return { value: num, moveToNext };
}
return { value: total, moveToNext: true };
}
function updateYear(num: number, prev: number | null, state: TypeAheadState): UpdateResult {
if (state.hasLeftFocus) {
state.hasLeftFocus = false;
prev = null;
}
if (prev === null || prev === undefined)
return { value: num === 0 ? 1 : num, moveToNext: false };
const str = prev.toString() + num.toString();
if (str.length > 4)
return { value: num === 0 ? 1 : num, moveToNext: false };
return { value: Number.parseInt(str, 10), moveToNext: str.length === 4 };
}
function cycle(value: number | null, delta: number, min: number, max: number, fallback: number): number {
if (value === null || value === undefined)
return delta > 0 ? min : fallback;
let next = value + delta;
const range = max - min + 1;
if (next > max)
next = min + ((next - min) % range);
if (next < min)
next = max - ((min - next - 1) % range);
return next;
}
export interface SegmentKeydownContext {
adapter: DateAdapter<Date>;
part: EditableSegmentPart;
values: SegmentValues;
placeholder: Date;
granularity: Granularity;
hourCycle: HourCycle;
locale: string;
state: TypeAheadState;
focusNext: () => void;
}
/**
* Apply a keydown to a single segment, returning the new value for that part
* (and, for hour edits, the synchronized day-period). Returns `null` value to
* clear. The caller writes the result into `segmentValues` and commits.
*/
export function applySegmentKeydown(
e: KeyboardEvent,
ctx: SegmentKeydownContext,
): { part: EditableSegmentPart; value: number | string | null; dayPeriod?: DayPeriod } | undefined {
const { adapter, part, values, placeholder, state } = ctx;
const key = e.key;
if (!isAcceptableSegmentKey(key) || isSegmentNavigationKey(key))
return undefined;
if (key === 'Backspace' || key === 'Delete') {
state.hasLeftFocus = false;
return { part, value: deleteDigit(values[part] as number | string | null) };
}
if (part === 'dayPeriod')
return applyDayPeriod(e, values);
const isArrow = key === 'ArrowUp' || key === 'ArrowDown';
const delta = key === 'ArrowUp' ? 1 : -1;
switch (part) {
case 'day': {
const monthDays = values.month
? adapter.getDaysInMonth(adapter.fromParts({ year: adapter.getParts(placeholder).year, month: values.month, day: 1 }))
: 31;
if (isArrow)
return { part, value: cycle(values.day, delta, 1, monthDays, monthDays) };
if (isNumberKey(key)) {
const r = updateCappedField(monthDays, Number.parseInt(key, 10), values.day, state, false);
if (r.moveToNext)
ctx.focusNext();
return { part, value: r.value };
}
return undefined;
}
case 'month': {
if (isArrow)
return { part, value: cycle(values.month, delta, 1, 12, 12) };
if (isNumberKey(key)) {
const r = updateCappedField(12, Number.parseInt(key, 10), values.month, state, false);
if (r.moveToNext)
ctx.focusNext();
return { part, value: r.value };
}
return undefined;
}
case 'year': {
if (isArrow)
return { part, value: values.year === null ? placeholder.getFullYear() : Math.max(1, values.year + delta) };
if (isNumberKey(key)) {
const r = updateYear(Number.parseInt(key, 10), values.year, state);
if (r.moveToNext)
ctx.focusNext();
return { part, value: r.value };
}
return undefined;
}
case 'hour': {
const is12 = resolveHourCycle(ctx.hourCycle, ctx.locale) === 12;
if (isArrow) {
const next = cycle(values.hour ?? null, delta, 0, 23, 23);
return { part, value: next, dayPeriod: next >= 12 ? 'PM' : 'AM' };
}
if (isNumberKey(key)) {
const displayMax = is12 ? 12 : 23;
let displayPrev = values.hour ?? null;
if (is12 && displayPrev !== null)
displayPrev = displayPrev % 12 === 0 ? 0 : (displayPrev > 12 ? displayPrev - 12 : displayPrev);
const r = updateCappedField(displayMax, Number.parseInt(key, 10), displayPrev, state, true);
let internal = r.value;
if (is12 && internal !== null) {
const period = values.dayPeriod ?? 'AM';
internal = internal === 12
? (period === 'AM' ? 0 : 12)
: (period === 'PM' ? internal + 12 : internal);
}
if (r.moveToNext)
ctx.focusNext();
return { part, value: internal, dayPeriod: internal === null ? undefined : (internal >= 12 ? 'PM' : 'AM') };
}
return undefined;
}
case 'minute': {
if (isArrow)
return { part, value: cycle(values.minute ?? null, delta, 0, 59, 59) };
if (isNumberKey(key)) {
const r = updateCappedField(59, Number.parseInt(key, 10), values.minute ?? null, state, true);
if (r.moveToNext)
ctx.focusNext();
return { part, value: r.value };
}
return undefined;
}
case 'second': {
if (isArrow)
return { part, value: cycle(values.second ?? null, delta, 0, 59, 59) };
if (isNumberKey(key)) {
const r = updateCappedField(59, Number.parseInt(key, 10), values.second ?? null, state, true);
if (r.moveToNext)
ctx.focusNext();
return { part, value: r.value };
}
return undefined;
}
default:
return undefined;
}
}
function applyDayPeriod(
e: KeyboardEvent,
values: SegmentValues,
): { part: 'dayPeriod'; value: DayPeriod; hour?: number } | undefined {
const key = e.key;
const current = values.dayPeriod ?? 'AM';
const hour = values.hour ?? null;
const setPeriod = (period: DayPeriod): { part: 'dayPeriod'; value: DayPeriod; hour?: number } => {
if (hour === null)
return { part: 'dayPeriod', value: period };
if (period === 'PM' && hour < 12)
return { part: 'dayPeriod', value: period, hour: hour + 12 };
if (period === 'AM' && hour >= 12)
return { part: 'dayPeriod', value: period, hour: hour - 12 };
return { part: 'dayPeriod', value: period };
};
if (key === 'ArrowUp' || key === 'ArrowDown')
return setPeriod(current === 'AM' ? 'PM' : 'AM');
if (key === 'a' || key === 'A')
return setPeriod('AM');
if (key === 'p' || key === 'P')
return setPeriod('PM');
return undefined;
}
function deleteDigit(prev: number | string | null): number | null {
if (prev === null || prev === undefined)
return null;
const str = prev.toString();
if (str.length <= 1)
return null;
return Number.parseInt(str.slice(0, -1), 10);
}
@@ -0,0 +1,41 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The visual fill of the progress bar, rendered inside `ProgressRoot`. It reads
* the value, max, and state from context and exposes them via `data-state`,
* `data-value`, and `data-max` (plus matching slot props) so you can size and
* style the fill — e.g. translating it by the completion percentage.
*/
export interface ProgressIndicatorProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
import { useProgressContext } from './context';
const { as = 'div' } = defineProps<ProgressIndicatorProps>();
const { forwardRef } = useForwardExpose();
const ctx = useProgressContext();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:data-state="ctx.state.value"
:data-value="ctx.value.value ?? undefined"
:data-max="ctx.max.value"
>
<slot
:value="ctx.value.value"
:max="ctx.max.value"
:state="ctx.state.value"
:progress="ctx.progress.value"
:percentage="ctx.percentage.value"
/>
</Primitive>
</template>
@@ -0,0 +1,133 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
import type { ProgressState } from './context';
/**
* A bar that shows the completion progress of a task, typically a horizontal
* fill that grows from empty to full. Use it for file uploads, multi-step form
* progress, loading indicators, or any operation whose progress you can measure
* (or, with a `null` value, signal as indeterminate).
*
* The root renders the accessible `progressbar` (wiring up `aria-valuemin`,
* `aria-valuemax`, `aria-valuenow`, `aria-valuetext`, and an `aria-label`
* accessible name) and derives the current `state` — `indeterminate`,
* `loading`, or `complete` — which it provides via context and exposes on the
* `data-state` attribute. Pair it with `ProgressIndicator` for the visual fill.
*
* Both `modelValue` and `max` are two-way (`v-model` / `v-model:max`): bad
* inputs (`NaN`, negatives, out-of-range, `max <= 0`) are validated, clamped,
* and reported in development, so the rendered ARIA is always valid.
*/
export interface ProgressRootProps extends PrimitiveProps {
/** Current value. `null` denotes an indeterminate progress bar. Two-way via `v-model`. */
modelValue?: number | null;
/** Maximum value. Two-way via `v-model:max`. @default 100 */
max?: number;
/**
* Builds the `aria-valuetext` describing the current value in a human-readable
* form. Receives the resolved value (`null` when indeterminate) and max.
* @default `(v, m) => v == null ? undefined : `${Math.round((v / m) * 100)}%``
*/
getValueLabel?: (value: number | null, max: number) => string | undefined;
/**
* Accessible name for the progressbar, rendered as `aria-label`. Accepts a
* static string or a function of the resolved value and max. Provide this (or
* an external `aria-labelledby`) so screen readers announce a meaningful name.
*/
accessibleLabel?: string | ((value: number | null, max: number) => string | undefined);
}
export interface ProgressRootEmits {
/** Emitted when the value changes (after validation/clamping). */
'update:modelValue': [value: number | null];
/** Emitted when the max changes (after validation). */
'update:max': [value: number];
}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { computed, ref } from 'vue';
import { provideProgressContext } from './context';
import { isFiniteNumber, resolveMax, resolveValue } from './utils';
import { useForwardExpose } from '@robonen/vue';
const {
max: maxProp,
getValueLabel = (v: number | null, m: number) => v === null ? undefined : `${Math.round((v / m) * 100)}%`,
accessibleLabel,
as = 'div',
} = defineProps<ProgressRootProps>();
defineEmits<ProgressRootEmits>();
const { forwardRef } = useForwardExpose();
const localValue = ref<number | null>(null);
const localMax = ref<number>(resolveMax(maxProp));
const max = defineModel<number>('max', {
get: external => resolveMax(external ?? localMax.value),
set: (value) => {
const next = resolveMax(value);
localMax.value = next;
return next;
},
});
const value = defineModel<number | null>({
get: external => resolveValue(external === undefined ? localValue.value : external, max.value),
set: (raw) => {
const next = resolveValue(raw, max.value);
localValue.value = next;
return next;
},
});
const state = computed<ProgressState>(() => {
const v = value.value;
if (v === null) return 'indeterminate';
if (v >= max.value) return 'complete';
return 'loading';
});
const progress = computed<number | null>(() => value.value === null ? null : value.value / max.value);
const percentage = computed<number | null>(() => progress.value === null ? null : Math.round(progress.value * 100));
const valueText = computed(() => getValueLabel(value.value, max.value));
const ariaLabel = computed(() => typeof accessibleLabel === 'function'
? accessibleLabel(value.value, max.value)
: accessibleLabel);
provideProgressContext({
value,
max,
state,
progress,
percentage,
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="progressbar"
:aria-valuemin="0"
:aria-valuemax="max"
:aria-valuenow="isFiniteNumber(value) ? value : undefined"
:aria-valuetext="valueText"
:aria-label="ariaLabel"
:data-state="state"
:data-value="value ?? undefined"
:data-max="max"
>
<slot
:value="value"
:max="max"
:state="state"
:progress="progress"
:percentage="percentage"
/>
</Primitive>
</template>
@@ -0,0 +1,277 @@
import { mount } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import { ProgressIndicator, ProgressRoot } from '../index';
function mountProgress(props: Record<string, unknown> = {}, slot?: (scope: Record<string, unknown>) => unknown) {
return mount(defineComponent({
setup: () => () => h(ProgressRoot, props, {
default: (scope: Record<string, unknown>) =>
slot ? slot(scope) : h(ProgressIndicator, { class: 'ind' }),
}),
}), { attachTo: document.body });
}
describe('Progress', () => {
it('has role="progressbar" with aria attributes', () => {
const wrapper = mountProgress({ modelValue: 40 });
expect(wrapper.attributes('role')).toBe('progressbar');
expect(wrapper.attributes('aria-valuemin')).toBe('0');
expect(wrapper.attributes('aria-valuemax')).toBe('100');
expect(wrapper.attributes('aria-valuenow')).toBe('40');
expect(wrapper.attributes('aria-valuetext')).toBe('40%');
wrapper.unmount();
});
it('is indeterminate when value is null', () => {
const wrapper = mountProgress({ modelValue: null });
expect(wrapper.attributes('data-state')).toBe('indeterminate');
expect(wrapper.attributes('aria-valuenow')).toBeUndefined();
wrapper.unmount();
});
it('is complete when value reaches max', () => {
const wrapper = mountProgress({ modelValue: 100 });
expect(wrapper.attributes('data-state')).toBe('complete');
wrapper.unmount();
});
it('custom max', () => {
const wrapper = mountProgress({ modelValue: 5, max: 10 });
expect(wrapper.attributes('aria-valuemax')).toBe('10');
expect(wrapper.attributes('aria-valuetext')).toBe('50%');
wrapper.unmount();
});
it('indicator receives matching data-state', () => {
const wrapper = mountProgress({ modelValue: 70 });
const ind = wrapper.find('.ind');
expect(ind.attributes('data-state')).toBe('loading');
expect(ind.attributes('data-value')).toBe('70');
wrapper.unmount();
});
it('getValueLabel override', () => {
const wrapper = mountProgress({ modelValue: 3, max: 10, getValueLabel: (v: number | null, m: number) => `${v} of ${m}` });
expect(wrapper.attributes('aria-valuetext')).toBe('3 of 10');
wrapper.unmount();
});
// ---- accessible name (aria-label) ----
describe('accessible name', () => {
it('renders no aria-label by default', () => {
const wrapper = mountProgress({ modelValue: 40 });
expect(wrapper.attributes('aria-label')).toBeUndefined();
wrapper.unmount();
});
it('accepts a static string accessibleLabel as aria-label', () => {
const wrapper = mountProgress({ modelValue: 40, accessibleLabel: 'Upload progress' });
expect(wrapper.attributes('aria-label')).toBe('Upload progress');
// value text is independent
expect(wrapper.attributes('aria-valuetext')).toBe('40%');
wrapper.unmount();
});
it('accepts a function accessibleLabel that receives value/max', () => {
const wrapper = mountProgress({
modelValue: 30,
max: 60,
accessibleLabel: (v: number | null, m: number) => `${v}/${m} done`,
});
expect(wrapper.attributes('aria-label')).toBe('30/60 done');
wrapper.unmount();
});
});
// ---- input validation / clamping ----
describe('input validation', () => {
let errorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
errorSpy.mockRestore();
});
it('clamps a value above max', () => {
const wrapper = mountProgress({ modelValue: 150, max: 100 });
expect(wrapper.attributes('aria-valuenow')).toBe('100');
expect(wrapper.attributes('data-state')).toBe('complete');
wrapper.unmount();
});
it('clamps a negative value to 0', () => {
const wrapper = mountProgress({ modelValue: -20 });
expect(wrapper.attributes('aria-valuenow')).toBe('0');
wrapper.unmount();
});
it('coerces NaN value to indeterminate and warns', () => {
const wrapper = mountProgress({ modelValue: Number.NaN });
expect(wrapper.attributes('aria-valuenow')).toBeUndefined();
expect(wrapper.attributes('data-state')).toBe('indeterminate');
expect(errorSpy).toHaveBeenCalled();
wrapper.unmount();
});
it('falls back to default max when max <= 0 and warns', () => {
const wrapper = mountProgress({ modelValue: 50, max: 0 });
expect(wrapper.attributes('aria-valuemax')).toBe('100');
// value text uses the corrected max — no Infinity%
expect(wrapper.attributes('aria-valuetext')).toBe('50%');
expect(errorSpy).toHaveBeenCalled();
wrapper.unmount();
});
it('falls back to default max when max is NaN', () => {
const wrapper = mountProgress({ modelValue: 50, max: Number.NaN });
expect(wrapper.attributes('aria-valuemax')).toBe('100');
wrapper.unmount();
});
it('does not render NaN aria-valuenow for non-finite input', () => {
const wrapper = mountProgress({ modelValue: Number.POSITIVE_INFINITY });
expect(wrapper.attributes('aria-valuenow')).toBeUndefined();
wrapper.unmount();
});
});
// ---- controlled two-way v-model ----
describe('controlled v-model', () => {
it('updates DOM when bound value changes', async () => {
const value = ref<number | null>(20);
const wrapper = mount(defineComponent({
setup: () => () => h(ProgressRoot, {
modelValue: value.value,
'onUpdate:modelValue': (v: number | null) => { value.value = v; },
}, { default: () => h(ProgressIndicator, { class: 'ind' }) }),
}), { attachTo: document.body });
expect(wrapper.attributes('aria-valuenow')).toBe('20');
value.value = 80;
await nextTick();
expect(wrapper.attributes('aria-valuenow')).toBe('80');
wrapper.unmount();
});
it('clamps an out-of-range bound value on read without mutating the parent', async () => {
// One-way bind (no update listener): the bar displays a clamped value but
// never writes back to a source the parent did not opt into mutating.
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const wrapper = mountProgress({ modelValue: 250, max: 100 });
expect(wrapper.attributes('aria-valuenow')).toBe('100');
expect(wrapper.attributes('data-state')).toBe('complete');
errorSpy.mockRestore();
wrapper.unmount();
});
it('declares the update:modelValue emit so v-model is a true two-way contract', async () => {
const value = ref<number | null>(40);
const wrapper = mount(defineComponent({
setup: () => () => h(ProgressRoot, {
modelValue: value.value,
'onUpdate:modelValue': (v: number | null) => { value.value = v; },
}, { default: () => h(ProgressIndicator) }),
}), { attachTo: document.body });
expect(wrapper.attributes('aria-valuenow')).toBe('40');
// parent remains the source of truth and drives the bar
value.value = 90;
await nextTick();
expect(wrapper.attributes('aria-valuenow')).toBe('90');
wrapper.unmount();
});
});
// ---- uncontrolled mode ----
describe('uncontrolled mode', () => {
it('starts indeterminate when no modelValue is provided', () => {
const wrapper = mount(ProgressRoot, {
attachTo: document.body,
slots: { default: () => h(ProgressIndicator) },
});
expect(wrapper.attributes('data-state')).toBe('indeterminate');
expect(wrapper.attributes('aria-valuenow')).toBeUndefined();
wrapper.unmount();
});
});
// ---- two-way max ----
describe('two-way max', () => {
it('reacts to bound max changes', async () => {
const max = ref(100);
const wrapper = mount(defineComponent({
setup: () => () => h(ProgressRoot, {
modelValue: 50,
max: max.value,
'onUpdate:max': (m: number) => { max.value = m; },
}, { default: () => h(ProgressIndicator) }),
}), { attachTo: document.body });
expect(wrapper.attributes('aria-valuetext')).toBe('50%');
max.value = 200;
await nextTick();
expect(wrapper.attributes('aria-valuemax')).toBe('200');
expect(wrapper.attributes('aria-valuetext')).toBe('25%');
wrapper.unmount();
});
});
// ---- slot scope ----
describe('slot scope', () => {
it('exposes progress and percentage on the root slot', () => {
const seen: Record<string, unknown> = {};
const wrapper = mountProgress({ modelValue: 25, max: 50 }, (scope) => {
Object.assign(seen, scope);
return h('span');
});
expect(seen.value).toBe(25);
expect(seen.max).toBe(50);
expect(seen.state).toBe('loading');
expect(seen.progress).toBe(0.5);
expect(seen.percentage).toBe(50);
wrapper.unmount();
});
it('exposes progress/percentage as null when indeterminate', () => {
const seen: Record<string, unknown> = {};
const wrapper = mountProgress({ modelValue: null }, (scope) => {
Object.assign(seen, scope);
return h('span');
});
expect(seen.progress).toBeNull();
expect(seen.percentage).toBeNull();
wrapper.unmount();
});
it('exposes progress/percentage on the indicator slot', () => {
const indSeen: Record<string, unknown> = {};
const wrapper = mount(defineComponent({
setup: () => () => h(ProgressRoot, { modelValue: 30, max: 60 }, {
default: () => h(ProgressIndicator, null, {
default: (scope: Record<string, unknown>) => {
Object.assign(indSeen, scope);
return h('span');
},
}),
}),
}), { attachTo: document.body });
expect(indSeen.progress).toBe(0.5);
expect(indSeen.percentage).toBe(50);
wrapper.unmount();
});
});
// ---- overshoot complete (robonen's preserved strength) ----
it('keeps complete state when value overshoots after clamp', () => {
const wrapper = mountProgress({ modelValue: 105, max: 100 });
// clamped to 100 -> still complete
expect(wrapper.attributes('data-state')).toBe('complete');
wrapper.unmount();
});
it('renders data-value/data-max on the root', () => {
const wrapper = mountProgress({ modelValue: 42, max: 84 });
expect(wrapper.attributes('data-value')).toBe('42');
expect(wrapper.attributes('data-max')).toBe('84');
wrapper.unmount();
});
});
@@ -0,0 +1,22 @@
import type { ComputedRef, Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
export type ProgressState = 'indeterminate' | 'loading' | 'complete';
export interface ProgressContext {
/** Resolved (validated/clamped) value; `null` when indeterminate. */
value: Ref<number | null>;
/** Resolved (validated) maximum. */
max: Ref<number>;
/** Derived progress state. */
state: Ref<ProgressState>;
/** Completion ratio in `[0, 1]`, or `null` when indeterminate. */
progress: ComputedRef<number | null>;
/** Completion percentage in `[0, 100]`, or `null` when indeterminate. */
percentage: ComputedRef<number | null>;
}
const ctx = useContextFactory<ProgressContext>('ProgressContext');
export const provideProgressContext = ctx.provide;
export const useProgressContext = ctx.inject;
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue';
import { ProgressIndicator, ProgressRoot } from '@robonen/primitives';
const value = ref(0);
let timer: ReturnType<typeof setInterval> | undefined;
function start() {
stop();
value.value = 0;
timer = setInterval(() => {
value.value = Math.min(100, value.value + Math.round(8 + Math.random() * 14));
if (value.value >= 100)
stop();
}, 600);
}
function stop() {
if (timer) {
clearInterval(timer);
timer = undefined;
}
}
onBeforeUnmount(stop);
</script>
<template>
<div class="demo-card w-full max-w-sm space-y-6 p-5 text-fg">
<!-- Determinate: a simulated upload -->
<div class="space-y-2">
<div class="flex items-baseline justify-between text-sm">
<span class="font-medium">Uploading build.zip</span>
<span class="font-mono text-fg-muted">{{ value }}%</span>
</div>
<ProgressRoot
v-slot="{ state }"
:model-value="value"
class="h-2 w-full overflow-hidden rounded-full bg-bg-inset"
>
<ProgressIndicator
class="h-full rounded-full transition-transform duration-500 ease-out"
:class="state === 'complete'
? 'bg-emerald-500 dark:bg-emerald-400'
: 'bg-accent'"
:style="{ transform: `translateX(-${100 - value}%)` }"
/>
</ProgressRoot>
<button
type="button"
class="rounded-md border border-border bg-bg px-3 py-1.5 text-sm text-fg transition hover:bg-bg-inset active:scale-95 cursor-pointer"
@click="start"
>
{{ value === 0 ? 'Start upload' : 'Restart' }}
</button>
</div>
<!-- Indeterminate: value is null -->
<div class="space-y-2">
<span class="text-sm font-medium">Syncing changes</span>
<ProgressRoot
:model-value="null"
class="h-2 w-full overflow-hidden rounded-full bg-bg-inset"
>
<ProgressIndicator class="h-full w-2/5 animate-pulse rounded-full bg-accent" />
</ProgressRoot>
</div>
</div>
</template>
@@ -0,0 +1,6 @@
export { default as ProgressRoot } from './ProgressRoot.vue';
export { default as ProgressIndicator } from './ProgressIndicator.vue';
export type { ProgressRootProps, ProgressRootEmits } from './ProgressRoot.vue';
export type { ProgressIndicatorProps } from './ProgressIndicator.vue';
export { provideProgressContext, useProgressContext } from './context';
export type { ProgressState, ProgressContext } from './context';
@@ -0,0 +1,54 @@
import { isNull, isNumber, isUndefined } from '@robonen/stdlib';
/** Fallback maximum used when `max` is omitted or invalid. */
export const DEFAULT_MAX = 100;
/** `null`/`undefined` mark an indeterminate progress bar. */
export function isNullish(value: unknown): value is null | undefined {
return isNull(value) || isUndefined(value);
}
/** A real, finite numeric value (filters `NaN`/`Infinity`). */
export function isFiniteNumber(value: unknown): value is number {
return isNumber(value) && Number.isFinite(value);
}
const VALUE_MESSAGE = (value: unknown, max: number): string =>
`Invalid \`modelValue\` of \`${String(value)}\` supplied to ProgressRoot. `
+ `The value must be a finite number between 0 and \`max\` (${max}), `
+ 'or `null`/`undefined` for an indeterminate bar. Defaulting to `null`.';
const MAX_MESSAGE = (max: unknown): string =>
`Invalid \`max\` of \`${String(max)}\` supplied to ProgressRoot. `
+ `Only finite numbers greater than 0 are valid. Defaulting to \`${DEFAULT_MAX}\`.`;
/**
* Resolve a usable `max`: a finite number greater than `0`, otherwise the
* default. Warns once in dev when an invalid value is supplied. Compiled out of
* production via the `__DEV__` global.
*/
export function resolveMax(max: number | undefined): number {
if (isUndefined(max)) return DEFAULT_MAX;
if (isFiniteNumber(max) && max > 0) return max;
if (__DEV__) console.error(MAX_MESSAGE(max));
return DEFAULT_MAX;
}
/**
* Resolve a usable value against an already-resolved `max`: pass through
* nullish (indeterminate) values, clamp finite numbers into `[0, max]`, and
* coerce anything else (`NaN`, non-numbers) to `null`. Warns once in dev when
* coercing an unusable value. Compiled out of production via `__DEV__`.
*/
export function resolveValue(value: number | null | undefined, max: number): number | null {
if (isNullish(value)) return null;
if (isFiniteNumber(value)) {
if (value < 0) return 0;
if (value > max) return max;
return value;
}
if (__DEV__) console.error(VALUE_MESSAGE(value, max));
return null;
}
@@ -0,0 +1,52 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A rectangle covering the entire code, quiet zone included. Use it for a solid
* backdrop or a gradient/pattern fill behind the modules. Defaults to
* `fill="none"` so the page background shows through unless you style it.
*/
export interface QrCodeBackgroundProps extends PrimitiveProps {
/** Fill applied to the rectangle. Default `'none'` (transparent). Overridable via CSS. */
fill?: string;
/** Corner radius in module units. Default `0`. */
radius?: number;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { useQrCodeContext } from './context';
const { as = 'rect', fill = 'none', radius = 0 } = defineProps<QrCodeBackgroundProps>();
const { forwardRef } = useForwardExpose();
const ctx = useQrCodeContext();
const rect = computed(() => {
const m = ctx.margin.value;
return {
x: -m.left,
y: -m.top,
width: m.left + ctx.size.value + m.right,
height: m.top + ctx.size.value + m.bottom,
};
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:x="rect.x"
:y="rect.y"
:width="rect.width"
:height="rect.height"
:rx="radius || undefined"
:ry="radius || undefined"
:fill="fill"
data-qr-background
/>
</template>
@@ -0,0 +1,76 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
import type { QrCellPattern } from './utils';
/**
* Renders the data modules of the code. The `pattern` prop switches between
* pixel styles; `fluid` is neighbour-aware and merges adjacent modules into
* smooth blobs. By default the three finder patterns are skipped so that
* `QrCodeMarker`/`QrCodeMarkers` can style them independently — set
* `includeMarkers` to draw a complete code from cells alone.
*
* For total control, provide a `#cell` slot: it is rendered once per dark
* module with its grid position and center, and you emit whatever SVG you like.
*/
export interface QrCodeCellsProps extends PrimitiveProps {
/** Module shape: `square` (default), `dot`, `rounded`, or `fluid` (connected). */
pattern?: QrCellPattern;
/** Corner roundness in `[0, 1]` for `rounded`/`fluid`. Default `0.5`. */
radius?: number;
/** Gap between modules in `[0, 1)`, as a fraction of cell size. Ignored by `fluid`. Default `0`. */
gap?: number;
/** Also render the finder-pattern modules (use when not composing `QrCodeMarkers`). Default `false`. */
includeMarkers?: boolean;
}
</script>
<script setup lang="ts">
import { computed, useSlots } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { useQrCodeContext } from './context';
import { cellList, cellsPath } from './utils';
const {
as = 'path',
pattern = 'square',
radius = 0.5,
gap = 0,
includeMarkers = false,
} = defineProps<QrCodeCellsProps>();
const { forwardRef } = useForwardExpose();
const ctx = useQrCodeContext();
const slots = useSlots();
const d = computed(() =>
cellsPath(ctx.qr.value, { pattern, radius, gap, includeMarkers, isReserved: ctx.isReserved }),
);
const cells = computed(() =>
cellList(ctx.qr.value, { includeMarkers, isReserved: ctx.isReserved }),
);
</script>
<template>
<Primitive
v-if="slots.cell"
:ref="forwardRef"
:as="as === 'path' ? 'g' : as"
data-qr-cells
>
<slot
v-for="cell in cells"
:key="`${cell.x}-${cell.y}`"
name="cell"
v-bind="cell"
/>
</Primitive>
<Primitive
v-else
:ref="forwardRef"
:as="as"
:d="d"
data-qr-cells
/>
</template>
@@ -0,0 +1,94 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Overlays a logo at the center of the code (or anywhere via `x`/`y`). Pass an
* image `src`, or use the default slot for arbitrary SVG content — the slot
* receives the computed placement so you can size and position freely.
*
* With `knockout` (the default) the modules behind the logo are cleared so it
* sits in clean space; pair it with a high `errorCorrection` level on the root
* to keep the code scannable.
*/
export interface QrCodeLogoProps extends PrimitiveProps {
/** Image URL to render via an SVG `<image>`. Optional when using the default slot. */
src?: string;
/** Logo extent as a fraction of the code size, in `[0, 1]`. Default `0.25`. */
size?: number;
/** Center X in module units. Defaults to the code's horizontal center. */
x?: number;
/** Center Y in module units. Defaults to the code's vertical center. */
y?: number;
/** Extra cleared padding around the logo, in modules. Default `1`. */
padding?: number;
/** Clear the modules behind the logo so it has clean space. Default `true`. */
knockout?: boolean;
}
</script>
<script setup lang="ts">
import { computed, onScopeDispose, watchEffect } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { useQrCodeContext } from './context';
const {
as = 'g',
src,
size = 0.25,
x,
y,
padding = 1,
knockout = true,
} = defineProps<QrCodeLogoProps>();
const { forwardRef } = useForwardExpose();
const ctx = useQrCodeContext();
const area = computed(() => {
const extent = Math.max(0, size) * ctx.size.value;
const cx = x ?? ctx.size.value / 2;
const cy = y ?? ctx.size.value / 2;
return {
x: cx - extent / 2,
y: cy - extent / 2,
width: extent,
height: extent,
cx,
cy,
};
});
const owner = Symbol('qr-logo');
watchEffect(() => {
if (!knockout) {
ctx.release(owner);
return;
}
const a = area.value;
ctx.reserve(owner, {
x: a.x - padding,
y: a.y - padding,
width: a.width + padding * 2,
height: a.height + padding * 2,
});
});
onScopeDispose(() => ctx.release(owner));
</script>
<template>
<Primitive :ref="forwardRef" :as="as" data-qr-logo>
<image
v-if="src"
:href="src"
:x="area.x"
:y="area.y"
:width="area.width"
:height="area.height"
preserveAspectRatio="xMidYMid meet"
/>
<slot v-bind="area" />
</Primitive>
</template>
@@ -0,0 +1,76 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
import type { MarkerCorner, QrMarkerBall, QrMarkerFrame } from './utils';
/**
* A single finder ("eye") pattern, made of an outer `frame` ring and an inner
* `ball`. Position it by `corner` (resolved from the matrix size) or pin it with
* explicit `x`/`y` module coordinates. Override the `#frame` / `#ball` slots to
* draw arbitrary shapes; each receives the 7×7 region's origin and center.
*/
export interface QrCodeMarkerProps extends PrimitiveProps {
/** Which finder to render. Ignored when both `x` and `y` are given. Default `'top-left'`. */
corner?: MarkerCorner;
/** Explicit X of the finder's top-left module (overrides `corner`). */
x?: number;
/** Explicit Y of the finder's top-left module (overrides `corner`). */
y?: number;
/** Outer ring shape: `square` (default), `rounded`, or `circle`. */
frame?: QrMarkerFrame;
/** Inner ball shape: `square` (default), `rounded`, `circle`, or `diamond`. */
ball?: QrMarkerBall;
/** Roundness in `[0, 1]` for `rounded` frames/balls. Default `0.5`. */
radius?: number;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { useQrCodeContext } from './context';
import { markerBallPath, markerFramePath } from './utils';
const {
as = 'g',
corner = 'top-left',
x,
y,
frame = 'square',
ball = 'square',
radius = 0.5,
} = defineProps<QrCodeMarkerProps>();
const { forwardRef } = useForwardExpose();
const ctx = useQrCodeContext();
const placement = computed(() => {
if (x !== undefined && y !== undefined)
return { x, y };
return ctx.markers.value.find(m => m.corner === corner) ?? { x: 0, y: 0 };
});
const origin = computed(() => {
const p = placement.value;
return { x: p.x, y: p.y, cx: p.x + 3.5, cy: p.y + 3.5 };
});
const framePath = computed(() => markerFramePath(placement.value.x, placement.value.y, frame, radius));
const ballPath = computed(() => markerBallPath(placement.value.x, placement.value.y, ball, radius));
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:data-corner="corner"
data-qr-marker
>
<slot name="frame" v-bind="origin">
<path :d="framePath" fill-rule="evenodd" data-qr-marker-frame />
</slot>
<slot name="ball" v-bind="origin">
<path :d="ballPath" data-qr-marker-ball />
</slot>
</Primitive>
</template>
@@ -0,0 +1,61 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
import type { QrMarkerBall, QrMarkerFrame } from './utils';
/**
* Convenience wrapper that renders all three finder patterns with a shared
* style. Forwards `frame`/`ball`/`radius` to each `QrCodeMarker` and re-exposes
* their `#frame` / `#ball` slots (augmented with the `corner` being drawn). For
* fully bespoke per-corner rendering, use the `#default` slot — it is invoked
* once per marker with its placement.
*/
export interface QrCodeMarkersProps extends PrimitiveProps {
/** Outer ring shape for every marker. Default `square`. */
frame?: QrMarkerFrame;
/** Inner ball shape for every marker. Default `square`. */
ball?: QrMarkerBall;
/** Roundness in `[0, 1]` for `rounded` frames/balls. Default `0.5`. */
radius?: number;
}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import QrCodeMarker from './QrCodeMarker.vue';
import { useQrCodeContext } from './context';
const { as = 'g', frame = 'square', ball = 'square', radius = 0.5 } = defineProps<QrCodeMarkersProps>();
const { forwardRef } = useForwardExpose();
const ctx = useQrCodeContext();
</script>
<template>
<Primitive :ref="forwardRef" :as="as" data-qr-markers>
<template v-for="marker in ctx.markers.value" :key="marker.corner">
<slot
v-if="$slots.default"
:corner="marker.corner"
:x="marker.x"
:y="marker.y"
:cx="marker.x + 3.5"
:cy="marker.y + 3.5"
/>
<QrCodeMarker
v-else
:corner="marker.corner"
:frame="frame"
:ball="ball"
:radius="radius"
>
<template v-if="$slots.frame" #frame="scope">
<slot name="frame" v-bind="scope" :corner="marker.corner" />
</template>
<template v-if="$slots.ball" #ball="scope">
<slot name="ball" v-bind="scope" :corner="marker.corner" />
</template>
</QrCodeMarker>
</template>
</Primitive>
</template>
@@ -0,0 +1,134 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
import type { QrCodeErrorCorrection, QrCodeMargin } from './context';
/**
* The root of a QR code. Encodes `value` into a matrix (via `@robonen/encoding`)
* and renders an `<svg>` whose viewBox is laid out in module units, so every
* child part draws in the same resolution-independent coordinate space and the
* whole code scales with the SVG's CSS width/height.
*
* It is fully headless: compose `QrCodeBackground`, `QrCodeCells`,
* `QrCodeMarkers`/`QrCodeMarker` and `QrCodeLogo` inside it and style them with
* CSS (`fill`, gradients, `<defs>`) — patterns, marker shapes and logos are all
* controlled by props or slots on those parts.
*
* @example
* ```vue
* <QrCodeRoot value="https://example.com" class="size-48">
* <QrCodeCells pattern="fluid" />
* <QrCodeMarkers frame="rounded" ball="circle" />
* </QrCodeRoot>
* ```
*/
export interface QrCodeRootProps extends PrimitiveProps {
/** The text to encode. Re-encodes reactively when it changes. */
value: string;
/** Error-correction level — higher levels survive more damage (and logos) at the cost of density. Default `'M'`. */
errorCorrection?: QrCodeErrorCorrection;
/** Quiet-zone width in modules, uniform or per-side. Default `4` (the spec minimum). */
margin?: number | Partial<QrCodeMargin>;
/** Smallest QR version (140) to consider. Default `1`. */
minVersion?: number;
/** Largest QR version (140) to consider. Default `40`. */
maxVersion?: number;
/** Mask pattern (07), or `-1` to auto-select the lowest-penalty mask. Default `-1`. */
mask?: number;
/** Whether to automatically raise the error-correction level if the data still fits. Default `true`. */
boostEcc?: boolean;
}
</script>
<script setup lang="ts">
import { computed, reactive } from 'vue';
import { EccMap, QrCodeDataType, encodeSegments, makeSegments } from '@robonen/encoding';
import { useForwardExpose, useId } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { provideQrCodeContext } from './context';
import type { QrCodeRegion } from './utils';
import { markerPlacements } from './utils';
const {
as = 'svg',
value,
errorCorrection = 'M',
margin = 4,
minVersion = 1,
maxVersion = 40,
mask = -1,
boostEcc = true,
} = defineProps<QrCodeRootProps>();
const { forwardRef } = useForwardExpose();
const id = useId(undefined, 'qr');
const qr = computed(() =>
encodeSegments(makeSegments(value), EccMap[errorCorrection], minVersion, maxVersion, mask, boostEcc),
);
const size = computed(() => qr.value.size);
const resolvedMargin = computed<QrCodeMargin>(() => {
if (typeof margin === 'number')
return { top: margin, right: margin, bottom: margin, left: margin };
return { top: margin.top ?? 0, right: margin.right ?? 0, bottom: margin.bottom ?? 0, left: margin.left ?? 0 };
});
const markers = computed(() => markerPlacements(size.value));
const viewBox = computed(() => {
const m = resolvedMargin.value;
const width = m.left + size.value + m.right;
const height = m.top + size.value + m.bottom;
return `${-m.left} ${-m.top} ${width} ${height}`;
});
const reserved = reactive(new Map<symbol, QrCodeRegion>());
function reserve(owner: symbol, region: QrCodeRegion | null): void {
if (region)
reserved.set(owner, region);
else
reserved.delete(owner);
}
function release(owner: symbol): void {
reserved.delete(owner);
}
function isReserved(x: number, y: number): boolean {
const cx = x + 0.5;
const cy = y + 0.5;
for (const r of reserved.values()) {
if (cx >= r.x && cx < r.x + r.width && cy >= r.y && cy < r.y + r.height)
return true;
}
return false;
}
provideQrCodeContext({
qr,
size,
margin: resolvedMargin,
markers,
isDark: (x, y) => qr.value.getModule(x, y),
getModuleType: (x, y) => (x >= 0 && y >= 0 && x < size.value && y < size.value ? qr.value.getType(x, y) : QrCodeDataType.Border),
isReserved,
reserve,
release,
id,
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:viewBox="viewBox"
role="img"
:data-qr-size="size"
:data-qr-version="qr.version"
>
<slot :qr="qr" :size="size" :markers="markers" />
</Primitive>
</template>
@@ -0,0 +1,190 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import { MEDIUM, QrCodeDataType, encodeText } from '@robonen/encoding';
import {
QrCodeCells,
QrCodeLogo,
QrCodeMarker,
QrCodeMarkers,
QrCodeRoot,
cellsPath,
circlePath,
markerFramePath,
markerPlacements,
roundedRectPath,
} from '../index';
const SVG_NS = 'http://www.w3.org/2000/svg';
function mountQr(rootProps: Record<string, unknown>, children: () => unknown) {
return mount(defineComponent({
setup: () => () => h(QrCodeRoot, rootProps, { default: children }),
}), { attachTo: document.body });
}
describe('qr-code geometry utils', () => {
it('roundedRectPath emits a sharp rect when all radii are zero', () => {
const d = roundedRectPath(0, 0, 1, 1, 0, 0, 0, 0);
expect(d).not.toContain('A');
expect(d.startsWith('M')).toBe(true);
expect(d.endsWith('Z')).toBe(true);
});
it('roundedRectPath adds arc commands for non-zero radii', () => {
expect(roundedRectPath(0, 0, 1, 1, 0.5, 0.5, 0.5, 0.5)).toContain('A');
});
it('circlePath produces two arcs centered on the point', () => {
const d = circlePath(5, 5, 2);
expect((d.match(/A/g) ?? []).length).toBe(2);
});
it('markerPlacements returns the three finder corners for a v1 code', () => {
const placements = markerPlacements(21);
expect(placements.map(p => p.corner)).toEqual(['top-left', 'top-right', 'bottom-left']);
expect(placements.find(p => p.corner === 'top-right')).toMatchObject({ x: 14, y: 0 });
expect(placements.find(p => p.corner === 'bottom-left')).toMatchObject({ x: 0, y: 14 });
});
it('cellsPath excludes finder modules by default and includes them on request', () => {
const qr = encodeText('hello', MEDIUM);
const base = { pattern: 'square' as const, radius: 0, gap: 0, isReserved: () => false };
const withoutMarkers = cellsPath(qr, { ...base, includeMarkers: false });
const withMarkers = cellsPath(qr, { ...base, includeMarkers: true });
expect(withMarkers.length).toBeGreaterThan(withoutMarkers.length);
});
it('cellsPath skips reserved modules', () => {
const qr = encodeText('hello world', MEDIUM);
const base = { pattern: 'square' as const, radius: 0, gap: 0, includeMarkers: true };
const full = cellsPath(qr, { ...base, isReserved: () => false });
const knocked = cellsPath(qr, { ...base, isReserved: () => true });
expect(knocked).toBe('');
expect(full.length).toBeGreaterThan(0);
});
it('markerFramePath uses an annulus (no straight edges) for the circle frame', () => {
expect(markerFramePath(0, 0, 'circle', 0.5)).toContain('A');
expect(markerFramePath(0, 0, 'square', 0)).not.toContain('A');
});
});
describe('QrCodeRoot rendering', () => {
it('renders an SVG element in the SVG namespace with a viewBox', () => {
const w = mountQr({ value: 'hello' }, () => h(QrCodeCells));
const svg = w.element as unknown as SVGSVGElement;
expect(svg.tagName.toLowerCase()).toBe('svg');
expect(svg.namespaceURI).toBe(SVG_NS);
// default margin 4, v1 size 21 → 4 + 21 + 4 = 29
expect(svg.getAttribute('viewBox')).toBe('-4 -4 29 29');
expect(svg.getAttribute('role')).toBe('img');
expect(svg.getAttribute('data-qr-size')).toBe('21');
w.unmount();
});
it('renders cells as a namespaced <path> with a non-empty d', () => {
const w = mountQr({ value: 'hello' }, () => h(QrCodeCells));
const path = w.element.querySelector('[data-qr-cells]') as SVGPathElement;
expect(path).toBeTruthy();
expect(path.namespaceURI).toBe(SVG_NS);
expect(path.getAttribute('d')!.length).toBeGreaterThan(0);
w.unmount();
});
it('square and dot patterns produce different geometry', () => {
const sq = mountQr({ value: 'pattern' }, () => h(QrCodeCells, { pattern: 'square' }));
const dot = mountQr({ value: 'pattern' }, () => h(QrCodeCells, { pattern: 'dot' }));
const sqD = sq.element.querySelector('[data-qr-cells]')!.getAttribute('d')!;
const dotD = dot.element.querySelector('[data-qr-cells]')!.getAttribute('d')!;
expect(sqD).not.toBe(dotD);
expect(sqD).not.toContain('A');
expect(dotD).toContain('A');
sq.unmount();
dot.unmount();
});
});
describe('QrCodeMarkers', () => {
it('renders three finder groups, each with a frame and a ball', () => {
const w = mountQr({ value: 'markers' }, () => [
h(QrCodeCells),
h(QrCodeMarkers, { frame: 'rounded', ball: 'circle' }),
]);
expect(w.element.querySelectorAll('[data-qr-marker]').length).toBe(3);
expect(w.element.querySelectorAll('[data-qr-marker-frame]').length).toBe(3);
expect(w.element.querySelectorAll('[data-qr-marker-ball]').length).toBe(3);
w.unmount();
});
it('a single QrCodemarker resolves its corner placement and tags it', () => {
const w = mountQr({ value: 'corner' }, () => h(QrCodeMarker, { corner: 'top-right' }));
const g = w.element.querySelector('[data-qr-marker]')!;
expect(g.getAttribute('data-corner')).toBe('top-right');
expect(g.querySelector('[data-qr-marker-frame]')).toBeTruthy();
w.unmount();
});
});
describe('QrCodeLogo', () => {
it('renders an <image> for src and knocks out the modules behind it', async () => {
const withLogo = mountQr({ value: 'logo knockout test value', errorCorrection: 'H' }, () => [
h(QrCodeCells, { includeMarkers: true }),
h(QrCodeLogo, { src: 'data:image/png;base64,iVBORw0KGgo=', size: 0.3 }),
]);
const without = mountQr({ value: 'logo knockout test value', errorCorrection: 'H' }, () =>
h(QrCodeCells, { includeMarkers: true }),
);
// The logo reserves its region on mount, which invalidates the cells path
// reactively — flush the resulting re-render before reading the DOM.
await nextTick();
const img = withLogo.element.querySelector('[data-qr-logo] image') as SVGImageElement;
expect(img).toBeTruthy();
expect(img.namespaceURI).toBe(SVG_NS);
expect(img.getAttribute('href')).toContain('data:image/png');
const knockedD = withLogo.element.querySelector('[data-qr-cells]')!.getAttribute('d')!;
const fullD = without.element.querySelector('[data-qr-cells]')!.getAttribute('d')!;
expect(knockedD.length).toBeLessThan(fullD.length);
withLogo.unmount();
without.unmount();
});
it('does not knock out modules when knockout is disabled', async () => {
const off = mountQr({ value: 'logo knockout test value', errorCorrection: 'H' }, () => [
h(QrCodeCells, { includeMarkers: true }),
h(QrCodeLogo, { size: 0.3, knockout: false }),
]);
const without = mountQr({ value: 'logo knockout test value', errorCorrection: 'H' }, () =>
h(QrCodeCells, { includeMarkers: true }),
);
await nextTick();
expect(off.element.querySelector('[data-qr-cells]')!.getAttribute('d'))
.toBe(without.element.querySelector('[data-qr-cells]')!.getAttribute('d'));
off.unmount();
without.unmount();
});
});
describe('custom #cell slot', () => {
it('renders one slotted node per dark data module with its type', () => {
const qr = encodeText('slot', MEDIUM);
let dataModules = 0;
for (let y = 0; y < qr.size; y++) {
for (let x = 0; x < qr.size; x++) {
if (qr.getModule(x, y) && qr.getType(x, y) !== QrCodeDataType.Position)
dataModules++;
}
}
const w = mountQr({ value: 'slot' }, () => h(QrCodeCells, null, {
cell: (slotProps: { cx: number; cy: number }) =>
h('circle', { cx: slotProps.cx, cy: slotProps.cy, r: 0.4, class: 'slotted' }),
}));
expect(w.element.querySelectorAll('circle.slotted').length).toBe(dataModules);
w.unmount();
});
});
@@ -0,0 +1,43 @@
import type { ComputedRef } from 'vue';
import type { QrCode, QrCodeDataType } from '@robonen/encoding';
import type { MarkerPlacement, QrCodeRegion } from './utils';
import { useContextFactory } from '@robonen/vue';
/** Friendly error-correction level: Low (~7%), Medium (~15%), Quartile (~25%), High (~30%). */
export type QrCodeErrorCorrection = 'L' | 'M' | 'Q' | 'H';
/** Quiet-zone widths around the code, in module units. */
export interface QrCodeMargin {
top: number;
right: number;
bottom: number;
left: number;
}
export interface QrCodeContext {
/** The encoded matrix. Recomputed whenever the input or encoding options change. */
qr: ComputedRef<QrCode>;
/** Side length of the matrix in modules (21177). */
size: ComputedRef<number>;
/** Resolved quiet-zone margins. */
margin: ComputedRef<QrCodeMargin>;
/** Top-left module index of each of the three finder patterns. */
markers: ComputedRef<MarkerPlacement[]>;
/** Whether the module at `(x, y)` is dark. Out-of-bounds reads return `false`. */
isDark: (x: number, y: number) => boolean;
/** Structural role of the module at `(x, y)`. */
getModuleType: (x: number, y: number) => QrCodeDataType;
/** Whether the module at `(x, y)` is covered by a reserved overlay region. */
isReserved: (x: number, y: number) => boolean;
/** Registers (or, with `null`, clears) a reserved region keyed by an owner symbol. */
reserve: (owner: symbol, region: QrCodeRegion | null) => void;
/** Removes a previously reserved region. */
release: (owner: symbol) => void;
/** Stable id, usable as a prefix for `clipPath`/gradient ids within the code. */
id: ComputedRef<string>;
}
const ctx = useContextFactory<QrCodeContext>('QrCodeContext');
export const provideQrCodeContext = ctx.provide;
export const useQrCodeContext = ctx.inject;
+103
View File
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
QrCodeBackground,
QrCodeCells,
QrCodeLogo,
QrCodeMarker,
QrCodeMarkers,
QrCodeRoot,
} from '@robonen/primitives';
import type { QrCellPattern, QrMarkerBall, QrMarkerFrame } from '@robonen/primitives';
const value = ref('https://github.com/robonen/tools');
const pattern = ref<QrCellPattern>('fluid');
const frame = ref<QrMarkerFrame>('rounded');
const ball = ref<QrMarkerBall>('circle');
const patterns: QrCellPattern[] = ['square', 'dot', 'rounded', 'fluid'];
const frames: QrMarkerFrame[] = ['square', 'rounded', 'circle'];
const balls: QrMarkerBall[] = ['square', 'rounded', 'circle', 'diamond'];
</script>
<template>
<div class="flex flex-col gap-6 text-fg">
<!-- Interactive playground -->
<div class="demo-card flex flex-col items-center gap-5 p-6 sm:flex-row sm:items-start">
<QrCodeRoot
:value="value"
error-correction="H"
class="size-48 shrink-0 text-fg"
>
<QrCodeBackground fill="transparent" :radius="2" />
<QrCodeCells :pattern="pattern" :radius="0.6" class="fill-current" />
<QrCodeMarkers :frame="frame" :ball="ball" :radius="0.5" class="fill-accent" />
<QrCodeLogo v-slot="{ cx, cy, width }" :size="0.22" :padding="1.5">
<circle :cx="cx" :cy="cy" :r="width / 2" class="fill-bg-elevated" />
<text
:x="cx"
:y="cy"
text-anchor="middle"
dominant-baseline="central"
:font-size="width * 0.7"
class="fill-accent"
style="font-family: ui-sans-serif, system-ui; font-weight: 700"
>
R
</text>
</QrCodeLogo>
</QrCodeRoot>
<div class="flex w-full flex-col gap-3 text-sm">
<label class="flex flex-col gap-1">
<span class="text-fg-muted">Value</span>
<input
v-model="value"
class="rounded-md border border-border bg-bg-inset px-2 py-1"
>
</label>
<label class="flex flex-col gap-1">
<span class="text-fg-muted">Pattern</span>
<select v-model="pattern" class="rounded-md border border-border bg-bg-inset px-2 py-1">
<option v-for="p in patterns" :key="p" :value="p">{{ p }}</option>
</select>
</label>
<div class="flex gap-3">
<label class="flex flex-1 flex-col gap-1">
<span class="text-fg-muted">Frame</span>
<select v-model="frame" class="rounded-md border border-border bg-bg-inset px-2 py-1">
<option v-for="f in frames" :key="f" :value="f">{{ f }}</option>
</select>
</label>
<label class="flex flex-1 flex-col gap-1">
<span class="text-fg-muted">Ball</span>
<select v-model="ball" class="rounded-md border border-border bg-bg-inset px-2 py-1">
<option v-for="b in balls" :key="b" :value="b">{{ b }}</option>
</select>
</label>
</div>
</div>
</div>
<!-- A few canned styles showing per-corner control and gradients -->
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
<QrCodeRoot value="dots" class="size-32 text-emerald-500">
<QrCodeCells pattern="dot" :gap="0.2" class="fill-current" />
<QrCodeMarkers frame="circle" ball="circle" class="fill-current" />
</QrCodeRoot>
<QrCodeRoot value="fluid" class="size-32 text-indigo-500">
<QrCodeCells pattern="fluid" :radius="1" class="fill-current" />
<QrCodeMarkers frame="rounded" ball="rounded" :radius="0.6" class="fill-current" />
</QrCodeRoot>
<QrCodeRoot value="per-corner" class="size-32">
<QrCodeCells pattern="rounded" :radius="0.4" class="fill-neutral-800 dark:fill-neutral-100" />
<QrCodeMarker corner="top-left" frame="circle" ball="circle" class="fill-rose-500" />
<QrCodeMarker corner="top-right" frame="rounded" ball="diamond" :radius="0.5" class="fill-amber-500" />
<QrCodeMarker corner="bottom-left" frame="square" ball="square" class="fill-sky-500" />
</QrCodeRoot>
</div>
</div>
</template>
@@ -0,0 +1,35 @@
export { default as QrCodeRoot } from './QrCodeRoot.vue';
export { default as QrCodeBackground } from './QrCodeBackground.vue';
export { default as QrCodeCells } from './QrCodeCells.vue';
export { default as QrCodeMarker } from './QrCodeMarker.vue';
export { default as QrCodeMarkers } from './QrCodeMarkers.vue';
export { default as QrCodeLogo } from './QrCodeLogo.vue';
export type { QrCodeRootProps } from './QrCodeRoot.vue';
export type { QrCodeBackgroundProps } from './QrCodeBackground.vue';
export type { QrCodeCellsProps } from './QrCodeCells.vue';
export type { QrCodeMarkerProps } from './QrCodeMarker.vue';
export type { QrCodeMarkersProps } from './QrCodeMarkers.vue';
export type { QrCodeLogoProps } from './QrCodeLogo.vue';
export { provideQrCodeContext, useQrCodeContext } from './context';
export type { QrCodeContext, QrCodeErrorCorrection, QrCodeMargin } from './context';
export {
cellList,
cellsPath,
circlePath,
markerBallPath,
markerFramePath,
markerPlacements,
roundedRectPath,
} from './utils';
export type {
MarkerCorner,
MarkerPlacement,
QrCellDescriptor,
QrCellPattern,
QrCodeRegion,
QrMarkerBall,
QrMarkerFrame,
} from './utils';
+241
View File
@@ -0,0 +1,241 @@
import type { QrCode } from '@robonen/encoding';
import { QrCodeDataType } from '@robonen/encoding';
import { clamp } from '@robonen/stdlib';
/**
* Geometry helpers for rendering a {@link QrCode} matrix to SVG paths.
*
* Every coordinate is expressed in *module units*: a module at grid index
* `(x, y)` occupies the square `[x, x + 1] × [y, y + 1]`, so its center sits at
* `(x + 0.5, y + 0.5)`. Multiplying by a scale factor (or letting the SVG
* viewBox do it) yields pixels — the paths themselves are resolution-independent.
*/
/** Visual style applied to each data module ("pixel") of the code. */
export type QrCellPattern = 'square' | 'dot' | 'rounded' | 'fluid';
/** Shape of the outer 7×7 ring of a finder ("eye") pattern. */
export type QrMarkerFrame = 'square' | 'rounded' | 'circle';
/** Shape of the inner 3×3 ball of a finder ("eye") pattern. */
export type QrMarkerBall = 'square' | 'rounded' | 'circle' | 'diamond';
/** Which of the three finder patterns a {@link MarkerPlacement} refers to. */
export type MarkerCorner = 'top-left' | 'top-right' | 'bottom-left';
/** Position of a single finder pattern, given as the top-left module of its 7×7 region. */
export interface MarkerPlacement {
corner: MarkerCorner;
/** X index of the finder's top-left module. */
x: number;
/** Y index of the finder's top-left module. */
y: number;
}
/** A rectangular region of the matrix, in module units, used to knock out modules behind overlays. */
export interface QrCodeRegion {
x: number;
y: number;
width: number;
height: number;
}
/** Description of one dark module, passed to the `#cell` slot for custom rendering. */
export interface QrCellDescriptor {
/** Column index. */
x: number;
/** Row index. */
y: number;
/** Center X (`x + 0.5`). */
cx: number;
/** Center Y (`y + 0.5`). */
cy: number;
/** Structural role of the module (data, timing, alignment, …). */
type: QrCodeDataType;
}
/** Returns the top-left module index of each of the three finder patterns. */
export function markerPlacements(size: number): MarkerPlacement[] {
return [
{ corner: 'top-left', x: 0, y: 0 },
{ corner: 'top-right', x: size - 7, y: 0 },
{ corner: 'bottom-left', x: 0, y: size - 7 },
];
}
/** Rounds to 4 decimals and strips trailing zeros to keep generated path strings compact. */
function fmt(value: number): string {
return String(Math.round(value * 1e4) / 1e4);
}
/**
* Builds a rectangle path with an independent corner radius per corner. A radius
* of `0` collapses that corner to a sharp right angle, which is how the `fluid`
* pattern merges a module into its dark neighbours.
*/
export function roundedRectPath(
x: number,
y: number,
w: number,
h: number,
rTL: number,
rTR: number,
rBR: number,
rBL: number,
): string {
const max = Math.min(w, h) / 2;
rTL = clamp(rTL, 0, max);
rTR = clamp(rTR, 0, max);
rBR = clamp(rBR, 0, max);
rBL = clamp(rBL, 0, max);
let d = `M${fmt(x + rTL)} ${fmt(y)}`;
d += `L${fmt(x + w - rTR)} ${fmt(y)}`;
if (rTR > 0)
d += `A${fmt(rTR)} ${fmt(rTR)} 0 0 1 ${fmt(x + w)} ${fmt(y + rTR)}`;
d += `L${fmt(x + w)} ${fmt(y + h - rBR)}`;
if (rBR > 0)
d += `A${fmt(rBR)} ${fmt(rBR)} 0 0 1 ${fmt(x + w - rBR)} ${fmt(y + h)}`;
d += `L${fmt(x + rBL)} ${fmt(y + h)}`;
if (rBL > 0)
d += `A${fmt(rBL)} ${fmt(rBL)} 0 0 1 ${fmt(x)} ${fmt(y + h - rBL)}`;
d += `L${fmt(x)} ${fmt(y + rTL)}`;
if (rTL > 0)
d += `A${fmt(rTL)} ${fmt(rTL)} 0 0 1 ${fmt(x + rTL)} ${fmt(y)}`;
return `${d}Z`;
}
/** Full circle (disc) path, drawn as two arcs. */
export function circlePath(cx: number, cy: number, r: number): string {
return `M${fmt(cx - r)} ${fmt(cy)}`
+ `A${fmt(r)} ${fmt(r)} 0 1 0 ${fmt(cx + r)} ${fmt(cy)}`
+ `A${fmt(r)} ${fmt(r)} 0 1 0 ${fmt(cx - r)} ${fmt(cy)}Z`;
}
function diamondPath(cx: number, cy: number, r: number): string {
return `M${fmt(cx)} ${fmt(cy - r)}L${fmt(cx + r)} ${fmt(cy)}`
+ `L${fmt(cx)} ${fmt(cy + r)}L${fmt(cx - r)} ${fmt(cy)}Z`;
}
/** Predicate deciding whether a module participates in cell rendering. */
type ModuleFilter = (x: number, y: number) => boolean;
interface CellOptions {
pattern: QrCellPattern;
/** Corner roundness in `[0, 1]` for `rounded`/`fluid` patterns. */
radius: number;
/** Inset applied to every module in `[0, 1)` to create gaps between cells. */
gap: number;
/** When `false`, modules belonging to a finder pattern are skipped (drawn by `QrCodeMarker`). */
includeMarkers: boolean;
/** Returns `true` for modules covered by an overlay (e.g. a logo) — they are skipped. */
isReserved: ModuleFilter;
}
/** Whether a module should be drawn by `QrCodeCells` given the active options. */
function isCell(qr: QrCode, x: number, y: number, opts: CellOptions): boolean {
if (x < 0 || y < 0 || x >= qr.size || y >= qr.size)
return false;
if (!qr.getModule(x, y))
return false;
if (!opts.includeMarkers && qr.getType(x, y) === QrCodeDataType.Position)
return false;
return !opts.isReserved(x, y);
}
function cellSnippet(qr: QrCode, x: number, y: number, opts: CellOptions): string {
const { pattern } = opts;
if (pattern === 'fluid') {
// Connect to dark neighbours by squaring off the corners between them.
const r = clamp(opts.radius, 0, 1) * 0.5;
const top = isCell(qr, x, y - 1, opts);
const bottom = isCell(qr, x, y + 1, opts);
const left = isCell(qr, x - 1, y, opts);
const right = isCell(qr, x + 1, y, opts);
return roundedRectPath(
x,
y,
1,
1,
!top && !left ? r : 0,
!top && !right ? r : 0,
!bottom && !right ? r : 0,
!bottom && !left ? r : 0,
);
}
const gap = clamp(opts.gap, 0, 0.95);
const inset = gap / 2;
const s = 1 - gap;
if (pattern === 'dot')
return circlePath(x + 0.5, y + 0.5, s / 2);
const r = pattern === 'rounded' ? clamp(opts.radius, 0, 1) * (s / 2) : 0;
return roundedRectPath(x + inset, y + inset, s, s, r, r, r, r);
}
/** Builds a single `<path>` `d` string covering every rendered data module. */
export function cellsPath(qr: QrCode, opts: CellOptions): string {
const size = qr.size;
let d = '';
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (isCell(qr, x, y, opts))
d += cellSnippet(qr, x, y, opts);
}
}
return d;
}
/** Collects descriptors for every rendered data module, for slot-based custom rendering. */
export function cellList(
qr: QrCode,
opts: Pick<CellOptions, 'includeMarkers' | 'isReserved'>,
): QrCellDescriptor[] {
const full: CellOptions = { ...opts, pattern: 'square', radius: 0, gap: 0 };
const size = qr.size;
const cells: QrCellDescriptor[] = [];
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (isCell(qr, x, y, full))
cells.push({ x, y, cx: x + 0.5, cy: y + 0.5, type: qr.getType(x, y) });
}
}
return cells;
}
/**
* Path for a finder pattern's outer ring. The frame occupies the 7×7 region
* with a 1-module-thick border (`square`/`rounded`) or an annulus (`circle`),
* leaving the standard 1-module light gap before the inner ball. Render with
* `fill-rule="evenodd"` so the inner cut-out reads as a hole.
*/
export function markerFramePath(mx: number, my: number, shape: QrMarkerFrame, radius: number): string {
const cx = mx + 3.5;
const cy = my + 3.5;
const t = clamp(radius, 0, 1);
if (shape === 'circle')
return circlePath(cx, cy, 3.5) + circlePath(cx, cy, 2.5);
const ro = shape === 'rounded' ? t * 3.5 : 0;
const ri = shape === 'rounded' ? Math.max(0, ro - 1) : 0;
return roundedRectPath(mx, my, 7, 7, ro, ro, ro, ro)
+ roundedRectPath(mx + 1, my + 1, 5, 5, ri, ri, ri, ri);
}
/** Path for a finder pattern's inner 3×3 ball. */
export function markerBallPath(mx: number, my: number, shape: QrMarkerBall, radius: number): string {
const cx = mx + 3.5;
const cy = my + 3.5;
if (shape === 'circle')
return circlePath(cx, cy, 1.5);
if (shape === 'diamond')
return diamondPath(cx, cy, 1.5);
const r = shape === 'rounded' ? clamp(radius, 0, 1) * 1.5 : 0;
return roundedRectPath(mx + 2, my + 2, 3, 3, r, r, r, r);
}
@@ -0,0 +1,88 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Fills the square where the horizontal and vertical scrollbars meet. It only renders when
* both scrollbars are present and have measurable size; otherwise it is omitted.
*/
export type ScrollAreaCornerProps = PrimitiveProps;
</script>
<script setup lang="ts">
import { computed, onMounted, onScopeDispose, ref, watch } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
import { useScrollAreaRootContext } from './context';
defineOptions({ inheritAttrs: false });
defineProps<ScrollAreaCornerProps>();
const ctx = useScrollAreaRootContext();
const { forwardRef } = useForwardExpose();
const width = ref(0);
const height = ref(0);
const hasSize = computed(() => width.value > 0 && height.value > 0);
// For `type='scroll'` the bars overlay the content, so no corner is needed.
const hasBoth = computed(() =>
ctx.type.value !== 'scroll'
&& ctx.scrollbarXEnabled.value
&& ctx.scrollbarYEnabled.value,
);
function measure() {
const x = ctx.scrollbarX.value;
const y = ctx.scrollbarY.value;
width.value = y ? y.offsetWidth : 0;
height.value = x ? x.offsetHeight : 0;
ctx.onCornerWidthChange(width.value);
ctx.onCornerHeightChange(height.value);
}
let xObs: ResizeObserver | null = null;
let yObs: ResizeObserver | null = null;
function attach() {
xObs?.disconnect();
yObs?.disconnect();
const x = ctx.scrollbarX.value;
const y = ctx.scrollbarY.value;
if (x) {
xObs = new ResizeObserver(measure);
xObs.observe(x);
}
if (y) {
yObs = new ResizeObserver(measure);
yObs.observe(y);
}
measure();
}
onMounted(attach);
watch([() => ctx.scrollbarX.value, () => ctx.scrollbarY.value], attach);
onScopeDispose(() => {
xObs?.disconnect();
yObs?.disconnect();
});
</script>
<template>
<Primitive
v-if="hasBoth && hasSize"
:ref="forwardRef"
:as="as ?? 'div'"
:style="{
width: `${width}px`,
height: `${height}px`,
position: 'absolute',
right: ctx.dir.value === 'rtl' ? undefined : 0,
left: ctx.dir.value === 'rtl' ? 0 : undefined,
bottom: 0,
}"
v-bind="$attrs"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,114 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
import type { ScrollAreaType } from './types';
/**
* Provides a styleable, cross-browser scroll container that swaps native scrollbars for
* custom ones while preserving native scrolling, keyboard, and accessibility behaviour.
* The root holds shared state and renders nothing visible on its own — compose it with a
* `ScrollAreaViewport` (the scrollable region), one or two `ScrollAreaScrollbar`s (each
* containing a `ScrollAreaThumb`), and an optional `ScrollAreaCorner`. Reach for it when
* the default OS scrollbars clash with your design or differ across platforms.
*/
export interface ScrollAreaRootProps extends PrimitiveProps {
/**
* Visibility behaviour for scrollbars.
* - `auto`: visible whenever content overflows.
* - `always`: always visible.
* - `scroll`: visible while the user is scrolling, then hides after `scrollHideDelay`.
* - `hover`: visible while the pointer is over the root, then hides after `scrollHideDelay`.
* - `glimpse`: briefly revealed when the pointer enters the root, then auto-hides;
* behaves like `scroll` once the user scrolls or interacts with the bar.
* @default 'hover'
*/
type?: ScrollAreaType;
/** Reading direction. Inherits from `ConfigProvider` when omitted. */
dir?: 'ltr' | 'rtl';
/**
* For `type='scroll'` and `type='hover'`, the time in ms before scrollbars hide
* after the user stops interacting.
* @default 600
*/
scrollHideDelay?: number;
}
</script>
<script setup lang="ts">
import { computed, ref, shallowRef, toRef } from 'vue';
import { Primitive } from '../../internal/primitive';
import { provideScrollAreaRootContext } from './context';
import { useConfig, useId } from '../../utilities/config-provider';
import { useForwardExpose } from '@robonen/vue';
defineOptions({ inheritAttrs: false });
const props = withDefaults(defineProps<ScrollAreaRootProps>(), {
type: 'hover',
scrollHideDelay: 600,
});
const config = useConfig();
const viewport = shallowRef<HTMLElement | null>(null);
const content = shallowRef<HTMLElement | null>(null);
const scrollbarX = shallowRef<HTMLElement | null>(null);
const scrollbarY = shallowRef<HTMLElement | null>(null);
const scrollbarXEnabled = ref(false);
const scrollbarYEnabled = ref(false);
const cornerWidth = ref(0);
const cornerHeight = ref(0);
const viewportId = useId(undefined, 'scroll-area-viewport');
const dir = computed(() => props.dir ?? config.dir.value);
// `defineExpose` runs BEFORE `useForwardExpose` so the composable merges these
// bindings (plus props + `$el`) instead of `defineExpose`'s `expose()`
// clobbering them and warning "expose() should be called only once". Unlike the
// other roots, `useForwardExpose` must stay above the provide below because
// `scrollArea` (its `currentElement`) feeds the context.
defineExpose({
viewport,
scrollTop: () => viewport.value?.scrollTo({ top: 0 }),
scrollTopLeft: () => viewport.value?.scrollTo({ top: 0, left: 0 }),
});
const { forwardRef, currentElement: scrollArea } = useForwardExpose();
provideScrollAreaRootContext({
type: toRef(props, 'type'),
dir,
scrollHideDelay: toRef(props, 'scrollHideDelay'),
scrollArea,
viewport,
content,
scrollbarX,
scrollbarY,
scrollbarXEnabled,
scrollbarYEnabled,
cornerWidth,
cornerHeight,
viewportId,
onScrollbarXEnabledChange: (v) => { scrollbarXEnabled.value = v; },
onScrollbarYEnabledChange: (v) => { scrollbarYEnabled.value = v; },
onScrollbarXChange: (el) => { scrollbarX.value = el; },
onScrollbarYChange: (el) => { scrollbarY.value = el; },
onCornerWidthChange: (n) => { cornerWidth.value = n; },
onCornerHeightChange: (n) => { cornerHeight.value = n; },
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:dir="dir"
:style="{
position: 'relative',
'--scroll-area-corner-width': `${cornerWidth}px`,
'--scroll-area-corner-height': `${cornerHeight}px`,
}"
v-bind="$attrs"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,104 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A custom scrollbar track for one axis. It picks the appropriate visibility strategy from
* the root's `type` (`auto`, `always`, `scroll`, or `hover`) and renders the matching
* scrolling behaviour. Render one for each axis you want scrollable and place a
* `ScrollAreaThumb` inside it.
*/
export interface ScrollAreaScrollbarProps extends PrimitiveProps {
/** @default 'vertical' */
orientation?: 'horizontal' | 'vertical';
/** Keep mounted regardless of visibility state. */
forceMount?: boolean;
}
</script>
<script setup lang="ts">
import { computed, onBeforeUnmount, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import ScrollAreaScrollbarAuto from './ScrollAreaScrollbarAuto.vue';
import ScrollAreaScrollbarGlimpse from './ScrollAreaScrollbarGlimpse.vue';
import ScrollAreaScrollbarHover from './ScrollAreaScrollbarHover.vue';
import ScrollAreaScrollbarScroll from './ScrollAreaScrollbarScroll.vue';
import ScrollAreaScrollbarVisible from './ScrollAreaScrollbarVisible.vue';
import { useScrollAreaRootContext } from './context';
defineOptions({ inheritAttrs: false });
const props = withDefaults(defineProps<ScrollAreaScrollbarProps>(), {
orientation: 'vertical',
});
const ctx = useScrollAreaRootContext();
const { forwardRef } = useForwardExpose();
const isHorizontal = computed(() => props.orientation === 'horizontal');
watch(isHorizontal, (h) => {
if (h)
ctx.onScrollbarXEnabledChange(true);
else
ctx.onScrollbarYEnabledChange(true);
}, { immediate: true });
onBeforeUnmount(() => {
if (isHorizontal.value)
ctx.onScrollbarXEnabledChange(false);
else
ctx.onScrollbarYEnabledChange(false);
});
</script>
<template>
<ScrollAreaScrollbarHover
v-if="ctx.type.value === 'hover'"
:ref="forwardRef"
v-bind="$attrs"
:orientation="orientation"
:force-mount="forceMount"
:as="as"
>
<slot />
</ScrollAreaScrollbarHover>
<ScrollAreaScrollbarScroll
v-else-if="ctx.type.value === 'scroll'"
:ref="forwardRef"
v-bind="$attrs"
:orientation="orientation"
:force-mount="forceMount"
:as="as"
>
<slot />
</ScrollAreaScrollbarScroll>
<ScrollAreaScrollbarGlimpse
v-else-if="ctx.type.value === 'glimpse'"
:ref="forwardRef"
v-bind="$attrs"
:orientation="orientation"
:force-mount="forceMount"
:as="as"
>
<slot />
</ScrollAreaScrollbarGlimpse>
<ScrollAreaScrollbarAuto
v-else-if="ctx.type.value === 'auto'"
:ref="forwardRef"
v-bind="$attrs"
:orientation="orientation"
:force-mount="forceMount"
:as="as"
>
<slot />
</ScrollAreaScrollbarAuto>
<ScrollAreaScrollbarVisible
v-else
:ref="forwardRef"
v-bind="$attrs"
:orientation="orientation"
:as="as"
data-state="visible"
>
<slot />
</ScrollAreaScrollbarVisible>
</template>
@@ -0,0 +1,83 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
export interface ScrollAreaScrollbarAutoProps extends PrimitiveProps {
orientation?: 'horizontal' | 'vertical';
forceMount?: boolean;
}
</script>
<script setup lang="ts">
import { computed, onMounted, onScopeDispose, ref, watch } from 'vue';
import { debounce } from '@robonen/stdlib';
import { useForwardExpose } from '@robonen/vue';
import { Presence } from '../../utilities/presence';
import ScrollAreaScrollbarVisible from './ScrollAreaScrollbarVisible.vue';
import { useScrollAreaRootContext } from './context';
defineOptions({ inheritAttrs: false });
const props = withDefaults(defineProps<ScrollAreaScrollbarAutoProps>(), {
orientation: 'vertical',
});
const ctx = useScrollAreaRootContext();
const { forwardRef } = useForwardExpose();
const visible = ref(false);
const isHorizontal = computed(() => props.orientation === 'horizontal');
const handleResize = debounce(() => {
const vp = ctx.viewport.value;
if (!vp)
return;
const overflowX = vp.offsetWidth < vp.scrollWidth;
const overflowY = vp.offsetHeight < vp.scrollHeight;
visible.value = isHorizontal.value ? overflowX : overflowY;
}, 10);
let viewportObserver: ResizeObserver | null = null;
let contentObserver: ResizeObserver | null = null;
function attach() {
detach();
const vp = ctx.viewport.value;
const co = ctx.content.value;
if (vp) {
viewportObserver = new ResizeObserver(handleResize);
viewportObserver.observe(vp);
}
if (co) {
contentObserver = new ResizeObserver(handleResize);
contentObserver.observe(co);
}
handleResize();
}
function detach() {
viewportObserver?.disconnect();
viewportObserver = null;
contentObserver?.disconnect();
contentObserver = null;
}
onMounted(attach);
watch([() => ctx.viewport.value, () => ctx.content.value], attach);
onScopeDispose(() => {
handleResize.cancel();
detach();
});
</script>
<template>
<Presence :present="forceMount || visible">
<ScrollAreaScrollbarVisible
:ref="forwardRef"
v-bind="$attrs"
:orientation="orientation"
:as="as"
:data-state="visible ? 'visible' : 'hidden'"
>
<slot />
</ScrollAreaScrollbarVisible>
</Presence>
</template>
@@ -0,0 +1,122 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A hybrid visibility strategy used for `type='glimpse'`: scrollbars briefly
* reveal themselves when the pointer enters the scroll area, then auto-hide
* after `scrollHideDelay`, and behave like `type='scroll'` once the user
* actually scrolls or interacts with the bar.
*/
export interface ScrollAreaScrollbarGlimpseProps extends PrimitiveProps {
orientation?: 'horizontal' | 'vertical';
forceMount?: boolean;
}
</script>
<script setup lang="ts">
import { computed, onScopeDispose, onWatcherCleanup, ref, watch, watchEffect } from 'vue';
import { debounce } from '@robonen/stdlib';
import { useEventListener, useForwardExpose } from '@robonen/vue';
import { Presence } from '../../utilities/presence';
import ScrollAreaScrollbarAuto from './ScrollAreaScrollbarAuto.vue';
import { useScrollAreaRootContext } from './context';
import { addUnlinkedScrollListener } from './utils';
defineOptions({ inheritAttrs: false });
const props = withDefaults(defineProps<ScrollAreaScrollbarGlimpseProps>(), {
orientation: 'vertical',
});
const ctx = useScrollAreaRootContext();
const { forwardRef } = useForwardExpose();
type GlimpseState = 'hidden' | 'glimpse' | 'scrolling' | 'interacting' | 'idle';
type GlimpseEvent = 'POINTER_ENTER' | 'POINTER_LEAVE' | 'SCROLL' | 'SCROLL_END' | 'HIDE';
/**
* Declarative transition table — keeps the visibility lifecycle flat instead
* of branching inline. Unknown events for a state are a no-op.
*/
const TRANSITIONS: Record<GlimpseState, Partial<Record<GlimpseEvent, GlimpseState>>> = {
hidden: { POINTER_ENTER: 'glimpse', SCROLL: 'scrolling' },
glimpse: { HIDE: 'hidden', POINTER_LEAVE: 'hidden', SCROLL: 'scrolling', POINTER_ENTER: 'glimpse' },
scrolling: { SCROLL_END: 'idle', POINTER_ENTER: 'interacting' },
interacting: { SCROLL: 'interacting', POINTER_LEAVE: 'idle' },
idle: { HIDE: 'hidden', SCROLL: 'scrolling', POINTER_ENTER: 'interacting' },
};
const state = ref<GlimpseState>('hidden');
const isHorizontal = computed(() => props.orientation === 'horizontal');
const visible = computed(() => state.value !== 'hidden');
function dispatch(event: GlimpseEvent) {
const next = TRANSITIONS[state.value][event];
if (next)
state.value = next;
}
const debouncedScrollEnd = debounce(() => dispatch('SCROLL_END'), 100);
function onEnter() {
dispatch('POINTER_ENTER');
}
function onLeave() {
dispatch('POINTER_LEAVE');
}
// Auto-hide after the delay while glimpsing or idle.
watchEffect(() => {
if (state.value === 'glimpse' || state.value === 'idle') {
const id = globalThis.setTimeout(() => dispatch('HIDE'), ctx.scrollHideDelay.value);
onWatcherCleanup(() => globalThis.clearTimeout(id));
}
});
let stop: (() => void) | null = null;
let last = { left: 0, top: 0 };
function attachScroll() {
stop?.();
const vp = ctx.viewport.value;
if (!vp)
return;
last = { left: vp.scrollLeft, top: vp.scrollTop };
stop = addUnlinkedScrollListener(vp, () => {
const next = { left: vp.scrollLeft, top: vp.scrollTop };
const matches = isHorizontal.value ? last.left !== next.left : last.top !== next.top;
if (matches) {
dispatch('SCROLL');
debouncedScrollEnd();
}
last = next;
});
}
// Re-attaches automatically when the scroll-area element changes; SSR-safe
// (the getter resolves to `undefined` on the server).
useEventListener(() => ctx.scrollArea.value, 'pointerenter', onEnter);
useEventListener(() => ctx.scrollArea.value, 'pointerleave', onLeave);
watch(() => ctx.viewport.value, attachScroll, { immediate: true });
onScopeDispose(() => {
stop?.();
debouncedScrollEnd.cancel();
});
</script>
<template>
<Presence :present="forceMount || visible">
<ScrollAreaScrollbarAuto
:ref="forwardRef"
v-bind="$attrs"
:orientation="orientation"
:as="as"
:data-state="visible ? 'visible' : 'hidden'"
force-mount
>
<slot />
</ScrollAreaScrollbarAuto>
</Presence>
</template>
@@ -0,0 +1,67 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
export interface ScrollAreaScrollbarHoverProps extends PrimitiveProps {
orientation?: 'horizontal' | 'vertical';
forceMount?: boolean;
}
</script>
<script setup lang="ts">
import { onScopeDispose, ref } from 'vue';
import { useEventListener, useForwardExpose } from '@robonen/vue';
import { Presence } from '../../utilities/presence';
import ScrollAreaScrollbarAuto from './ScrollAreaScrollbarAuto.vue';
import { useScrollAreaRootContext } from './context';
defineOptions({ inheritAttrs: false });
withDefaults(defineProps<ScrollAreaScrollbarHoverProps>(), {
orientation: 'vertical',
});
const ctx = useScrollAreaRootContext();
const { forwardRef } = useForwardExpose();
const visible = ref(false);
let hideTimer: ReturnType<typeof setTimeout> | null = null;
function clearTimer() {
if (hideTimer !== null) {
globalThis.clearTimeout(hideTimer);
hideTimer = null;
}
}
function onEnter() {
clearTimer();
visible.value = true;
}
function onLeave() {
clearTimer();
hideTimer = globalThis.setTimeout(() => {
visible.value = false;
}, ctx.scrollHideDelay.value);
}
// Re-attaches automatically when the scroll-area element changes; SSR-safe
// (the getter resolves to `undefined` on the server).
useEventListener(() => ctx.scrollArea.value, 'pointerenter', onEnter);
useEventListener(() => ctx.scrollArea.value, 'pointerleave', onLeave);
onScopeDispose(clearTimer);
</script>
<template>
<Presence :present="forceMount || visible">
<ScrollAreaScrollbarAuto
:ref="forwardRef"
v-bind="$attrs"
:orientation="orientation"
:as="as"
:data-state="visible ? 'visible' : 'hidden'"
force-mount
>
<slot />
</ScrollAreaScrollbarAuto>
</Presence>
</template>
@@ -0,0 +1,335 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
import type { ScrollAreaSizes } from './types';
export interface ScrollAreaScrollbarImplProps extends PrimitiveProps {
orientation: 'horizontal' | 'vertical';
sizes: ScrollAreaSizes;
hasThumb: boolean;
}
</script>
<script setup lang="ts">
import { computed, onMounted, onScopeDispose, ref, watch } from 'vue';
import { debounce } from '@robonen/stdlib';
import { Primitive } from '../../internal/primitive';
import { getThumbSize, toInt } from './utils';
import { useForwardExpose } from '@robonen/vue';
import { useScrollAreaRootContext } from './context';
defineOptions({ inheritAttrs: false });
const props = defineProps<ScrollAreaScrollbarImplProps>();
const emit = defineEmits<{
sizesChange: [sizes: ScrollAreaSizes];
wheelScroll: [event: WheelEvent, maxScroll: number];
dragScroll: [pointerPos: number];
thumbPositionChange: [];
registerScrollbar: [el: HTMLElement | null];
}>();
const ctx = useScrollAreaRootContext();
const { forwardRef, currentElement } = useForwardExpose();
const isHorizontal = computed(() => props.orientation === 'horizontal');
const rectRef = ref<DOMRect | null>(null);
const prevWebkitUserSelect = ref('');
const prevPointerEvents = ref('');
const prevScrollBehavior = ref('');
/** Live viewport scroll position along this scrollbar's axis. */
const scrollPos = ref(0);
const maxScroll = computed(() =>
Math.max(0, props.sizes.content - props.sizes.viewport),
);
const ariaValueNow = computed(() => {
if (maxScroll.value <= 0) return 0;
const pct = (scrollPos.value / maxScroll.value) * 100;
return Math.round(Math.min(100, Math.max(0, pct)));
});
/** Scrollbar is interactive only when content actually overflows. */
const isInteractive = computed(() => props.hasThumb && maxScroll.value > 0);
/**
* Thumb length along this scrollbar's axis, exposed as the CSS var that
* `ScrollAreaThumb` reads. Without this the thumb collapses to zero length.
*/
const thumbSize = computed(() => `${getThumbSize(props.sizes)}px`);
/**
* Absolute positioning of the track. RTL flips the resting edge: the vertical
* bar moves to the left, and the horizontal bar's corner gap swaps sides.
*/
const positionStyle = computed(() => {
const isRtl = ctx.dir.value === 'rtl';
if (isHorizontal.value) {
return {
bottom: 0,
left: isRtl ? 'var(--scroll-area-corner-width)' : 0,
right: isRtl ? 0 : 'var(--scroll-area-corner-width)',
'--scroll-area-thumb-width': thumbSize.value,
};
}
return {
top: 0,
right: isRtl ? undefined : 0,
left: isRtl ? 0 : undefined,
bottom: 'var(--scroll-area-corner-height)',
'--scroll-area-thumb-height': thumbSize.value,
};
});
function updateScrollPos() {
const vp = ctx.viewport.value;
if (!vp) return;
scrollPos.value = isHorizontal.value ? vp.scrollLeft : vp.scrollTop;
}
function getPointerPosition(event: PointerEvent): number {
const rect = rectRef.value;
if (!rect)
return 0;
return isHorizontal.value ? event.clientX - rect.left : event.clientY - rect.top;
}
function onPointerDown(event: PointerEvent) {
if (event.button !== 0)
return;
const target = event.target as HTMLElement;
target.setPointerCapture(event.pointerId);
rectRef.value = currentElement.value?.getBoundingClientRect() ?? null;
prevWebkitUserSelect.value = document.body.style.webkitUserSelect;
document.body.style.webkitUserSelect = 'none';
if (ctx.viewport.value) {
prevPointerEvents.value = ctx.viewport.value.style.pointerEvents;
ctx.viewport.value.style.pointerEvents = 'none';
// Disable smooth scrolling during the drag so the thumb tracks the
// pointer 1:1 instead of lagging behind a `scroll-behavior: smooth`.
prevScrollBehavior.value = ctx.viewport.value.style.scrollBehavior;
ctx.viewport.value.style.scrollBehavior = 'auto';
}
emit('dragScroll', getPointerPosition(event));
}
function onPointerMove(event: PointerEvent) {
// `rectRef` is only set on pointerdown and cleared on pointerup, so it is the
// natural drag flag. Without this guard `pointermove` fires on every hover
// move over the track: each one emits `dragScroll` with a position of 0
// (getPointerPosition returns 0 when `rectRef` is null) and forces the
// viewport toward scroll position 0 — both wasted work and a correctness bug.
if (!rectRef.value)
return;
emit('dragScroll', getPointerPosition(event));
}
function onPointerUp(event: PointerEvent) {
const target = event.target as HTMLElement;
if (target.hasPointerCapture(event.pointerId))
target.releasePointerCapture(event.pointerId);
document.body.style.webkitUserSelect = prevWebkitUserSelect.value;
if (ctx.viewport.value) {
ctx.viewport.value.style.pointerEvents = prevPointerEvents.value;
ctx.viewport.value.style.scrollBehavior = prevScrollBehavior.value;
}
rectRef.value = null;
}
/**
* Wheeling over the scrollbar scrolls the viewport. The listener must be
* non-passive so `preventDefault` inside the handler can stop the page from
* scrolling when the viewport is mid-range; an `@wheel.passive` binding would
* make that `preventDefault` a no-op. It is scoped to wheels landing on this
* scrollbar via `contains`.
*/
function onWheel(event: WheelEvent) {
const sb = currentElement.value;
if (!sb || !sb.contains(event.target as Node))
return;
emit('wheelScroll', event, maxScroll.value);
}
/**
* WAI-ARIA scrollbar pattern — Arrow ±5% of the viewport size, PageUp/Down
* jump a full viewport, Home/End to the extremes. In RTL the horizontal
* arrow keys are visually reversed.
*/
function onKeyDown(event: KeyboardEvent) {
if (!isInteractive.value) return;
const vp = ctx.viewport.value;
if (!vp) return;
const step = Math.max(1, Math.round(props.sizes.viewport * 0.05));
const page = Math.max(step, props.sizes.viewport);
const dir = ctx.dir.value;
let delta = 0;
let absolute: number | null = null;
if (isHorizontal.value) {
const forwardKey = dir === 'rtl' ? 'ArrowLeft' : 'ArrowRight';
const backwardKey = dir === 'rtl' ? 'ArrowRight' : 'ArrowLeft';
if (event.key === forwardKey) delta = step;
else if (event.key === backwardKey) delta = -step;
else if (event.key === 'PageDown' || event.key === 'PageUp')
delta = event.key === 'PageDown' ? page : -page;
else if (event.key === 'Home') absolute = 0;
else if (event.key === 'End') absolute = maxScroll.value;
else return;
}
else if (event.key === 'ArrowDown') delta = step;
else if (event.key === 'ArrowUp') delta = -step;
else if (event.key === 'PageDown') delta = page;
else if (event.key === 'PageUp') delta = -page;
else if (event.key === 'Home') absolute = 0;
else if (event.key === 'End') absolute = maxScroll.value;
else return;
event.preventDefault();
const current = isHorizontal.value ? vp.scrollLeft : vp.scrollTop;
const next = absolute !== null
? absolute
: Math.max(0, Math.min(maxScroll.value, current + delta));
if (isHorizontal.value) vp.scrollLeft = next;
else vp.scrollTop = next;
}
function measure() {
const sb = currentElement.value;
const vp = ctx.viewport.value;
const co = ctx.content.value;
if (!sb || !vp)
return;
const cs = globalThis.getComputedStyle(sb);
emit('sizesChange', {
content: co ? (isHorizontal.value ? co.scrollWidth : co.scrollHeight) : (isHorizontal.value ? vp.scrollWidth : vp.scrollHeight),
viewport: isHorizontal.value ? vp.offsetWidth : vp.offsetHeight,
scrollbar: {
size: isHorizontal.value ? sb.clientWidth : sb.clientHeight,
paddingStart: isHorizontal.value ? toInt(cs.paddingLeft) : toInt(cs.paddingTop),
paddingEnd: isHorizontal.value ? toInt(cs.paddingRight) : toInt(cs.paddingBottom),
},
});
}
let sbObs: ResizeObserver | null = null;
let vpObs: ResizeObserver | null = null;
let coObs: ResizeObserver | null = null;
/**
* A single layout change can resize more than one observed element (scrollbar,
* viewport, content), invoking the observer callback multiple times
* synchronously — each forcing a `getComputedStyle` + scrollWidth/offsetWidth
* read. Debouncing (mirroring `ScrollAreaScrollbarAuto`) coalesces those into
* one measure per burst. `attach()` still calls the undebounced `measure()`
* once so the initial size is available immediately on mount (no thumb flash).
*/
const measureDebounced = debounce(measure, 10);
/** The element the non-passive wheel listener is currently bound to. */
let wheelEl: HTMLElement | null = null;
function attachWheel() {
const sb = currentElement.value ?? null;
if (wheelEl === sb)
return;
wheelEl?.removeEventListener('wheel', onWheel);
wheelEl = sb;
// Non-passive so `preventDefault` inside `onWheel` can stop the page from
// scrolling when the viewport is mid-range. Scoped to the scrollbar element
// itself rather than `document`, so unrelated page wheels no longer pay the
// non-passive cost or run the handler.
wheelEl?.addEventListener('wheel', onWheel, { passive: false });
}
function attach() {
detach();
if (currentElement.value) {
sbObs = new ResizeObserver(measureDebounced);
sbObs.observe(currentElement.value);
}
if (ctx.viewport.value) {
vpObs = new ResizeObserver(measureDebounced);
vpObs.observe(ctx.viewport.value);
}
if (ctx.content.value) {
coObs = new ResizeObserver(measureDebounced);
coObs.observe(ctx.content.value);
}
attachWheel();
measure();
updateScrollPos();
emit('thumbPositionChange');
}
function detach() {
measureDebounced.cancel();
sbObs?.disconnect();
sbObs = null;
vpObs?.disconnect();
vpObs = null;
coObs?.disconnect();
coObs = null;
}
function registerScrollbarEl(el: HTMLElement | null) {
emit('registerScrollbar', el);
if (isHorizontal.value)
ctx.onScrollbarXChange(el);
else
ctx.onScrollbarYChange(el);
}
onMounted(() => {
registerScrollbarEl(currentElement.value ?? null);
attach();
});
watch([() => ctx.viewport.value, () => ctx.content.value, currentElement], attach);
function onViewportScroll() {
updateScrollPos();
emit('thumbPositionChange');
}
watch(() => ctx.viewport.value, (vp, prev) => {
prev?.removeEventListener('scroll', onViewportScroll);
vp?.addEventListener('scroll', onViewportScroll, { passive: true });
}, { immediate: true });
onScopeDispose(() => {
detach();
wheelEl?.removeEventListener('wheel', onWheel);
wheelEl = null;
ctx.viewport.value?.removeEventListener('scroll', onViewportScroll);
registerScrollbarEl(null);
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as ?? 'div'"
role="scrollbar"
:aria-orientation="orientation"
:aria-controls="ctx.viewportId.value"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="ariaValueNow"
:tabindex="isInteractive ? 0 : -1"
:aria-disabled="isInteractive ? undefined : true"
:data-orientation="orientation"
:style="{
position: 'absolute',
...positionStyle,
}"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@keydown="onKeyDown"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,92 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
export interface ScrollAreaScrollbarScrollProps extends PrimitiveProps {
orientation?: 'horizontal' | 'vertical';
forceMount?: boolean;
}
</script>
<script setup lang="ts">
import { computed, onMounted, onScopeDispose, ref, watch } from 'vue';
import { debounce } from '@robonen/stdlib';
import { useForwardExpose } from '@robonen/vue';
import { Presence } from '../../utilities/presence';
import { useScrollAreaRootContext } from './context';
import ScrollAreaScrollbarVisible from './ScrollAreaScrollbarVisible.vue';
import { addUnlinkedScrollListener } from './utils';
defineOptions({ inheritAttrs: false });
const props = withDefaults(defineProps<ScrollAreaScrollbarScrollProps>(), {
orientation: 'vertical',
});
const ctx = useScrollAreaRootContext();
const { forwardRef } = useForwardExpose();
const isHorizontal = computed(() => props.orientation === 'horizontal');
const state = ref<'hidden' | 'scrolling' | 'interacting' | 'idle'>('hidden');
const debouncedScrollEnd = debounce(() => {
state.value = 'idle';
}, 100);
const debouncedHide = debounce(() => {
state.value = 'hidden';
}, ctx.scrollHideDelay.value);
watch(state, (s, prev) => {
if (s === 'idle')
debouncedHide();
if (s !== 'idle' && prev === 'idle')
debouncedHide.cancel();
});
let last = { left: 0, top: 0 };
let stop: (() => void) | null = null;
function attach() {
stop?.();
const vp = ctx.viewport.value;
if (!vp)
return;
last = { left: vp.scrollLeft, top: vp.scrollTop };
stop = addUnlinkedScrollListener(vp, () => {
const next = { left: vp.scrollLeft, top: vp.scrollTop };
const horiz = last.left !== next.left;
const vert = last.top !== next.top;
const matches = isHorizontal.value ? horiz : vert;
if (matches) {
state.value = 'scrolling';
debouncedScrollEnd();
}
last = next;
});
}
onMounted(attach);
watch(() => ctx.viewport.value, attach);
onScopeDispose(() => {
stop?.();
debouncedScrollEnd.cancel();
debouncedHide.cancel();
});
const isVisible = computed(() => state.value !== 'hidden');
</script>
<template>
<Presence :present="forceMount || isVisible">
<ScrollAreaScrollbarVisible
:ref="forwardRef"
v-bind="$attrs"
:orientation="orientation"
:as="as"
:data-state="isVisible ? 'visible' : 'hidden'"
@pointerenter="state = 'interacting'"
@pointerleave="state = 'idle'"
>
<slot />
</ScrollAreaScrollbarVisible>
</Presence>
</template>
@@ -0,0 +1,120 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
</script>
<script setup lang="ts">
import type { ScrollAreaSizes } from './types';
import { computed, ref, shallowRef } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { provideScrollAreaScrollbarContext, useScrollAreaRootContext } from './context';
import ScrollAreaScrollbarImpl from './ScrollAreaScrollbarImpl.vue';
import { getScrollPositionFromPointer, getThumbOffsetFromScroll, getThumbRatio, isScrollingWithinScrollbarBounds } from './utils';
defineOptions({ inheritAttrs: false });
const props = withDefaults(defineProps<PrimitiveProps & { orientation?: 'horizontal' | 'vertical' }>(), {
orientation: 'vertical',
});
const ctx = useScrollAreaRootContext();
const { forwardRef } = useForwardExpose();
const sizes = ref<ScrollAreaSizes>({
content: 0,
viewport: 0,
scrollbar: { size: 0, paddingStart: 0, paddingEnd: 0 },
});
const isHorizontal = computed(() => props.orientation === 'horizontal');
const hasThumb = computed(() => {
const r = getThumbRatio(sizes.value.viewport, sizes.value.content);
return r > 0 && r < 1;
});
const thumbEl = shallowRef<HTMLElement | null>(null);
const scrollbarEl = shallowRef<HTMLElement | null>(null);
const pointerOffset = ref(0);
function onSizesChange(s: ScrollAreaSizes) {
sizes.value = s;
}
function onThumbPointerDown(point: { x: number; y: number }) {
pointerOffset.value = isHorizontal.value ? point.x : point.y;
}
function onThumbPointerUp() {
pointerOffset.value = 0;
}
function onWheelScroll(event: WheelEvent, maxScroll: number) {
const vp = ctx.viewport.value;
if (!vp)
return;
if (isHorizontal.value) {
const next = vp.scrollLeft + event.deltaY;
vp.scrollLeft = next;
if (isScrollingWithinScrollbarBounds(next, maxScroll))
event.preventDefault();
}
else {
const next = vp.scrollTop + event.deltaY;
vp.scrollTop = next;
if (isScrollingWithinScrollbarBounds(next, maxScroll))
event.preventDefault();
}
}
function onDragScroll(pointerPos: number) {
const vp = ctx.viewport.value;
if (!vp)
return;
if (isHorizontal.value) {
vp.scrollLeft = getScrollPositionFromPointer(pointerPos, pointerOffset.value, sizes.value, ctx.dir.value);
}
else {
vp.scrollTop = getScrollPositionFromPointer(pointerPos, pointerOffset.value, sizes.value);
}
}
function onThumbPositionChange() {
const vp = ctx.viewport.value;
const thumb = thumbEl.value;
if (!vp || !thumb)
return;
if (isHorizontal.value) {
const offset = getThumbOffsetFromScroll(vp.scrollLeft, sizes.value, ctx.dir.value);
thumb.style.transform = `translate3d(${offset}px, 0, 0)`;
}
else {
const offset = getThumbOffsetFromScroll(vp.scrollTop, sizes.value);
thumb.style.transform = `translate3d(0, ${offset}px, 0)`;
}
}
provideScrollAreaScrollbarContext({
orientation: props.orientation,
hasThumb,
scrollbar: scrollbarEl,
onThumbChange: (el) => { thumbEl.value = el; },
onThumbPointerUp,
onThumbPointerDown,
onThumbPositionChange,
});
</script>
<template>
<ScrollAreaScrollbarImpl
:ref="forwardRef"
v-bind="$attrs"
:orientation="orientation"
:as="as"
:sizes="sizes"
:has-thumb="hasThumb"
@sizes-change="onSizesChange"
@wheel-scroll="onWheelScroll"
@drag-scroll="onDragScroll"
@thumb-position-change="onThumbPositionChange"
@register-scrollbar="(el) => { scrollbarEl = el; }"
>
<slot />
</ScrollAreaScrollbarImpl>
</template>
@@ -0,0 +1,88 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
</script>
<script setup lang="ts">
import { computed, onMounted, onScopeDispose, ref, watch } from 'vue';
import { Presence } from '../../utilities/presence';
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
import { useScrollAreaRootContext, useScrollAreaScrollbarContext } from './context';
import { addUnlinkedScrollListener } from './utils';
/**
* The draggable handle inside a `ScrollAreaScrollbar`. Its size and position track the
* scroll offset, and dragging it scrolls the viewport. Must be rendered inside a
* `ScrollAreaScrollbar`.
*/
export interface ScrollAreaThumbProps extends PrimitiveProps {
/** Keep mounted regardless of `hasThumb`. */
forceMount?: boolean;
}
defineOptions({ inheritAttrs: false });
const props = defineProps<ScrollAreaThumbProps>();
const root = useScrollAreaRootContext();
const sb = useScrollAreaScrollbarContext();
const removeUnlinkedScrollListenerRef = ref<(() => void) | null>(null);
const { forwardRef, currentElement } = useForwardExpose();
function handlePointerDown(event: PointerEvent) {
const target = event.target as HTMLElement;
const rect = target.getBoundingClientRect();
sb.onThumbPointerDown({ x: event.clientX - rect.left, y: event.clientY - rect.top });
}
function handlePointerUp() {
sb.onThumbPointerUp();
}
function attachScroll() {
removeUnlinkedScrollListenerRef.value?.();
const vp = root.viewport.value;
if (!vp)
return;
sb.onThumbPositionChange();
removeUnlinkedScrollListenerRef.value = addUnlinkedScrollListener(vp, () => {
sb.onThumbPositionChange();
});
}
onMounted(() => {
attachScroll();
if (currentElement.value)
sb.onThumbChange(currentElement.value);
});
watch(currentElement, el => sb.onThumbChange(el ?? null));
watch(() => root.viewport.value, attachScroll);
watch(() => sb.hasThumb.value, () => {
sb.onThumbPositionChange();
});
onScopeDispose(() => {
removeUnlinkedScrollListenerRef.value?.();
sb.onThumbChange(null);
});
const present = computed(() => props.forceMount || sb.hasThumb.value);
const dataState = computed(() => (sb.hasThumb.value ? 'visible' : 'hidden'));
</script>
<template>
<Presence :present="present">
<Primitive
:ref="forwardRef"
:as="as ?? 'div'"
:data-state="dataState"
:style="{ width: 'var(--scroll-area-thumb-width)', height: 'var(--scroll-area-thumb-height)' }"
v-bind="$attrs"
@pointerdowncapture="handlePointerDown"
@pointerup="handlePointerUp"
>
<slot />
</Primitive>
</Presence>
</template>
@@ -0,0 +1,97 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The scrollable region that clips and natively scrolls its content while the OS scrollbars
* are visually hidden. Place all scrollable content inside it; it must be a child of
* `ScrollAreaRoot`.
*/
export interface ScrollAreaViewportProps extends PrimitiveProps {
/**
* Inline `nonce` attribute applied to the injected style tag (CSP support).
* Falls back to the `ConfigProvider` `nonce` when omitted.
*/
nonce?: string;
/**
* `tabindex` applied to the scrollable region so keyboard users can focus the
* panel and arrow-scroll natively even when no scrollbar is interactive.
* Pass `-1`/`undefined` to opt out.
* @default 0
*/
tabindex?: number;
}
</script>
<script setup lang="ts">
import { computed, onMounted, shallowRef, toRef, watch } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
import { useNonce } from '../../utilities/config-provider';
import { useScrollAreaRootContext } from './context';
defineOptions({ inheritAttrs: false });
const props = withDefaults(defineProps<ScrollAreaViewportProps>(), {
tabindex: 0,
});
const ctx = useScrollAreaRootContext();
const { forwardRef, currentElement } = useForwardExpose();
const nonce = useNonce(toRef(() => props.nonce));
/**
* Only force `fit-content` when horizontal scrolling is enabled. In the common
* vertical-only case leaving `minWidth` unset lets the parent constrain width
* so `text-overflow: ellipsis` keeps working inside the content wrapper.
*/
const contentMinWidth = computed(() =>
ctx.scrollbarXEnabled.value ? 'fit-content' : '100%',
);
const contentRef = shallowRef<HTMLElement | null>(null);
watch(currentElement, (el) => {
ctx.viewport.value = el ?? null;
}, { immediate: true });
watch(contentRef, (el) => {
ctx.content.value = el ?? null;
}, { immediate: true });
onMounted(() => {
ctx.viewport.value = currentElement.value ?? null;
ctx.content.value = contentRef.value ?? null;
});
</script>
<template>
<!-- Hide native scrollbars while preserving native scrolling behaviour. -->
<component
:is="'style'"
:nonce="nonce"
>
[data-scroll-area-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}[data-scroll-area-viewport]::-webkit-scrollbar{display:none;}
</component>
<Primitive
:ref="forwardRef"
:as="as"
:id="($attrs.id as string | undefined) ?? ctx.viewportId.value"
data-scroll-area-viewport=""
:tabindex="tabindex"
:style="{
overflowX: ctx.scrollbarXEnabled.value ? 'scroll' : 'hidden',
overflowY: ctx.scrollbarYEnabled.value ? 'scroll' : 'hidden',
}"
v-bind="$attrs"
>
<!-- A `min-width: fit-content` inner ensures horizontal content is measurable. -->
<div
:ref="(el: any) => { contentRef = el; }"
:style="{ minWidth: contentMinWidth, display: 'table' }"
>
<slot />
</div>
</Primitive>
</template>
@@ -0,0 +1,200 @@
import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import {
ScrollAreaRoot,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
} from '../../../index';
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;
}
function makeApp({
rootProps = {},
innerSize = '500px',
}: { rootProps?: Record<string, unknown>; innerSize?: string } = {}) {
return defineComponent({
setup() {
return () => h(
ScrollAreaRoot,
{ ...rootProps, type: 'always', style: { width: '100px', height: '100px' } },
{
default: () => [
h(ScrollAreaViewport, { style: { width: '100%', height: '100%' } }, {
default: () => h('div', { style: { width: innerSize, height: innerSize } }, 'content'),
}),
h(ScrollAreaScrollbar, { orientation: 'vertical' }, {
default: () => h(ScrollAreaThumb),
}),
h(ScrollAreaScrollbar, { orientation: 'horizontal' }, {
default: () => h(ScrollAreaThumb),
}),
],
},
);
},
});
}
function mountApp(options?: { rootProps?: Record<string, unknown>; innerSize?: string }) {
return track(mount(makeApp(options), { attachTo: document.body }));
}
function getScrollbar(orientation: 'horizontal' | 'vertical'): HTMLElement {
return document.querySelector(`[role="scrollbar"][aria-orientation="${orientation}"]`) as HTMLElement;
}
function getViewport(): HTMLElement {
return document.querySelector('[data-scroll-area-viewport]') as HTMLElement;
}
async function waitFrames(n = 3) {
for (let i = 0; i < n; i++) {
await new Promise<void>(r => requestAnimationFrame(() => r()));
await nextTick();
}
}
describe('scroll-area — scrollbar ARIA', () => {
it('exposes role=scrollbar with full ARIA contract', async () => {
mountApp();
await waitFrames();
const v = getScrollbar('vertical');
const h = getScrollbar('horizontal');
expect(v).toBeTruthy();
expect(h).toBeTruthy();
for (const sb of [v, h]) {
expect(sb.getAttribute('aria-valuemin')).toBe('0');
expect(sb.getAttribute('aria-valuemax')).toBe('100');
expect(sb.getAttribute('aria-valuenow')).toBe('0');
}
expect(v.getAttribute('aria-orientation')).toBe('vertical');
expect(h.getAttribute('aria-orientation')).toBe('horizontal');
});
it('wires aria-controls to the viewport id', async () => {
mountApp();
await waitFrames();
const v = getScrollbar('vertical');
const vp = getViewport();
expect(vp.id).toBeTruthy();
expect(v.getAttribute('aria-controls')).toBe(vp.id);
});
it('marks scrollbar interactive (tabindex=0) when content overflows', async () => {
mountApp({ innerSize: '500px' });
await waitFrames();
const v = getScrollbar('vertical');
expect(v.getAttribute('tabindex')).toBe('0');
expect(v.hasAttribute('aria-disabled')).toBe(false);
});
it('marks scrollbar non-interactive (tabindex=-1, aria-disabled) when content fits', async () => {
mountApp({ innerSize: '50px' });
await waitFrames();
const v = getScrollbar('vertical');
expect(v.getAttribute('tabindex')).toBe('-1');
expect(v.getAttribute('aria-disabled')).toBe('true');
});
});
describe('scroll-area — scrollbar keyboard support', () => {
it('End scrolls the vertical viewport to the bottom and updates aria-valuenow', async () => {
mountApp({ innerSize: '500px' });
await waitFrames();
const v = getScrollbar('vertical');
const vp = getViewport();
expect(vp.scrollTop).toBe(0);
const ev = new KeyboardEvent('keydown', { key: 'End', bubbles: true, cancelable: true });
v.dispatchEvent(ev);
await waitFrames();
expect(ev.defaultPrevented).toBe(true);
expect(vp.scrollTop).toBeGreaterThan(0);
expect(v.getAttribute('aria-valuenow')).toBe('100');
});
it('Home scrolls to the top', async () => {
mountApp({ innerSize: '500px' });
await waitFrames();
const v = getScrollbar('vertical');
const vp = getViewport();
vp.scrollTop = 9999;
await waitFrames();
v.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true, cancelable: true }));
await waitFrames();
expect(vp.scrollTop).toBe(0);
expect(v.getAttribute('aria-valuenow')).toBe('0');
});
it('ArrowDown moves the vertical viewport forward by ~5% of viewport size', async () => {
mountApp({ innerSize: '500px' });
await waitFrames();
const v = getScrollbar('vertical');
const vp = getViewport();
const expectedStep = Math.max(1, Math.round(vp.offsetHeight * 0.05));
v.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }));
await waitFrames();
expect(vp.scrollTop).toBe(expectedStep);
});
it('PageDown moves the vertical viewport by a full viewport', async () => {
mountApp({ innerSize: '500px' });
await waitFrames();
const v = getScrollbar('vertical');
const vp = getViewport();
const page = vp.offsetHeight;
v.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageDown', bubbles: true, cancelable: true }));
await waitFrames();
expect(vp.scrollTop).toBe(page);
});
it('LTR: ArrowRight scrolls the horizontal viewport forward', async () => {
mountApp({ innerSize: '500px' });
await waitFrames();
const h = getScrollbar('horizontal');
const vp = getViewport();
const expectedStep = Math.max(1, Math.round(vp.offsetWidth * 0.05));
h.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true }));
await waitFrames();
expect(vp.scrollLeft).toBe(expectedStep);
});
it('RTL: ArrowLeft on the horizontal scrollbar engages the handler (visually reversed)', async () => {
mountApp({ rootProps: { dir: 'rtl' }, innerSize: '500px' });
await waitFrames();
const h = getScrollbar('horizontal');
const ev = new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true, cancelable: true });
h.dispatchEvent(ev);
await waitFrames();
// In RTL the visually-forward arrow is ArrowLeft; we assert the handler
// claimed the event (browser RTL scrollLeft semantics vary, so direction
// of the delta itself is asserted indirectly via preventDefault here).
expect(ev.defaultPrevented).toBe(true);
});
it('keydown is a no-op when the scrollbar is non-interactive', async () => {
mountApp({ innerSize: '50px' });
await waitFrames();
const v = getScrollbar('vertical');
const vp = getViewport();
const before = vp.scrollTop;
const ev = new KeyboardEvent('keydown', { key: 'End', bubbles: true, cancelable: true });
v.dispatchEvent(ev);
await waitFrames();
expect(ev.defaultPrevented).toBe(false);
expect(vp.scrollTop).toBe(before);
});
});
@@ -0,0 +1,198 @@
import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import {
ScrollAreaCorner,
ScrollAreaRoot,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
} from '../../../index';
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 makeApp(rootProps: Record<string, unknown> = {}, opts: { scrollbarRef?: any } = {}) {
return defineComponent({
setup() {
return () => h(
ScrollAreaRoot,
{ ...rootProps, style: { width: '100px', height: '100px' } },
{
default: () => [
h(ScrollAreaViewport, { style: { width: '100%', height: '100%' } }, {
default: () => h('div', { style: { width: '500px', height: '500px' } }, 'content'),
}),
h(ScrollAreaScrollbar, { orientation: 'vertical', ...(opts.scrollbarRef ? { ref: opts.scrollbarRef } : {}) }, {
default: () => h(ScrollAreaThumb),
}),
h(ScrollAreaScrollbar, { orientation: 'horizontal' }, {
default: () => h(ScrollAreaThumb),
}),
h(ScrollAreaCorner),
],
},
);
},
});
}
async function waitFrames(n = 3) {
for (let i = 0; i < n; i++) {
await new Promise<void>(r => requestAnimationFrame(() => r()));
await nextTick();
}
}
function getScrollbar(orientation: 'horizontal' | 'vertical'): HTMLElement {
return document.querySelector(`[role="scrollbar"][aria-orientation="${orientation}"]`) as HTMLElement;
}
function getViewport(): HTMLElement {
return document.querySelector('[data-scroll-area-viewport]') as HTMLElement;
}
describe('scroll-area — thumb sizing', () => {
it('sets the thumb-size CSS var on the scrollbar so the thumb has length', async () => {
track(mount(makeApp({ type: 'always' }), { attachTo: document.body }));
await waitFrames();
const v = getScrollbar('vertical');
const h2 = getScrollbar('horizontal');
// The vars are written inline on the scrollbar element style.
expect(v.style.getPropertyValue('--scroll-area-thumb-height')).toMatch(/px$/);
expect(h2.style.getPropertyValue('--scroll-area-thumb-width')).toMatch(/px$/);
});
});
describe('scroll-area — viewport keyboard focus', () => {
it('viewport is focusable by default (tabindex=0)', async () => {
track(mount(makeApp({ type: 'always' }), { attachTo: document.body }));
await waitFrames();
expect(getViewport().getAttribute('tabindex')).toBe('0');
});
it('viewport tabindex is overridable', async () => {
const App = defineComponent({
setup() {
return () => h(
ScrollAreaRoot,
{ type: 'always', style: { width: '100px', height: '100px' } },
{
default: () => [
h(ScrollAreaViewport, { tabindex: -1, style: { width: '100%', height: '100%' } }, {
default: () => h('div', { style: { width: '500px', height: '500px' } }, 'content'),
}),
h(ScrollAreaScrollbar, { orientation: 'vertical' }, { default: () => h(ScrollAreaThumb) }),
],
},
);
},
});
track(mount(App, { attachTo: document.body }));
await waitFrames();
expect(getViewport().getAttribute('tabindex')).toBe('-1');
});
});
describe('scroll-area — RTL scrollbar positioning', () => {
it('LTR keeps the vertical bar on the right', async () => {
track(mount(makeApp({ type: 'always', dir: 'ltr' }), { attachTo: document.body }));
await waitFrames();
const v = getScrollbar('vertical');
expect(v.style.right).toBe('0px');
expect(v.style.left).toBe('');
});
it('RTL flips the vertical bar to the left', async () => {
track(mount(makeApp({ type: 'always', dir: 'rtl' }), { attachTo: document.body }));
await waitFrames();
const v = getScrollbar('vertical');
expect(v.style.left).toBe('0px');
expect(v.style.right).toBe('');
});
});
describe('scroll-area — thumb data-state', () => {
it('reflects hasThumb when content overflows', async () => {
track(mount(makeApp({ type: 'always' }), { attachTo: document.body }));
await waitFrames();
const thumb = document.querySelector('[data-state]') as HTMLElement;
expect(thumb).toBeTruthy();
// jsdom has no layout; with always type the scrollbar is mounted and
// thumb data-state derives from hasThumb (false in jsdom => 'hidden').
expect(['visible', 'hidden']).toContain(thumb.getAttribute('data-state'));
});
});
describe('scroll-area — ref forwarding', () => {
it('forwards a template ref on ScrollAreaScrollbar to the DOM scrollbar element', async () => {
const scrollbarRef = ref<any>(null);
track(mount(makeApp({ type: 'always' }, { scrollbarRef }), { attachTo: document.body }));
await waitFrames();
const el = scrollbarRef.value?.$el ?? scrollbarRef.value;
expect(el).toBeTruthy();
expect((el as HTMLElement).getAttribute?.('role')).toBe('scrollbar');
});
});
describe('scroll-area — glimpse type', () => {
it('accepts type="glimpse" and reveals scrollbars on pointer enter', async () => {
track(mount(makeApp({ type: 'glimpse', scrollHideDelay: 5000 }), { attachTo: document.body }));
await waitFrames();
const root = document.querySelector('[dir]') as HTMLElement;
root.dispatchEvent(new PointerEvent('pointerenter'));
await waitFrames();
expect(document.querySelectorAll('[data-state="visible"]').length).toBeGreaterThan(0);
});
it('glimpse stays hidden before any interaction', async () => {
track(mount(makeApp({ type: 'glimpse', scrollHideDelay: 5000 }), { attachTo: document.body }));
await waitFrames();
// No pointer enter / scroll => no visible scrollbar yet.
expect(document.querySelectorAll('[data-state="visible"]').length).toBe(0);
});
});
describe('scroll-area — corner type guard', () => {
it('does not render a corner for type="scroll"', async () => {
const App = defineComponent({
setup() {
return () => h(
ScrollAreaRoot,
{ type: 'scroll', style: { width: '100px', height: '100px' } },
{
default: () => [
h(ScrollAreaViewport, { style: { width: '100%', height: '100%' } }, {
default: () => h('div', { style: { width: '500px', height: '500px' } }, 'content'),
}),
h(ScrollAreaScrollbar, { orientation: 'vertical', forceMount: true }, { default: () => h(ScrollAreaThumb) }),
h(ScrollAreaScrollbar, { orientation: 'horizontal', forceMount: true }, { default: () => h(ScrollAreaThumb) }),
h(ScrollAreaCorner),
],
},
);
},
});
const w = track(mount(App, { attachTo: document.body }));
await waitFrames();
// Corner only renders when both bars are present AND type !== 'scroll'.
// For type='scroll' the corner must never appear.
const corners = w.findAll('div').filter(d => (d.element as HTMLElement).style.position === 'absolute' && (d.element as HTMLElement).getAttribute('role') !== 'scrollbar');
// None of the absolutely-positioned non-scrollbar divs should be the corner.
// (Corner has both width/height px set and bottom:0 with no role.)
const corner = corners.find(d => (d.element as HTMLElement).style.bottom === '0px' && !(d.element as HTMLElement).hasAttribute('aria-orientation'));
expect(corner).toBeUndefined();
});
});
@@ -0,0 +1,182 @@
import {
ScrollAreaCorner,
ScrollAreaRoot,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
} from '../../../index';
import {
clamp,
getScrollPositionFromPointer,
getThumbOffsetFromScroll,
getThumbRatio,
getThumbSize,
isScrollingWithinScrollbarBounds,
toInt,
} from '../utils';
import { afterEach, describe, expect, it } 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');
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
function makeApp(rootProps: Record<string, unknown> = {}) {
return defineComponent({
setup(_, { expose }) {
const rootRef = ref<any>(null);
expose({ rootRef });
return () => h(ScrollAreaRoot, { ref: rootRef, ...rootProps, style: { width: '100px', height: '100px' } }, {
default: () => [
h(ScrollAreaViewport, { style: { width: '100%', height: '100%' } }, {
default: () => h('div', { style: { width: '500px', height: '500px' } }, 'content'),
}),
h(ScrollAreaScrollbar, { orientation: 'vertical' }, {
default: () => h(ScrollAreaThumb),
}),
h(ScrollAreaScrollbar, { orientation: 'horizontal' }, {
default: () => h(ScrollAreaThumb),
}),
h(ScrollAreaCorner),
],
});
},
});
}
describe('scrollArea utils', () => {
it('clamp constrains value to range', () => {
expect(clamp(5, 0, 10)).toBe(5);
expect(clamp(-1, 0, 10)).toBe(0);
expect(clamp(99, 0, 10)).toBe(10);
});
it('toInt parses pixel strings', () => {
expect(toInt('12px')).toBe(12);
expect(toInt('')).toBe(0);
expect(toInt(undefined)).toBe(0);
expect(toInt(null)).toBe(0);
});
it('getThumbRatio is 1 when viewport >= content', () => {
expect(getThumbRatio(100, 100)).toBe(1);
expect(getThumbRatio(200, 100)).toBe(1);
expect(getThumbRatio(0, 0)).toBe(1);
});
it('getThumbRatio is fraction when content exceeds viewport', () => {
expect(getThumbRatio(100, 200)).toBe(0.5);
expect(getThumbRatio(50, 200)).toBe(0.25);
});
it('getThumbSize enforces 18px minimum', () => {
const sizes = {
content: 100000,
viewport: 100,
scrollbar: { size: 100, paddingStart: 0, paddingEnd: 0 },
};
expect(getThumbSize(sizes)).toBe(18);
});
it('getThumbSize scales with ratio', () => {
const sizes = {
content: 200,
viewport: 100,
scrollbar: { size: 100, paddingStart: 0, paddingEnd: 0 },
};
expect(getThumbSize(sizes)).toBe(50);
});
it('getThumbOffsetFromScroll maps 0 → 0 and max → maxThumbPos (LTR)', () => {
const sizes = {
content: 200,
viewport: 100,
scrollbar: { size: 100, paddingStart: 0, paddingEnd: 0 },
};
expect(getThumbOffsetFromScroll(0, sizes)).toBe(0);
expect(getThumbOffsetFromScroll(100, sizes)).toBe(50);
expect(getThumbOffsetFromScroll(50, sizes)).toBe(25);
});
it('getThumbOffsetFromScroll handles RTL negative scroll', () => {
const sizes = {
content: 200,
viewport: 100,
scrollbar: { size: 100, paddingStart: 0, paddingEnd: 0 },
};
expect(getThumbOffsetFromScroll(0, sizes, 'rtl')).toBe(50);
expect(getThumbOffsetFromScroll(-100, sizes, 'rtl')).toBe(0);
});
it('getScrollPositionFromPointer maps min/max pointer to scroll range', () => {
const sizes = {
content: 200,
viewport: 100,
scrollbar: { size: 100, paddingStart: 0, paddingEnd: 0 },
};
expect(getScrollPositionFromPointer(25, 25, sizes)).toBe(0);
expect(getScrollPositionFromPointer(75, 25, sizes)).toBe(100);
expect(getScrollPositionFromPointer(50, 25, sizes)).toBe(50);
});
it('isScrollingWithinScrollbarBounds detects intermediate scroll positions', () => {
expect(isScrollingWithinScrollbarBounds(0, 100)).toBe(false);
expect(isScrollingWithinScrollbarBounds(100, 100)).toBe(false);
expect(isScrollingWithinScrollbarBounds(50, 100)).toBe(true);
});
});
describe('scrollArea components', () => {
it('renders root with viewport and scrollbars', async () => {
const w = track(mount(makeApp({ type: 'always' }), { attachTo: document.body }));
await nextTick();
expect(w.find('[data-scroll-area-viewport]').exists()).toBe(true);
expect(w.findAll('[data-orientation="vertical"]').length).toBeGreaterThan(0);
expect(w.findAll('[data-orientation="horizontal"]').length).toBeGreaterThan(0);
});
it('viewport hides native scrollbars via injected stylesheet', () => {
const w = track(mount(makeApp({ type: 'always' }), { attachTo: document.body }));
expect(w.html()).toContain('-webkit-scrollbar');
});
it('honours `dir` prop', () => {
const w = track(mount(makeApp({ dir: 'rtl' }), { attachTo: document.body }));
expect(w.find('[dir="rtl"]').exists()).toBe(true);
});
it('forwards `as` to Primitive', () => {
const w = track(mount(makeApp({ as: 'section' }), { attachTo: document.body }));
expect(w.find('section').exists()).toBe(true);
});
it('hover mode keeps scrollbar mounted while pointer is over root', async () => {
const w = track(mount(makeApp({ type: 'hover', scrollHideDelay: 1 }), { attachTo: document.body }));
await nextTick();
const root = w.find('[dir]').element as HTMLElement;
root.dispatchEvent(new PointerEvent('pointerenter'));
await nextTick();
expect(w.findAll('[data-state="visible"]').length).toBeGreaterThan(0);
});
it('exposes scrollTop / scrollTopLeft', async () => {
const w = track(mount(makeApp({ type: 'always' }), { attachTo: document.body }));
await nextTick();
const root = (w.vm as any).rootRef;
expect(typeof root.scrollTop).toBe('function');
expect(typeof root.scrollTopLeft).toBe('function');
root.scrollTop();
root.scrollTopLeft();
});
});

Some files were not shown because too many files have changed in this diff Show More