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.
@@ -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%');
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
@@ -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();
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -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');
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
@@ -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 (1–40) to consider. Default `1`. */
|
||||
minVersion?: number;
|
||||
/** Largest QR version (1–40) to consider. Default `40`. */
|
||||
maxVersion?: number;
|
||||
/** Mask pattern (0–7), 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();
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 17 KiB |
@@ -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 (21–177). */
|
||||
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;
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||