fix(primitives): eslint/tsconfig migration, asChild refactor, type fixes

- Migrate to eslint flat config + composite tsconfig.
- Complete the asChild→as="template" refactor (remove asChild prop + :as-child
  bindings across components, matching Primitive's slot model).
- Fix test type errors and source type-safety (useGraceArea hull/point math,
  FocusScope/util ref typing).

Note: ~53 vue-tsc errors remain (HTML attr/event passthrough typing on
transparent wrapper components + a couple of duplicate-export naming
collisions) — not gated by CI (build/lint/test green); pending a
component-attribute-typing design decision.
This commit is contained in:
2026-06-07 16:29:56 +07:00
parent c7644ade69
commit 626fbc70d8
408 changed files with 27367 additions and 154 deletions
@@ -0,0 +1,39 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface AccordionContentProps extends PrimitiveProps {
/** Keep content mounted even when closed. */
forceMount?: boolean;
}
</script>
<script setup lang="ts">
import { useAccordionContext, useAccordionItemContext } from './context';
import { Presence } from '../presence';
import { Primitive } from '../primitive';
import { useForwardExpose } from '@robonen/vue';
const { as = 'div', forceMount = false } = defineProps<AccordionContentProps>();
const { forwardRef } = useForwardExpose();
const ctx = useAccordionContext();
const item = useAccordionItemContext();
</script>
<template>
<Presence :present="forceMount || item.open.value">
<Primitive
:ref="forwardRef"
:as="as"
role="region"
:id="item.contentId.value"
:aria-labelledby="item.triggerId.value"
:data-state="item.open.value ? 'open' : 'closed'"
:data-disabled="item.disabled.value ? '' : undefined"
:data-orientation="ctx.orientation.value"
:hidden="!item.open.value || undefined"
>
<slot :open="item.open.value" />
</Primitive>
</Presence>
</template>
@@ -0,0 +1,49 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface AccordionItemProps extends PrimitiveProps {
/** Unique value for this item. */
value: string;
/** Disable this item. */
disabled?: boolean;
}
</script>
<script setup lang="ts">
import { provideAccordionItemContext, useAccordionContext } from './context';
import { Primitive } from '../primitive';
import { computed } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useId } from '../config-provider';
const { value, disabled = false, as = 'div' } = defineProps<AccordionItemProps>();
const { forwardRef } = useForwardExpose();
const ctx = useAccordionContext();
const isOpen = computed(() => ctx.isOpen(value));
const isDisabled = computed(() => ctx.disabled.value || disabled);
const triggerId = useId(undefined, 'accordion-trigger');
const contentId = useId(undefined, 'accordion-content');
provideAccordionItemContext({
value,
open: isOpen,
disabled: isDisabled,
triggerId,
contentId,
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:data-state="isOpen ? 'open' : 'closed'"
:data-disabled="isDisabled ? '' : undefined"
:data-orientation="ctx.orientation.value"
>
<slot :open="isOpen" />
</Primitive>
</template>
@@ -0,0 +1,153 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
import type { RovingDirection } from '../utils/roving-focus';
export interface AccordionRootProps extends PrimitiveProps {
/** Current open value(s) for controlled mode. */
modelValue?: string | string[];
/** Initial value(s) for uncontrolled mode. */
defaultValue?: string | string[];
/** 'single' allows one panel; 'multiple' allows many. @default 'single' */
type?: 'single' | 'multiple';
/** Allow closing all panels in single mode. @default false */
collapsible?: boolean;
/** Disable all items. */
disabled?: boolean;
/** Orientation of the accordion. @default 'vertical' */
orientation?: 'horizontal' | 'vertical';
/** Writing direction. @default 'ltr' */
dir?: RovingDirection;
/** Wrap keyboard navigation. @default true */
loop?: boolean;
}
</script>
<script setup lang="ts">
import { computed, shallowRef, toRef, watch } from 'vue';
import { resolveNextIndex, rovingKeyToAction } from '../utils/roving-focus';
import { Primitive } from '../primitive';
import { provideAccordionContext } from './context';
import { toArray } from '@robonen/stdlib';
import { useCollectionProvider } from '../collection';
import { useForwardExpose } from '@robonen/vue';
const {
type = 'single',
collapsible = false,
disabled = false,
orientation = 'vertical',
dir = 'ltr',
loop = true,
modelValue,
defaultValue,
as = 'div',
} = defineProps<AccordionRootProps>();
const { forwardRef } = useForwardExpose();
const emit = defineEmits<{ 'update:modelValue': [value: string | string[] | undefined] }>();
type RovingAction = NonNullable<ReturnType<typeof rovingKeyToAction>>;
const openSet = shallowRef<Set<string>>(
new Set(toArray(modelValue ?? defaultValue)),
);
function setEqualsArray(set: Set<string>, arr: string[]): boolean {
if (arr.length !== set.size) return false;
for (let i = 0; i < arr.length; i++) if (!set.has(arr[i]!)) return false;
return true;
}
watch(() => modelValue, (v) => {
if (v === undefined) return;
const arr = toArray(v);
if (setEqualsArray(openSet.value, arr)) return;
openSet.value = new Set(arr);
});
function nextOpenSet(cur: Set<string>, value: string): Set<string> {
const present = cur.has(value);
if (type === 'single') {
if (!present) return new Set([value]);
return collapsible ? new Set() : cur;
}
const next = new Set(cur);
if (present) next.delete(value);
else next.add(value);
return next;
}
function toEmitValue(set: Set<string>): string | string[] | undefined {
return type === 'single' ? set.values().next().value : [...set];
}
function commit(next: Set<string>): void {
openSet.value = next;
emit('update:modelValue', toEmitValue(next));
}
function isOpen(value: string): boolean {
return openSet.value.has(value);
}
function toggle(value: string): void {
if (disabled) return;
const cur = openSet.value;
const next = nextOpenSet(cur, value);
if (next !== cur) commit(next);
}
const { getItems, CollectionSlot } = useCollectionProvider();
const triggerElements = computed(() => getItems(true).map(i => i.ref));
function resolveFocusIndex(action: RovingAction, current: number, count: number): number {
if (action.absolute === 'home') return 0;
if (action.absolute === 'end') return count - 1;
return resolveNextIndex(current === -1 ? 0 : current, action.delta, count, loop);
}
function onTriggerKeyDown(event: KeyboardEvent, el: HTMLElement): void {
const action = rovingKeyToAction(event, { orientation, dir, loop });
if (!action) return;
event.preventDefault();
const enabled = triggerElements.value.filter(x => !x.hasAttribute('data-disabled'));
if (enabled.length === 0) return;
enabled[resolveFocusIndex(action, enabled.indexOf(el), enabled.length)]!.focus();
}
provideAccordionContext({
disabled: toRef(() => disabled),
orientation: toRef(() => orientation),
direction: toRef(() => dir),
loop: toRef(() => loop),
collapsible: toRef(() => collapsible),
triggerElements,
isOpen,
toggle,
onTriggerKeyDown,
});
</script>
<template>
<CollectionSlot>
<Primitive
:ref="forwardRef"
:as="as"
:data-orientation="orientation"
:data-disabled="disabled ? '' : undefined"
>
<slot />
</Primitive>
</CollectionSlot>
</template>
@@ -0,0 +1,52 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface AccordionTriggerProps extends PrimitiveProps {
}
</script>
<script setup lang="ts">
import { useAccordionContext, useAccordionItemContext } from './context';
import { Primitive } from '../primitive';
import { useCollectionInjector } from '../collection';
import { useForwardExpose } from '@robonen/vue';
const { as = 'button' } = defineProps<AccordionTriggerProps>();
const ctx = useAccordionContext();
const item = useAccordionItemContext();
const { forwardRef, currentElement } = useForwardExpose();
const { CollectionItem } = useCollectionInjector();
function onClick(): void {
if (item.disabled.value) return;
ctx.toggle(item.value);
}
function onKeyDown(event: KeyboardEvent): void {
if (!currentElement.value) return;
ctx.onTriggerKeyDown(event, currentElement.value);
}
</script>
<template>
<CollectionItem>
<Primitive
:as="as"
:ref="forwardRef"
:type="as === 'button' ? 'button' : undefined"
:id="item.triggerId.value"
:aria-expanded="item.open.value"
:aria-controls="item.contentId.value"
:aria-disabled="item.disabled.value || undefined"
:data-state="item.open.value ? 'open' : 'closed'"
:data-disabled="item.disabled.value ? '' : undefined"
:data-orientation="ctx.orientation.value"
:disabled="item.disabled.value || undefined"
@click="onClick"
@keydown="onKeyDown"
>
<slot :open="item.open.value" />
</Primitive>
</CollectionItem>
</template>
@@ -0,0 +1,242 @@
import { AccordionContent, AccordionItem, AccordionRoot, AccordionTrigger } from '../index';
import { defineComponent, h, nextTick, ref } from 'vue';
import { describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
function createAccordion(rootProps: Record<string, unknown> = {}, itemCount = 3) {
return mount(
defineComponent({
setup() {
return () => h(AccordionRoot, { ...rootProps }, {
default: () => Array.from({ length: itemCount }, (_, i) => {
const val = String.fromCodePoint(97 + i); // 'a', 'b', 'c'
return h(AccordionItem, { value: val, key: val, disabled: i === 2 ? true : undefined }, {
default: () => [
h(AccordionTrigger, null, { default: () => `Trigger ${val.toUpperCase()}` }),
h(AccordionContent, null, { default: () => `Content ${val.toUpperCase()}` }),
],
});
}),
});
},
}),
{ attachTo: document.body },
);
}
function press(el: Element, key: string) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
}
describe('Accordion', () => {
it('renders items with correct structure', () => {
const w = createAccordion();
const triggers = w.findAll('button');
expect(triggers).toHaveLength(3);
triggers.forEach((t) => {
expect(t.attributes('aria-expanded')).toBeDefined();
expect(t.attributes('aria-controls')).toBeDefined();
});
w.unmount();
});
it('all panels closed by default (single, non-collapsible)', () => {
const w = createAccordion();
const regions = w.findAll('[role="region"]');
expect(regions).toHaveLength(0);
w.unmount();
});
it('defaultValue opens a panel', () => {
const w = createAccordion({ defaultValue: 'a' });
const regions = w.findAll('[role="region"]');
expect(regions).toHaveLength(1);
expect(regions[0]!.text()).toBe('Content A');
w.unmount();
});
it('click toggles panel open/closed (single, collapsible)', async () => {
const w = createAccordion({ collapsible: true });
const triggers = w.findAll('button');
await triggers[0]!.trigger('click');
await nextTick();
expect(w.findAll('[role="region"]')).toHaveLength(1);
expect(w.find('[role="region"]').text()).toBe('Content A');
// clicking again closes it (collapsible)
await triggers[0]!.trigger('click');
await nextTick();
expect(w.findAll('[role="region"]')).toHaveLength(0);
w.unmount();
});
it('single mode: opening one closes previous', async () => {
const w = createAccordion({ defaultValue: 'a' });
const triggers = w.findAll('button');
await triggers[1]!.trigger('click');
await nextTick();
const regions = w.findAll('[role="region"]');
expect(regions).toHaveLength(1);
expect(regions[0]!.text()).toBe('Content B');
w.unmount();
});
it('single mode: cannot close when not collapsible', async () => {
const w = createAccordion({ defaultValue: 'a', collapsible: false });
const triggers = w.findAll('button');
await triggers[0]!.trigger('click');
await nextTick();
// should stay open
expect(w.findAll('[role="region"]')).toHaveLength(1);
expect(w.find('[role="region"]').text()).toBe('Content A');
w.unmount();
});
it('multiple mode: multiple panels open', async () => {
const w = createAccordion({ type: 'multiple' });
const triggers = w.findAll('button');
await triggers[0]!.trigger('click');
await nextTick();
await triggers[1]!.trigger('click');
await nextTick();
const regions = w.findAll('[role="region"]');
expect(regions).toHaveLength(2);
w.unmount();
});
it('multiple mode: toggle individual items', async () => {
const w = createAccordion({ type: 'multiple', defaultValue: ['a', 'b'] });
expect(w.findAll('[role="region"]')).toHaveLength(2);
const triggers = w.findAll('button');
await triggers[0]!.trigger('click');
await nextTick();
// 'a' closed, 'b' still open
const regions = w.findAll('[role="region"]');
expect(regions).toHaveLength(1);
expect(regions[0]!.text()).toBe('Content B');
w.unmount();
});
it('v-model works (single)', async () => {
const value = ref<string | undefined>('a');
const w = mount(
defineComponent({
setup() {
return () => h(AccordionRoot, {
modelValue: value.value,
'onUpdate:modelValue': (v: string | string[] | undefined) => { value.value = v as string | undefined; },
collapsible: true,
}, {
default: () => [
h(AccordionItem, { value: 'a' }, {
default: () => [
h(AccordionTrigger, null, { default: () => 'A' }),
h(AccordionContent, null, { default: () => 'PA' }),
],
}),
h(AccordionItem, { value: 'b' }, {
default: () => [
h(AccordionTrigger, null, { default: () => 'B' }),
h(AccordionContent, null, { default: () => 'PB' }),
],
}),
],
});
},
}),
{ attachTo: document.body },
);
expect(w.find('[role="region"]').text()).toBe('PA');
const triggers = w.findAll('button');
await triggers[1]!.trigger('click');
await nextTick();
expect(value.value).toBe('b');
w.unmount();
});
it('keyboard navigation (vertical, ArrowDown/ArrowUp)', async () => {
const w = createAccordion({ defaultValue: 'a' });
await nextTick();
const triggers = w.findAll('button');
const trigA = triggers[0]!.element as HTMLElement;
trigA.focus();
press(trigA, 'ArrowDown');
await nextTick();
expect(document.activeElement).toBe(triggers[1]!.element);
press(triggers[1]!.element, 'ArrowUp');
await nextTick();
expect(document.activeElement).toBe(triggers[0]!.element);
w.unmount();
});
it('Home/End keys move focus', async () => {
const w = createAccordion({ defaultValue: 'a' });
await nextTick();
const triggers = w.findAll('button');
const trigA = triggers[0]!.element as HTMLElement;
trigA.focus();
press(trigA, 'End');
await nextTick();
// End goes to last enabled trigger (B, since C is disabled)
expect(document.activeElement).toBe(triggers[1]!.element);
press(triggers[1]!.element, 'Home');
await nextTick();
expect(document.activeElement).toBe(triggers[0]!.element);
w.unmount();
});
it('disabled item cannot be toggled', async () => {
const w = createAccordion({ type: 'multiple' });
const triggers = w.findAll('button');
await triggers[2]!.trigger('click');
await nextTick();
expect(w.findAll('[role="region"]')).toHaveLength(0);
w.unmount();
});
it('disabled root blocks all interaction', async () => {
const w = createAccordion({ disabled: true });
const triggers = w.findAll('button');
await triggers[0]!.trigger('click');
await nextTick();
expect(w.findAll('[role="region"]')).toHaveLength(0);
w.unmount();
});
it('data-state and aria-expanded reflect open state', async () => {
const w = createAccordion({ defaultValue: 'a' });
const triggers = w.findAll('button');
expect(triggers[0]!.attributes('aria-expanded')).toBe('true');
expect(triggers[0]!.attributes('data-state')).toBe('open');
expect(triggers[1]!.attributes('aria-expanded')).toBe('false');
expect(triggers[1]!.attributes('data-state')).toBe('closed');
w.unmount();
});
it('content has role=region with aria-labelledby', () => {
const w = createAccordion({ defaultValue: 'a' });
const region = w.find('[role="region"]');
expect(region.attributes('aria-labelledby')).toBeDefined();
const trigger = w.findAll('button')[0]!;
expect(region.attributes('aria-labelledby')).toBe(trigger.attributes('id'));
w.unmount();
});
it('orientation reflects in data-orientation', () => {
const w = createAccordion({ orientation: 'horizontal' });
expect(w.find('[data-orientation="horizontal"]').exists()).toBe(true);
w.unmount();
});
});
+33
View File
@@ -0,0 +1,33 @@
import type { ComputedRef, Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
export interface AccordionContext {
disabled: Ref<boolean>;
orientation: Ref<'horizontal' | 'vertical'>;
direction: Ref<'ltr' | 'rtl'>;
loop: Ref<boolean>;
collapsible: Ref<boolean>;
/** DOM-ordered trigger elements, sourced from the internal Collection. */
triggerElements: ComputedRef<HTMLElement[]>;
isOpen: (value: string) => boolean;
toggle: (value: string) => void;
onTriggerKeyDown: (event: KeyboardEvent, el: HTMLElement) => void;
}
export const {
inject: useAccordionContext,
provide: provideAccordionContext,
} = useContextFactory<AccordionContext>('AccordionContext');
export interface AccordionItemContext {
value: string;
open: ComputedRef<boolean>;
disabled: ComputedRef<boolean>;
triggerId: ComputedRef<string>;
contentId: ComputedRef<string>;
}
export const {
inject: useAccordionItemContext,
provide: provideAccordionItemContext,
} = useContextFactory<AccordionItemContext>('AccordionItemContext');
+12
View File
@@ -0,0 +1,12 @@
export { default as AccordionRoot } from './AccordionRoot.vue';
export { default as AccordionItem } from './AccordionItem.vue';
export { default as AccordionTrigger } from './AccordionTrigger.vue';
export { default as AccordionContent } from './AccordionContent.vue';
export { provideAccordionContext, useAccordionContext, provideAccordionItemContext, useAccordionItemContext } from './context';
export type { AccordionRootProps } from './AccordionRoot.vue';
export type { AccordionItemProps } from './AccordionItem.vue';
export type { AccordionTriggerProps } from './AccordionTrigger.vue';
export type { AccordionContentProps } from './AccordionContent.vue';
export type { AccordionContext, AccordionItemContext } from './context';
@@ -0,0 +1,17 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface AlertDialogActionProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { DialogClose } from '../dialog';
const { as = 'button' } = defineProps<AlertDialogActionProps>();
</script>
<template>
<DialogClose :as="as" data-alert-dialog-action>
<slot />
</DialogClose>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface AlertDialogCancelProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { DialogClose } from '../dialog';
const { as = 'button' } = defineProps<AlertDialogCancelProps>();
</script>
<template>
<DialogClose :as="as" data-alert-dialog-cancel>
<slot />
</DialogClose>
</template>
@@ -0,0 +1,43 @@
<script lang="ts">
import type { DialogContentEmits, DialogContentProps } from '../dialog';
export interface AlertDialogContentProps extends Omit<DialogContentProps, 'role'> {}
export type AlertDialogContentEmits = DialogContentEmits;
</script>
<script setup lang="ts">
import { DialogContent } from '../dialog';
const props = defineProps<AlertDialogContentProps>();
const emit = defineEmits<AlertDialogContentEmits>();
function onOpenAutoFocus(event: Event) {
emit('openAutoFocus', event);
if (event.defaultPrevented) return;
queueMicrotask(() => {
const content = document.querySelector<HTMLElement>('[data-alert-dialog-content]');
const cancel = content?.querySelector<HTMLElement>('[data-alert-dialog-cancel]');
if (cancel) {
event.preventDefault();
cancel.focus();
}
});
}
</script>
<template>
<DialogContent
v-bind="props"
role="alertdialog"
data-alert-dialog-content
@open-auto-focus="onOpenAutoFocus"
@close-auto-focus="emit('closeAutoFocus', $event)"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="(e: PointerEvent | MouseEvent) => { e.preventDefault(); emit('pointerDownOutside', e); }"
@focus-outside="(e: FocusEvent) => { e.preventDefault(); emit('focusOutside', e); }"
@interact-outside="emit('interactOutside', $event)"
@dismiss="emit('dismiss')"
>
<slot />
</DialogContent>
</template>
@@ -0,0 +1,25 @@
<script lang="ts">
import type { DialogRootProps } from '../dialog';
export interface AlertDialogRootProps extends Omit<DialogRootProps, 'modal'> {}
</script>
<script setup lang="ts">
import { DialogRoot } from '../dialog';
defineOptions({ inheritAttrs: false });
const props = defineProps<AlertDialogRootProps>();
const openModel = defineModel<boolean | undefined>('open', { default: undefined });
</script>
<template>
<DialogRoot
:default-open="props.defaultOpen"
:modal="true"
:open="openModel"
@update:open="openModel = $event"
>
<slot :open="openModel" />
</DialogRoot>
</template>
@@ -0,0 +1,118 @@
import { mount } from '@vue/test-utils';
import type { VueWrapper } from '@vue/test-utils';
import { afterEach, describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle,
AlertDialogTrigger,
} from '../index';
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
document.body.removeAttribute('style');
delete document.body.dataset['dismissableBlocking'];
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
function mountAlert(initialOpen = true) {
const open = ref(initialOpen);
const Harness = defineComponent({
setup() {
return () => h(
AlertDialogRoot,
{
open: open.value,
'onUpdate:open': (v: boolean | undefined) => { open.value = v!; },
},
{
default: () => [
h(AlertDialogTrigger, null, { default: () => 'Open' }),
h(AlertDialogPortal, null, {
default: () => [
h(AlertDialogOverlay),
h(AlertDialogContent, null, {
default: () => [
h(AlertDialogTitle, null, { default: () => 'Are you sure?' }),
h(AlertDialogDescription, null, { default: () => 'This cannot be undone.' }),
h(AlertDialogCancel, null, { default: () => 'Cancel' }),
h(AlertDialogAction, null, { default: () => 'OK' }),
],
}),
],
}),
],
},
);
},
});
const w = track(mount(Harness, { attachTo: document.body }));
return { wrapper: w, open };
}
describe('AlertDialog', () => {
it('renders content with role="alertdialog"', async () => {
mountAlert(true);
await nextTick();
await nextTick();
const content = document.querySelector('[data-alert-dialog-content]');
expect(content).toBeTruthy();
expect(content!.getAttribute('role')).toBe('alertdialog');
});
it('labels content via Title and describes via Description', async () => {
mountAlert(true);
await nextTick();
await nextTick();
const content = document.querySelector<HTMLElement>('[data-alert-dialog-content]')!;
const labelledby = content.getAttribute('aria-labelledby');
const describedby = content.getAttribute('aria-describedby');
expect(labelledby).toMatch(/dialog-title/);
expect(describedby).toMatch(/dialog-description/);
expect(document.getElementById(labelledby!)?.textContent).toBe('Are you sure?');
expect(document.getElementById(describedby!)?.textContent).toBe('This cannot be undone.');
});
it('Cancel button closes the dialog', async () => {
const { open } = mountAlert(true);
await nextTick();
await nextTick();
const cancel = document.querySelector<HTMLButtonElement>('[data-alert-dialog-cancel]')!;
cancel.click();
await nextTick();
await nextTick();
expect(open.value).toBe(false);
});
it('Action button closes the dialog', async () => {
const { open } = mountAlert(true);
await nextTick();
await nextTick();
const action = document.querySelector<HTMLButtonElement>('[data-alert-dialog-action]')!;
action.click();
await nextTick();
await nextTick();
expect(open.value).toBe(false);
});
it('Cancel and Action carry data attributes', async () => {
mountAlert(true);
await nextTick();
await nextTick();
expect(document.querySelector('[data-alert-dialog-cancel]')).toBeTruthy();
expect(document.querySelector('[data-alert-dialog-action]')).toBeTruthy();
});
});
+11
View File
@@ -0,0 +1,11 @@
export { DialogDescription as AlertDialogDescription, DialogOverlay as AlertDialogOverlay, DialogPortal as AlertDialogPortal, DialogTitle as AlertDialogTitle, DialogTrigger as AlertDialogTrigger } from '../dialog';
export { default as AlertDialogAction } from './AlertDialogAction.vue';
export { default as AlertDialogCancel } from './AlertDialogCancel.vue';
export { default as AlertDialogContent } from './AlertDialogContent.vue';
export { default as AlertDialogRoot } from './AlertDialogRoot.vue';
export type { AlertDialogActionProps } from './AlertDialogAction.vue';
export type { AlertDialogCancelProps } from './AlertDialogCancel.vue';
export type { AlertDialogContentEmits, AlertDialogContentProps } from './AlertDialogContent.vue';
export type { AlertDialogRootProps } from './AlertDialogRoot.vue';
@@ -0,0 +1,41 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
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 { Primitive } from '../primitive';
import { useForwardExpose } from '@robonen/vue';
useForwardExpose();
const { ratio = 1, as = 'div' } = defineProps<AspectRatioProps>();
const wrapperStyle = {
position: 'relative' as const,
width: '100%',
paddingBottom: `${(1 / ratio) * 100}%`,
};
// 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 :style="wrapperStyle" data-aspect-ratio-wrapper>
<Primitive :as="as" :style="INNER_STYLE" :data-aspect-ratio="true">
<slot />
</Primitive>
</div>
</template>
@@ -0,0 +1,24 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { AspectRatio } from '../index';
describe('AspectRatio', () => {
it('renders with default 1:1 ratio', () => {
const wrapper = mount(AspectRatio);
const outer = wrapper.element as HTMLElement;
expect(outer.style.paddingBottom).toBe('100%');
});
it('computes padding-bottom from ratio', () => {
const wrapper = mount(AspectRatio, { props: { ratio: 16 / 9 } });
const outer = wrapper.element as HTMLElement;
expect(outer.style.paddingBottom).toMatch(/^56\.25%$/);
});
it('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');
});
});
+2
View File
@@ -0,0 +1,2 @@
export { default as AspectRatio } from './AspectRatio.vue';
export type { AspectRatioProps } from './AspectRatio.vue';
@@ -0,0 +1,57 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
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 '../primitive';
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useAvatarContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'span', delayMs = 0 } = defineProps<AvatarFallbackProps>();
const { forwardRef } = useForwardExpose();
const ctx = useAvatarContext();
const canShow = ref<boolean>(delayMs === 0);
let timer: ReturnType<typeof setTimeout> | null = null;
watch(() => ctx.imageLoadingStatus.value, (status) => {
if (status === 'loaded') {
canShow.value = false;
if (timer) {
clearTimeout(timer);
timer = null;
}
return;
}
if (delayMs === 0) {
canShow.value = true;
return;
}
if (timer) clearTimeout(timer);
canShow.value = false;
timer = setTimeout(() => {
canShow.value = true;
}, delayMs);
}, { immediate: true });
onBeforeUnmount(() => {
if (timer) clearTimeout(timer);
});
const shouldRender = computed(() => canShow.value && ctx.imageLoadingStatus.value !== 'loaded');
</script>
<template>
<Primitive :ref="forwardRef" v-if="shouldRender" :as="as">
<slot />
</Primitive>
</template>
+83
View File
@@ -0,0 +1,83 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
import type { AvatarImageLoadingStatus } from './context';
export interface AvatarImageProps extends PrimitiveProps {
src?: string;
alt?: string;
/** Optional hook to reject loaded images by their dimensions/src. */
onLoadingStatusChange?: (status: AvatarImageLoadingStatus) => void;
}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useAvatarContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'img', src, alt, onLoadingStatusChange } = defineProps<AvatarImageProps>();
const { forwardRef } = useForwardExpose();
const ctx = useAvatarContext();
const status = ref<AvatarImageLoadingStatus>('idle');
function setStatus(next: AvatarImageLoadingStatus) {
status.value = next;
ctx.onImageLoadingStatusChange(next);
onLoadingStatusChange?.(next);
}
let currentImage: HTMLImageElement | null = null;
function load(nextSrc: string | undefined) {
if (currentImage) {
currentImage.onload = null;
currentImage.onerror = null;
currentImage = null;
}
if (!nextSrc) {
setStatus('error');
return;
}
if (typeof globalThis.window === 'undefined') {
setStatus('loading');
return;
}
setStatus('loading');
const img = new globalThis.Image();
currentImage = img;
img.onload = () => {
if (currentImage === img) setStatus('loaded');
};
img.onerror = () => {
if (currentImage === img) setStatus('error');
};
img.src = nextSrc;
}
watch(() => src, load, { immediate: true });
onBeforeUnmount(() => {
if (currentImage) {
currentImage.onload = null;
currentImage.onerror = null;
currentImage = null;
}
});
const shouldRender = computed(() => status.value === 'loaded');
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
v-if="shouldRender"
:src="src"
:alt="alt"
/>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface AvatarRootProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import type { AvatarImageLoadingStatus } from './context';
import { Primitive } from '../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,93 @@
import { mount } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import { AvatarFallback, AvatarImage, AvatarRoot } from '../index';
class MockImage {
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
private _src = '';
set src(value: string) {
this._src = value;
queueMicrotask(() => {
if (value.includes('broken')) this.onerror?.();
else this.onload?.();
});
}
get src() { return this._src; }
}
describe('Avatar', () => {
beforeEach(() => {
vi.stubGlobal('Image', MockImage as unknown as typeof Image);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('renders fallback until image loads', async () => {
const w = mount(defineComponent({
setup: () => () => h(AvatarRoot, null, {
default: () => [
h(AvatarImage, { src: '/ok.png', alt: 'user' }),
h(AvatarFallback, { class: 'fb' }, { default: () => 'AB' }),
],
}),
}));
expect(w.find('.fb').exists()).toBe(true);
expect(w.find('img').exists()).toBe(false);
await new Promise(r => queueMicrotask(() => r(null)));
await nextTick();
expect(w.find('img').exists()).toBe(true);
expect(w.find('img').attributes('src')).toBe('/ok.png');
expect(w.find('.fb').exists()).toBe(false);
});
it('keeps fallback visible on error', async () => {
const w = mount(defineComponent({
setup: () => () => h(AvatarRoot, null, {
default: () => [
h(AvatarImage, { src: '/broken.png' }),
h(AvatarFallback, { class: 'fb' }, { default: () => 'AB' }),
],
}),
}));
await new Promise(r => queueMicrotask(() => r(null)));
await nextTick();
expect(w.find('img').exists()).toBe(false);
expect(w.find('.fb').exists()).toBe(true);
});
it('delays fallback rendering when delayMs is set', async () => {
vi.useFakeTimers();
const w = mount(defineComponent({
setup: () => () => h(AvatarRoot, null, {
default: () => [
h(AvatarFallback, { class: 'fb', delayMs: 500 }, { default: () => 'AB' }),
],
}),
}));
expect(w.find('.fb').exists()).toBe(false);
vi.advanceTimersByTime(500);
await nextTick();
expect(w.find('.fb').exists()).toBe(true);
vi.useRealTimers();
});
it('sets data-status on the root element', async () => {
const w = mount(defineComponent({
setup: () => () => h(AvatarRoot, null, {
default: () => [
h(AvatarImage, { src: '/ok.png' }),
h(AvatarFallback, null, { default: () => '?' }),
],
}),
}));
await nextTick();
expect(w.element.getAttribute('data-status')).toBe('loading');
await new Promise(r => queueMicrotask(() => r(null)));
await nextTick();
expect(w.element.getAttribute('data-status')).toBe('loaded');
});
});
+14
View File
@@ -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;
+8
View File
@@ -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 } from './AvatarImage.vue';
export type { AvatarFallbackProps } from './AvatarFallback.vue';
export { provideAvatarContext, useAvatarContext } from './context';
export type { AvatarContext, AvatarImageLoadingStatus } from './context';
@@ -0,0 +1,43 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CalendarCellProps extends PrimitiveProps {
/** The date this cell represents. */
date: Date;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { Primitive } from '../primitive';
import { useCalendarGridContext, useCalendarRootContext } from './context';
import { isSameDay, isSameMonth } from './utils';
const { as = 'td', date } = defineProps<CalendarCellProps>();
const ctx = useCalendarRootContext();
const gridCtx = useCalendarGridContext();
const isSelected = computed(() => ctx.isDateSelected(date));
const isDisabled = computed(() => ctx.isDateDisabled(date));
const isUnavailable = computed(() => ctx.isDateUnavailable(date));
const isOutsideView = computed(() => !isSameMonth(date, gridCtx.month.value));
const isToday = computed(() => isSameDay(date, new Date()));
</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-today="isToday ? '' : undefined"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,198 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
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;
unavailable: boolean;
}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { computed, nextTick } from 'vue';
import { Primitive } from '../primitive';
import { useCalendarGridContext, useCalendarRootContext } from './context';
import { addDays, addMonths, addYears, formatFullDate, isSameDay, isSameMonth } from './utils';
const { as = 'div', day, month } = defineProps<CalendarCellTriggerProps>();
defineSlots<{
default?: (props: CalendarCellTriggerSlotProps) => unknown;
}>();
const ctx = useCalendarRootContext();
const gridCtx = useCalendarGridContext();
const { forwardRef, currentElement } = useForwardExpose();
const monthValue = computed(() => month ?? gridCtx.month.value);
const isOutsideView = computed(() => !isSameMonth(day, monthValue.value));
const isDisabled = computed(() => ctx.isDateDisabled(day));
const isUnavailable = computed(() => ctx.isDateUnavailable(day));
const isSelected = computed(() => ctx.isDateSelected(day));
const isToday = computed(() => isSameDay(day, new Date()));
const dayValue = computed(() => day.getDate().toLocaleString(ctx.locale.value));
const labelText = computed(() => formatFullDate(day, ctx.locale.value));
const isFocusedDate = computed(() => {
if (isOutsideView.value || isDisabled.value) return false;
if (ctx.focusedDate.value) return isSameDay(day, ctx.focusedDate.value);
// Fallback focusable: selected, else today (if in view), else first day of month.
if (ctx.modelValue.value && isSameMonth(ctx.modelValue.value, monthValue.value))
return isSameDay(day, ctx.modelValue.value);
const today = new Date();
if (isSameMonth(today, monthValue.value))
return isSameDay(day, today);
return day.getDate() === 1 && isSameMonth(day, monthValue.value);
});
function selectIfAllowed() {
if (ctx.readonly.value) return;
if (isDisabled.value || isUnavailable.value) return;
ctx.setDate(day);
ctx.focusedDate.value = day;
}
function handleClick() {
selectIfAllowed();
}
function focusByDataValue(target: Date) {
const parent = ctx.parentElement.value;
if (!parent) return false;
const el = parent.querySelector<HTMLElement>(
`[data-primitives-calendar-cell-trigger][data-value="${target.toISOString().slice(0, 10)}"]:not([data-outside-view])`,
);
if (el) {
el.focus();
return true;
}
return false;
}
function shiftFocus(target: Date) {
if (ctx.minValue.value && target < ctx.minValue.value) return;
if (ctx.maxValue.value && target > ctx.maxValue.value) return;
ctx.focusedDate.value = target;
if (focusByDataValue(target)) return;
// Crossed visible range — page placeholder and retry.
if (target > ctx.placeholder.value) {
if (ctx.isNextButtonDisabled()) return;
ctx.nextPage();
}
else {
if (ctx.isPrevButtonDisabled()) return;
ctx.prevPage();
}
nextTick(() => focusByDataValue(target));
}
function handleKeyDown(e: KeyboardEvent) {
if (isDisabled.value) return;
const rtl = ctx.dir.value === 'rtl' ? -1 : 1;
switch (e.key) {
case 'ArrowRight':
e.preventDefault();
shiftFocus(addDays(day, rtl));
break;
case 'ArrowLeft':
e.preventDefault();
shiftFocus(addDays(day, -rtl));
break;
case 'ArrowUp':
e.preventDefault();
shiftFocus(addDays(day, -7));
break;
case 'ArrowDown':
e.preventDefault();
shiftFocus(addDays(day, 7));
break;
case 'Home': {
e.preventDefault();
const dow = day.getDay();
const offset = (dow - ctx.weekStartsOn.value + 7) % 7;
shiftFocus(addDays(day, -offset));
break;
}
case 'End': {
e.preventDefault();
const dow = day.getDay();
const offset = (dow - ctx.weekStartsOn.value + 7) % 7;
shiftFocus(addDays(day, 6 - offset));
break;
}
case 'PageUp':
e.preventDefault();
shiftFocus(e.shiftKey ? addYears(day, -1) : addMonths(day, -1));
break;
case 'PageDown':
e.preventDefault();
shiftFocus(e.shiftKey ? addYears(day, 1) : addMonths(day, 1));
break;
case 'Enter':
case ' ':
e.preventDefault();
selectIfAllowed();
break;
}
}
function handleFocus() {
ctx.focusedDate.value = day;
}
const dataValue = computed(() => day.toISOString().slice(0, 10));
const tabindex = computed(() => {
if (isFocusedDate.value) return 0;
if (isOutsideView.value || isDisabled.value) return undefined;
return -1;
});
defineExpose({ currentElement });
</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-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"
:unavailable="isUnavailable"
>
{{ dayValue }}
</slot>
</Primitive>
</template>
@@ -0,0 +1,40 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
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 '../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,17 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CalendarGridBodyProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
const { as = 'tbody' } = defineProps<CalendarGridBodyProps>();
</script>
<template>
<Primitive :as="as" :data-primitives-calendar-grid-body="''">
<slot />
</Primitive>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CalendarGridHeadProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
const { as = 'thead' } = defineProps<CalendarGridHeadProps>();
</script>
<template>
<Primitive :as="as" :data-primitives-calendar-grid-head="''">
<slot />
</Primitive>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CalendarGridRowProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
const { as = 'tr' } = defineProps<CalendarGridRowProps>();
</script>
<template>
<Primitive :as="as" :data-primitives-calendar-grid-row="''">
<slot />
</Primitive>
</template>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
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 '../primitive';
import { useCalendarRootContext } from './context';
import { formatWeekday } from './utils';
const { as = 'th', day } = defineProps<CalendarHeadCellProps>();
const ctx = useCalendarRootContext();
const longLabel = computed(() => (day ? 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,17 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CalendarHeaderProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
const { as = 'div' } = defineProps<CalendarHeaderProps>();
</script>
<template>
<Primitive :as="as" :data-primitives-calendar-header="''">
<slot />
</Primitive>
</template>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CalendarHeadingProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../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,45 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
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 '../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,45 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
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 '../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,324 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
import type { CalendarMonth, WeekDayFormat } from './utils';
export interface CalendarRootProps extends PrimitiveProps {
/** Uncontrolled default selected date. */
defaultValue?: 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;
}
export interface CalendarRootEmits {
'update:modelValue': [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 { Primitive } from '../primitive';
import { provideCalendarRootContext } from './context';
import {
addMonths,
addYears,
clamp,
createMonths,
formatMonthYear,
getWeekdayLabels,
isAfter,
isBefore,
isSameDay,
isSameMonth,
isDateUnavailable as isUnavailable,
toDateOnly,
} from './utils';
defineOptions({ inheritAttrs: false });
const {
as = 'div',
defaultValue,
defaultPlaceholder,
minValue,
maxValue,
isDateUnavailable: propsIsDateUnavailable,
isDateDisabled: propsIsDateDisabled,
pagedNavigation = false,
weekStartsOn = 0,
weekdayFormat = 'short',
fixedWeeks = true,
numberOfMonths = 1,
disabled = false,
readonly = false,
initialFocus = false,
locale = 'en',
dir = 'ltr',
nextPage: propsNextPage,
prevPage: propsPrevPage,
calendarLabel = 'Calendar',
} = defineProps<CalendarRootProps>();
defineEmits<CalendarRootEmits>();
defineSlots<{
default?: (props: {
date: Date;
grid: CalendarMonth[];
weekDays: string[];
weekStartsOn: number;
locale: string;
modelValue: Date | undefined;
}) => unknown;
}>();
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>(
toDateOnly(defaultPlaceholder ?? modelValue.value ?? new Date()),
);
const placeholder = defineModel<Date>('placeholder', {
default: undefined,
get: v => v ?? localPlaceholder.value,
set: (v) => {
localPlaceholder.value = toDateOnly(v);
return localPlaceholder.value;
},
});
const { forwardRef, currentElement: parentElement } = useForwardExpose();
const focusedDate = ref<Date | undefined>();
const localeRef = toRef(() => locale);
const dirRef = toRef(() => dir);
const weekStartsOnRef = toRef(() => weekStartsOn);
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 minValueRef = toRef(() => minValue);
const maxValueRef = toRef(() => maxValue);
const grid = computed<CalendarMonth[]>(() => createMonths({
date: placeholder.value,
numberOfMonths,
weekStartsOn,
}));
const weekDays = computed(() => getWeekdayLabels(weekStartsOn, locale, weekdayFormat));
const headingValue = computed(() => {
const months = grid.value;
if (!months.length) return '';
if (months.length === 1) return formatMonthYear(months[0]!.value, locale);
const first = formatMonthYear(months[0]!.value, locale);
const last = 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 && isBefore(date, minValue)) return true;
if (maxValue && isAfter(date, maxValue)) return true;
return false;
}
function isDateUnavailableLocal(date: Date): boolean {
return isUnavailable(date, propsIsDateUnavailable, minValue, maxValue);
}
function isDateSelected(date: Date): boolean {
return modelValue.value ? isSameDay(modelValue.value, date) : false;
}
function isOutsideVisibleView(date: Date): boolean {
return !grid.value.some(m => isSameMonth(m.value, date));
}
const isInvalid = computed(() => {
if (!modelValue.value) return false;
return isDateDisabled(modelValue.value) || isDateUnavailableLocal(modelValue.value);
});
function setDate(date: Date | undefined) {
if (readonly) return;
if (date && (isDateDisabled(date) || isDateUnavailableLocal(date))) return;
modelValue.value = date ? toDateOnly(date) : undefined;
}
function setPlaceholder(date: Date) {
placeholder.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
? toDateOnly(fnToUse(placeholder.value))
: addMonths(placeholder.value, pageStep());
}
function prevPage(fn?: (placeholder: Date) => Date) {
const fnToUse = fn ?? propsPrevPage;
placeholder.value = fnToUse
? toDateOnly(fnToUse(placeholder.value))
: addMonths(placeholder.value, -pageStep());
}
function nextYear() {
placeholder.value = addYears(placeholder.value, 1);
}
function prevYear() {
placeholder.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
? toDateOnly(fnToUse(placeholder.value))
: addMonths(lastMonth, 1);
return 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
? toDateOnly(fnToUse(placeholder.value))
: addMonths(firstMonth, -1);
return isBefore(probe, minValue);
}
watch(modelValue, (v) => {
if (v && !isSameMonth(v, placeholder.value))
placeholder.value = toDateOnly(v);
});
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({
modelValue,
placeholder,
locale: localeRef,
dir: dirRef,
grid,
weekDays,
headingValue,
fullCalendarLabel,
weekStartsOn: weekStartsOnRef,
weekdayFormat: weekdayFormatRef,
fixedWeeks: fixedWeeksRef,
numberOfMonths: numberOfMonthsRef,
disabled: disabledRef,
readonly: readonlyRef,
pagedNavigation: pagedNavigationRef,
minValue: minValueRef,
maxValue: maxValueRef,
isDateDisabled,
isDateUnavailable: isDateUnavailableLocal,
isDateSelected,
isOutsideVisibleView,
isInvalid,
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"
/>
</Primitive>
</template>
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import {
addMonths,
getWeeks,
isDateUnavailable,
isSameDay,
startOfWeek,
} 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('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);
});
});
+67
View File
@@ -0,0 +1,67 @@
import type { ComputedRef, Ref } from 'vue';
import type { CalendarMonth, WeekDayFormat } from './utils';
import { useContextFactory } from '@robonen/vue';
export interface CalendarRootContext {
/** Currently selected date (or undefined). */
modelValue: Ref<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>;
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>;
/** 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;
+125
View File
@@ -0,0 +1,125 @@
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);
}
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;
}
/**
* 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;
}
export function formatDate(
d: Date,
opts: Intl.DateTimeFormatOptions,
locale: string,
): string {
return new Intl.DateTimeFormat(locale, opts).format(d);
}
export function formatWeekday(
d: Date,
locale: string,
width: WeekDayFormat = 'short',
): string {
return new Intl.DateTimeFormat(locale, { weekday: width }).format(d);
}
+42
View File
@@ -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';
+64
View File
@@ -0,0 +1,64 @@
import type { WeekDayFormat } from './date-utils';
import {
addMonths,
formatDate,
formatWeekday,
getWeeks,
startOfMonth,
startOfWeek,
} from './date-utils';
export * from './date-utils';
export interface CalendarMonth {
/** First day of this month (date-only). */
value: Date;
/** 6×7 grid of dates including leading/trailing adjacent-month days. */
weeks: Date[][];
}
export interface CreateMonthsOptions {
date: Date;
numberOfMonths: number;
weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6;
}
/** Build N consecutive months starting from `date`'s month. */
export function createMonths(opts: CreateMonthsOptions): CalendarMonth[] {
const months: CalendarMonth[] = [];
for (let i = 0; i < opts.numberOfMonths; i++) {
const m = startOfMonth(addMonths(opts.date, i));
months.push({ value: m, weeks: getWeeks(m, opts.weekStartsOn) });
}
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;
}
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,32 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CheckboxIndicatorProps extends PrimitiveProps {
/** Keep mounted even when unchecked (for CSS exit animations). */
forceMount?: boolean;
}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
import { useCheckboxContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'span', forceMount = false } = defineProps<CheckboxIndicatorProps>();
const ctx = useCheckboxContext();
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
v-if="forceMount || ctx.checked.value !== false"
:data-state="ctx.checked.value === 'indeterminate' ? 'indeterminate' : (ctx.checked.value ? 'checked' : 'unchecked')"
:data-disabled="ctx.disabled.value ? '' : undefined"
style="pointer-events: none;"
>
<slot :checked="ctx.checked.value" />
</Primitive>
</template>
@@ -0,0 +1,99 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
import type { CheckedState } from './context';
export interface CheckboxRootProps extends PrimitiveProps {
/** Uncontrolled initial checked state. */
defaultChecked?: CheckedState;
/** Disable interaction. */
disabled?: boolean;
/** Mark associated hidden input as required. */
required?: boolean;
/** Hidden input name attribute. */
name?: string;
/** Hidden input value attribute. @default 'on' */
value?: string;
}
export interface CheckboxRootEmits {
checkedChange: [value: CheckedState];
}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
import { ref, toRef, watch } from 'vue';
import { provideCheckboxContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { disabled = false, required = false, value = 'on', defaultChecked, name, as = 'button' } = defineProps<CheckboxRootProps>();
const { forwardRef } = useForwardExpose();
const emit = defineEmits<CheckboxRootEmits>();
const model = defineModel<CheckedState | undefined>('checked', { default: undefined });
const localChecked = ref<CheckedState>(model.value ?? defaultChecked ?? false);
watch(model, (v) => {
if (v === undefined) return;
if (v !== localChecked.value) localChecked.value = v;
});
function setChecked(v: CheckedState): void {
localChecked.value = v;
model.value = v;
emit('checkedChange', v);
}
function toggle(): void {
if (disabled) return;
setChecked(localChecked.value !== true);
}
function onKeyDown(event: KeyboardEvent): void {
// Prevent form submit on Enter when inside a form.
if (event.key === 'Enter') event.preventDefault();
}
provideCheckboxContext({
// `localChecked` is already a `Ref<CheckedState>`; forward directly without
// wrapping in a computed. `toRef(() => disabled)` gives a reactive identity
// passthrough without `ReactiveEffect`/cache.
checked: localChecked,
disabled: toRef(() => disabled),
});
// Inlined in template — no need for a cached computed for a single call site.
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
role="checkbox"
:aria-checked="localChecked === 'indeterminate' ? 'mixed' : localChecked"
:aria-required="required || undefined"
:aria-disabled="disabled || undefined"
:data-state="localChecked === 'indeterminate' ? 'indeterminate' : (localChecked ? 'checked' : 'unchecked')"
:data-disabled="disabled ? '' : undefined"
:disabled="disabled || undefined"
@click="toggle"
@keydown="onKeyDown"
>
<slot :checked="localChecked" />
<input
v-if="name"
type="checkbox"
tabindex="-1"
aria-hidden="true"
:name="name"
:value="value"
:checked="localChecked === true"
:required="required"
:disabled="disabled"
style="position: absolute; pointer-events: none; opacity: 0; margin: 0; transform: translateX(-100%);"
>
</Primitive>
</template>
@@ -0,0 +1,109 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import { CheckboxIndicator, CheckboxRoot } from '../index';
function mountCheckbox(props: Record<string, unknown> = {}) {
return mount(CheckboxRoot, {
attachTo: document.body,
props,
slots: {
default: () => h(CheckboxIndicator, null, { default: () => '✓' }),
},
});
}
describe('Checkbox', () => {
it('renders role="checkbox" with aria-checked="false" initially', () => {
const w = mountCheckbox();
const el = w.element;
expect(el.getAttribute('role')).toBe('checkbox');
expect(el.getAttribute('aria-checked')).toBe('false');
expect(el.getAttribute('data-state')).toBe('unchecked');
w.unmount();
});
it('toggles on click', async () => {
const w = mountCheckbox();
const el = w.element as HTMLElement;
el.click();
await nextTick();
expect(el.getAttribute('aria-checked')).toBe('true');
expect(el.getAttribute('data-state')).toBe('checked');
el.click();
await nextTick();
expect(el.getAttribute('aria-checked')).toBe('false');
w.unmount();
});
it('honours defaultChecked', () => {
const w = mountCheckbox({ defaultChecked: true });
expect(w.element.getAttribute('aria-checked')).toBe('true');
w.unmount();
});
it('supports indeterminate state with aria-checked="mixed"', async () => {
const checked = ref<boolean | 'indeterminate'>('indeterminate');
const Harness = defineComponent({
setup: () => () => h(CheckboxRoot, {
checked: checked.value,
'onUpdate:checked': (v: boolean | 'indeterminate' | undefined) => { checked.value = v!; },
}, { default: () => h(CheckboxIndicator) }),
});
const w = mount(Harness, { attachTo: document.body });
expect(w.element.getAttribute('aria-checked')).toBe('mixed');
(w.element as HTMLElement).click();
await nextTick();
// Click from indeterminate → true
expect(checked.value).toBe(true);
w.unmount();
});
it('disabled: no toggle on click, aria-disabled set', async () => {
const w = mountCheckbox({ disabled: true });
const el = w.element as HTMLElement;
expect(el.getAttribute('aria-disabled')).toBe('true');
el.click();
await nextTick();
expect(el.getAttribute('aria-checked')).toBe('false');
w.unmount();
});
it('emits checkedChange', async () => {
const w = mountCheckbox();
(w.element as HTMLElement).click();
await nextTick();
expect(w.emitted('checkedChange')).toEqual([[true]]);
w.unmount();
});
it('renders hidden input when name is set', async () => {
const w = mountCheckbox({ name: 'agree', value: 'yes', defaultChecked: true });
const input = w.element.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(input).toBeTruthy();
expect(input.name).toBe('agree');
expect(input.value).toBe('yes');
expect(input.checked).toBe(true);
w.unmount();
});
it('CheckboxIndicator only renders when checked (or forceMount)', async () => {
const w = mountCheckbox();
expect(w.element.querySelector('span')).toBeNull();
(w.element as HTMLElement).click();
await nextTick();
expect(w.element.querySelector('span')).toBeTruthy();
w.unmount();
});
it('CheckboxIndicator forceMount stays mounted when unchecked', () => {
const w = mount(CheckboxRoot, {
attachTo: document.body,
slots: {
default: () => h(CheckboxIndicator, { forceMount: true }, { default: () => '✓' }),
},
});
expect(w.element.querySelector('span')).toBeTruthy();
w.unmount();
});
});
+14
View File
@@ -0,0 +1,14 @@
import type { Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
export type CheckedState = boolean | 'indeterminate';
export interface CheckboxContext {
checked: Ref<CheckedState>;
disabled: Ref<boolean>;
}
const ctx = useContextFactory<CheckboxContext>('CheckboxContext');
export const provideCheckboxContext = ctx.provide;
export const useCheckboxContext = ctx.inject;
+5
View File
@@ -0,0 +1,5 @@
export { default as CheckboxIndicator } from './CheckboxIndicator.vue';
export { default as CheckboxRoot } from './CheckboxRoot.vue';
export type { CheckedState } from './context';
export type { CheckboxIndicatorProps } from './CheckboxIndicator.vue';
export type { CheckboxRootEmits, CheckboxRootProps } from './CheckboxRoot.vue';
@@ -0,0 +1,36 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CollapsibleContentProps extends PrimitiveProps {
/** Render the content even when closed (useful for animation control). */
forceMount?: boolean;
}
</script>
<script setup lang="ts">
import { Presence } from '../presence';
import { Primitive } from '../primitive';
import { useCollapsibleContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'div', forceMount = false } = defineProps<CollapsibleContentProps>();
const { forwardRef } = useForwardExpose();
const ctx = useCollapsibleContext();
</script>
<template>
<Presence :present="forceMount || ctx.open.value">
<Primitive
:ref="forwardRef"
:id="ctx.contentId.value"
:as="as"
:data-state="ctx.open.value ? 'open' : 'closed'"
:data-disabled="ctx.disabled.value ? '' : undefined"
:hidden="!ctx.open.value ? true : undefined"
>
<slot :open="ctx.open.value" />
</Primitive>
</Presence>
</template>
@@ -0,0 +1,56 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CollapsibleRootProps extends PrimitiveProps {
defaultOpen?: boolean;
disabled?: boolean;
}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
import { ref, toRef } from 'vue';
import { provideCollapsibleContext } from './context';
import { useForwardExpose } from '@robonen/vue';
import { useId } from '../config-provider';
const { defaultOpen = false, disabled = false, as = 'div' } = defineProps<CollapsibleRootProps>();
const { forwardRef } = useForwardExpose();
const localOpen = ref<boolean>(defaultOpen);
const open = defineModel<boolean>('open', {
default: undefined,
get: v => v ?? localOpen.value,
set: (v) => {
localOpen.value = v;
return v;
},
});
// Identity passthrough via `toRef` — reactive without `computed`'s effect/cache.
const disabledRef = toRef(() => disabled);
const contentId = useId(undefined, 'collapsible-content');
provideCollapsibleContext({
open,
disabled: disabledRef,
contentId,
onToggle: () => { if (!disabled) open.value = !open.value; },
onOpen: () => { if (!disabled) open.value = true; },
onClose: () => { if (!disabled) open.value = false; },
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:data-state="open ? 'open' : 'closed'"
:data-disabled="disabled ? '' : undefined"
>
<slot :open="open" />
</Primitive>
</template>
@@ -0,0 +1,32 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CollapsibleTriggerProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
import { useCollapsibleContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'button' } = defineProps<CollapsibleTriggerProps>();
const { forwardRef } = useForwardExpose();
const ctx = useCollapsibleContext();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
:aria-expanded="ctx.open.value"
:aria-controls="ctx.contentId.value"
:data-state="ctx.open.value ? 'open' : 'closed'"
:data-disabled="ctx.disabled.value ? '' : undefined"
:disabled="as === 'button' ? ctx.disabled.value : undefined"
@click="ctx.onToggle"
>
<slot :open="ctx.open.value" />
</Primitive>
</template>
@@ -0,0 +1,65 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from '../index';
function mountCollapsible(props: Record<string, unknown> = {}) {
return mount(defineComponent({
setup: () => () => h(CollapsibleRoot, props, {
default: () => [
h(CollapsibleTrigger, { class: 'trig' }, { default: () => 'Toggle' }),
h(CollapsibleContent, { class: 'c' }, { default: () => 'Body' }),
],
}),
}));
}
describe('Collapsible', () => {
it('starts closed by default; trigger toggles state', async () => {
const w = mountCollapsible();
const trigger = w.find('.trig');
expect(trigger.attributes('aria-expanded')).toBe('false');
expect(w.find('.c').exists()).toBe(false);
await trigger.trigger('click');
expect(trigger.attributes('aria-expanded')).toBe('true');
expect(w.find('.c').exists()).toBe(true);
});
it('opens via defaultOpen', async () => {
const w = mountCollapsible({ defaultOpen: true });
await nextTick();
expect(w.find('.trig').attributes('aria-expanded')).toBe('true');
expect(w.find('.c').exists()).toBe(true);
expect(w.find('.c').text()).toBe('Body');
});
it('wires aria-controls to content id', async () => {
const w = mountCollapsible({ defaultOpen: true });
await nextTick();
const id = w.find('.c').attributes('id');
expect(id).toMatch(/collapsible-content/);
expect(w.find('.trig').attributes('aria-controls')).toBe(id);
});
it('respects disabled', async () => {
const w = mountCollapsible({ disabled: true });
await w.find('.trig').trigger('click');
expect(w.find('.trig').attributes('aria-expanded')).toBe('false');
expect(w.find('.trig').attributes('data-disabled')).toBe('');
});
it('forceMount keeps content in DOM when closed', () => {
const w = mount(defineComponent({
setup: () => () => h(CollapsibleRoot, null, {
default: () => [
h(CollapsibleTrigger, { class: 'trig' }),
h(CollapsibleContent, { class: 'c', forceMount: true }, { default: () => 'Body' }),
],
}),
}));
const content = w.find('.c');
expect(content.exists()).toBe(true);
expect(content.attributes('hidden')).toBeDefined();
expect(content.attributes('data-state')).toBe('closed');
});
});
+16
View File
@@ -0,0 +1,16 @@
import type { ComputedRef, Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
export interface CollapsibleContext {
open: Ref<boolean>;
disabled: Ref<boolean>;
contentId: ComputedRef<string>;
onToggle: () => void;
onOpen: () => void;
onClose: () => void;
}
const ctx = useContextFactory<CollapsibleContext>('CollapsibleContext');
export const provideCollapsibleContext = ctx.provide;
export const useCollapsibleContext = ctx.inject;
+8
View File
@@ -0,0 +1,8 @@
export { default as CollapsibleRoot } from './CollapsibleRoot.vue';
export { default as CollapsibleTrigger } from './CollapsibleTrigger.vue';
export { default as CollapsibleContent } from './CollapsibleContent.vue';
export type { CollapsibleRootProps } from './CollapsibleRoot.vue';
export type { CollapsibleTriggerProps } from './CollapsibleTrigger.vue';
export type { CollapsibleContentProps } from './CollapsibleContent.vue';
export { provideCollapsibleContext, useCollapsibleContext } from './context';
export type { CollapsibleContext } from './context';
+6
View File
@@ -0,0 +1,6 @@
export {
useCollectionProvider,
useCollectionInjector,
type CollectionContext,
type CollectionItemData,
} from './useCollection';
@@ -0,0 +1,185 @@
import type { ComputedRef, DefineComponent, ShallowRef } from 'vue';
import {
computed,
defineComponent,
h,
markRaw,
shallowRef,
triggerRef,
watch,
} from 'vue';
import { unrefElement, useContextFactory } from '@robonen/vue';
import { Slot } from '../primitive';
/**
* Data attribute used to locate items inside a collection via `querySelectorAll`.
* Rendered automatically by `<CollectionItem>`.
*/
const ITEM_DATA_ATTR = 'data-collection-item';
export interface CollectionItemData<Value = unknown> {
/** DOM element that represents the item. */
ref: HTMLElement;
/** Arbitrary `value` associated with the item via `<CollectionItem :value>`. */
value?: Value;
}
export interface CollectionContext<Value = unknown> {
/** Root element of the collection (set by `<CollectionSlot>`). */
collectionRef: ShallowRef<HTMLElement | undefined>;
/** Raw element→data map. Mutated via `triggerRef` — do not rely on deep reactivity. */
itemMap: ShallowRef<Map<HTMLElement, CollectionItemData<Value>>>;
/**
* Returns items sorted by their DOM order. Items with `data-disabled` are
* skipped unless `includeDisabled` is `true`.
*
* The ordering comes from `collectionRef.querySelectorAll(...)`, which means
* it survives `<Teleport>`, `<Suspense>` and `v-for` reorders — unlike a
* mount-order based registry.
*/
getItems: (includeDisabled?: boolean) => Array<CollectionItemData<Value>>;
/** Reactive snapshot of all items (unsorted). Invalidated when `itemMap` changes. */
reactiveItems: ComputedRef<Array<CollectionItemData<Value>>>;
/** Reactive count of items. */
itemMapSize: ComputedRef<number>;
/** Root marker component — render at the collection's root. */
CollectionSlot: DefineComponent;
/** Item marker component — wrap each focusable/selectable child. */
CollectionItem: DefineComponent<{ value?: unknown }>;
}
function createCollectionState<Value = unknown>(): CollectionContext<Value> {
// `shallowRef` + manual `triggerRef` avoids wrapping the Map in a deep Proxy.
// For collections with many items (large lists, menus, listboxes) this is
// measurably cheaper than `ref(new Map())`.
const collectionRef = shallowRef<HTMLElement>();
const itemMap = shallowRef(
new Map<HTMLElement, CollectionItemData<Value>>(),
);
const getItems = (includeDisabled = false) => {
const collectionNode = collectionRef.value;
if (!collectionNode) return [];
const orderedNodes = Array.from(
collectionNode.querySelectorAll(`[${ITEM_DATA_ATTR}]`),
);
const items = Array.from(itemMap.value.values());
items.sort(
(a, b) => orderedNodes.indexOf(a.ref) - orderedNodes.indexOf(b.ref),
);
return includeDisabled
? items
: items.filter(i => i.ref.dataset['disabled'] !== '');
};
const CollectionSlot = defineComponent({
name: 'CollectionSlot',
inheritAttrs: false,
setup(_, { slots, attrs }) {
return () =>
h(
Slot,
{
...attrs,
ref: (el: unknown) => {
const element = unrefElement(el as Parameters<typeof unrefElement>[0]);
if (element instanceof HTMLElement) {
collectionRef.value = element;
}
},
},
slots,
);
},
}) as DefineComponent;
const CollectionItem = defineComponent({
name: 'CollectionItem',
inheritAttrs: false,
props: {
value: {
// Accepts any value.
validator: () => true,
},
},
setup(props, { slots, attrs }) {
const currentElement = shallowRef<HTMLElement>();
watch(
[currentElement, () => props.value],
([el], _prev, onCleanup) => {
if (!el) return;
// `markRaw` keeps Vue from trying to make the element reactive —
// we only care about identity as a Map key.
const key = markRaw(el);
itemMap.value.set(key, { ref: el, value: props.value as Value });
triggerRef(itemMap);
onCleanup(() => {
itemMap.value.delete(key);
triggerRef(itemMap);
});
},
{ immediate: true },
);
return () =>
h(
Slot,
{
...attrs,
[ITEM_DATA_ATTR]: '',
ref: (el: unknown) => {
const element = unrefElement(el as Parameters<typeof unrefElement>[0]);
if (element instanceof HTMLElement) {
currentElement.value = element;
}
},
},
slots,
);
},
}) as DefineComponent<{ value?: unknown }>;
const reactiveItems = computed(() => Array.from(itemMap.value.values()));
const itemMapSize = computed(() => itemMap.value.size);
return {
collectionRef,
itemMap,
getItems,
reactiveItems,
itemMapSize,
CollectionSlot,
CollectionItem,
};
}
const CollectionCtx = useContextFactory<CollectionContext>('CollectionContext');
/**
* Creates a new collection state and provides it to descendants.
* Call this in the parent (e.g. `RovingFocusGroup`, `ListboxRoot`).
*
* @example
* ```ts
* const { getItems, CollectionSlot } = useCollectionProvider();
* ```
*/
export function useCollectionProvider<Value = unknown>(): CollectionContext<Value> {
const ctx = createCollectionState<Value>();
CollectionCtx.provide(ctx as CollectionContext);
return ctx;
}
/**
* Injects the collection context from the nearest `useCollectionProvider()`.
* Call this in children (e.g. `RovingFocusItem`, `ListboxItem`).
*
* @throws when used outside a provider.
*/
export function useCollectionInjector<Value = unknown>(): CollectionContext<Value> {
return CollectionCtx.inject() as CollectionContext<Value>;
}
@@ -0,0 +1,33 @@
<script lang="ts">
import type { PopperAnchorProps } from '../popper';
export interface ComboboxAnchorProps extends PopperAnchorProps {}
</script>
<script setup lang="ts">
import { onBeforeUnmount, watchPostEffect } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { PopperAnchor } from '../popper';
import { Primitive } from '../primitive';
import { useComboboxRootContext } from './context';
const props = defineProps<ComboboxAnchorProps>();
const { forwardRef, currentElement } = useForwardExpose();
const rootCtx = useComboboxRootContext();
watchPostEffect(() => rootCtx.onParentChange(currentElement.value));
onBeforeUnmount(() => rootCtx.onParentChange(undefined));
</script>
<template>
<PopperAnchor :reference="props.reference">
<Primitive
:ref="forwardRef"
:as="props.as ?? 'div'"
>
<slot />
</Primitive>
</PopperAnchor>
</template>
@@ -0,0 +1,24 @@
<script lang="ts">
import type { PopperArrowProps } from '../popper';
export type ComboboxArrowProps = PopperArrowProps;
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { PopperArrow } from '../popper';
import { useComboboxRootContext } from './context';
const props = defineProps<ComboboxArrowProps>();
const { forwardRef } = useForwardExpose();
const rootCtx = useComboboxRootContext();
</script>
<template>
<PopperArrow
v-if="rootCtx.open.value"
:ref="forwardRef"
v-bind="props"
/>
</template>
@@ -0,0 +1,40 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ComboboxCancelProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useComboboxRootContext } from './context';
const { as = 'button' } = defineProps<ComboboxCancelProps>();
const { forwardRef } = useForwardExpose();
const rootCtx = useComboboxRootContext();
function handleClick() {
rootCtx.onSearchTermChange('');
const input = rootCtx.inputElement.value;
if (input) {
input.value = '';
input.focus();
}
rootCtx.onUserInputtedChange(false);
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
tabindex="-1"
aria-label="Clear"
@click="handleClick"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,30 @@
<script lang="ts">
import type { ComboboxContentImplEmits, ComboboxContentImplProps } from './ComboboxContentImpl.vue';
export type ComboboxContentProps = ComboboxContentImplProps;
export type ComboboxContentEmits = ComboboxContentImplEmits;
</script>
<script setup lang="ts">
import { Presence } from '../presence';
import ComboboxContentImpl from './ComboboxContentImpl.vue';
import { useComboboxRootContext } from './context';
const props = defineProps<ComboboxContentProps>();
const emit = defineEmits<ComboboxContentEmits>();
const rootCtx = useComboboxRootContext();
</script>
<template>
<Presence :present="rootCtx.open.value">
<ComboboxContentImpl
v-bind="props"
@close-auto-focus="emit('closeAutoFocus', $event)"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
>
<slot />
</ComboboxContentImpl>
</Presence>
</template>
@@ -0,0 +1,139 @@
<script lang="ts">
import type { DismissableLayerEmits } from '../dismissable-layer';
import type { FocusScopeEmits } from '../focus-scope';
import type { PopperContentProps } from '../popper';
import type { PrimitiveProps } from '../primitive';
export interface ComboboxContentImplProps extends PrimitiveProps, /* @vue-ignore */ Partial<PopperContentProps> {
/** Position strategy. @default 'popper' */
position?: 'inline' | 'popper';
/** Block outside pointer events. @default false */
disableOutsidePointerEvents?: boolean;
}
export interface ComboboxContentImplEmits {
closeAutoFocus: FocusScopeEmits['unmountAutoFocus'];
escapeKeyDown: DismissableLayerEmits['escapeKeyDown'];
pointerDownOutside: DismissableLayerEmits['pointerDownOutside'];
focusOutside: FocusScopeEmits['unmountAutoFocus'];
}
</script>
<script setup lang="ts">
import { onBeforeUnmount, shallowRef, toRef, watchPostEffect } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { DismissableLayer } from '../dismissable-layer';
import { FocusScope } from '../focus-scope';
import { PopperContent } from '../popper';
import { Primitive } from '../primitive';
import { VisuallyHidden } from '../visually-hidden';
import { useHideOthers } from '../utils/useHideOthers';
import { provideComboboxContentContext, useComboboxRootContext } from './context';
const props = defineProps<ComboboxContentImplProps>();
const emit = defineEmits<ComboboxContentImplEmits>();
const { forwardRef, currentElement } = useForwardExpose();
const rootCtx = useComboboxRootContext();
const viewportElement = shallowRef<HTMLElement | undefined>(undefined);
watchPostEffect(() => rootCtx.onContentChange(currentElement.value));
onBeforeUnmount(() => rootCtx.onContentChange(undefined));
useHideOthers(toRef(() => rootCtx.parentElement.value));
provideComboboxContentContext({
viewportElement,
onViewportChange: (el) => { viewportElement.value = el; },
position: toRef(() => props.position ?? 'popper'),
});
function handleEscape(event: KeyboardEvent) {
rootCtx.onOpenChange(false);
emit('escapeKeyDown', event);
}
function handlePointerDownOutside(event: any) {
const target = event.target as Element | null;
const input = rootCtx.inputElement.value;
const trigger = rootCtx.triggerElement.value;
if (target && (input?.contains(target) || trigger?.contains(target))) {
event.preventDefault();
return;
}
emit('pointerDownOutside', event);
if (!event.defaultPrevented) rootCtx.onOpenChange(false);
}
function handleFocusOutside(event: any) {
emit('focusOutside', event);
}
function handleCloseAutoFocus(event: Event) {
emit('closeAutoFocus', event);
}
</script>
<template>
<FocusScope
as="template"
:loop="false"
:trapped="false"
@mount-auto-focus.prevent
@unmount-auto-focus="handleCloseAutoFocus"
>
<DismissableLayer
as="template"
:disable-outside-pointer-events="props.disableOutsidePointerEvents ?? false"
@escape-key-down="handleEscape"
@pointer-down-outside="handlePointerDownOutside"
@focus-outside="handleFocusOutside"
@dismiss="rootCtx.onOpenChange(false)"
>
<PopperContent
v-if="(props.position ?? 'popper') === 'popper'"
:ref="forwardRef"
:as="props.as ?? 'div'"
:side="props.side ?? 'bottom'"
:side-offset="props.sideOffset ?? 4"
:align="props.align ?? 'start'"
:align-offset="props.alignOffset"
:avoid-collisions="props.avoidCollisions"
:collision-boundary="props.collisionBoundary"
:collision-padding="props.collisionPadding"
:arrow-padding="props.arrowPadding"
:sticky="props.sticky"
:hide-when-detached="props.hideWhenDetached"
:update-position-strategy="props.updatePositionStrategy"
:id="rootCtx.contentId.value"
role="listbox"
:aria-multiselectable="rootCtx.multiple.value || undefined"
:data-state="rootCtx.open.value ? 'open' : 'closed'"
data-primitives-combobox-content
>
<VisuallyHidden role="status" aria-live="polite" data-primitives-combobox-announce>
{{ rootCtx.filterState.value.count === 1 ? '1 result available.' : `${rootCtx.filterState.value.count} results available.` }}
</VisuallyHidden>
<slot />
</PopperContent>
<Primitive
v-else
:ref="forwardRef"
:as="props.as ?? 'div'"
:id="rootCtx.contentId.value"
role="listbox"
:aria-multiselectable="rootCtx.multiple.value || undefined"
:data-state="rootCtx.open.value ? 'open' : 'closed'"
data-primitives-combobox-content
>
<VisuallyHidden role="status" aria-live="polite" data-primitives-combobox-announce>
{{ rootCtx.filterState.value.count === 1 ? '1 result available.' : `${rootCtx.filterState.value.count} results available.` }}
</VisuallyHidden>
<slot />
</Primitive>
</DismissableLayer>
</FocusScope>
</template>
@@ -0,0 +1,37 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ComboboxEmptyProps extends PrimitiveProps {
/** Render even when items exist but none are filtered out. */
always?: boolean;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useComboboxRootContext } from './context';
const { as = 'div', always = false } = defineProps<ComboboxEmptyProps>();
const { forwardRef } = useForwardExpose();
const rootCtx = useComboboxRootContext();
const shouldRender = computed(() => {
if (always) return true;
return rootCtx.filterState.value.count === 0;
});
</script>
<template>
<Primitive
v-if="shouldRender"
:ref="forwardRef"
:as="as"
role="presentation"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,39 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ComboboxGroupProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useId } from '../config-provider';
import { Primitive } from '../primitive';
import { provideComboboxGroupContext, useComboboxRootContext } from './context';
const { as = 'div' } = defineProps<ComboboxGroupProps>();
const { forwardRef } = useForwardExpose();
const rootCtx = useComboboxRootContext();
const id = useId(undefined, 'combobox-group');
const isVisible = computed(() => rootCtx.filterState.value.groups.has(id.value));
onMounted(() => rootCtx.onGroupRegister(id.value));
onBeforeUnmount(() => rootCtx.onGroupUnregister(id.value));
provideComboboxGroupContext({ id });
</script>
<template>
<Primitive
v-show="isVisible"
:ref="forwardRef"
:as="as"
role="group"
:aria-labelledby="id"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,221 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ComboboxInputProps extends PrimitiveProps {
/** Disable the input. */
disabled?: boolean;
/** Focus the input on mount. */
autoFocus?: boolean;
/** Open the combobox when the input is focused. */
openOnFocus?: boolean;
/** Open the combobox when the input is clicked. */
openOnClick?: boolean;
}
</script>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useComboboxRootContext } from './context';
import { OPEN_KEYS } from './utils';
const {
as = 'input',
disabled = false,
autoFocus = false,
openOnFocus = false,
openOnClick = false,
} = defineProps<ComboboxInputProps>();
const { forwardRef, currentElement } = useForwardExpose();
const rootCtx = useComboboxRootContext();
const isDisabled = computed(() => disabled || rootCtx.disabled.value);
const activeDescendant = computed(() => rootCtx.selectedValueId.value);
function displayString(value: unknown): string {
if (rootCtx.displayValue) return rootCtx.displayValue(value);
if (value === undefined || value === null) return '';
if (Array.isArray(value)) return '';
if (typeof value === 'object') return '';
return String(value);
}
function syncDisplayValue() {
const input = currentElement.value as HTMLInputElement | undefined;
if (!input) return;
const next = displayString(rootCtx.modelValue.value);
if (input.value !== next) input.value = next;
}
onMounted(() => {
const el = currentElement.value as HTMLInputElement | undefined;
rootCtx.onInputChange(el);
if (el) {
el.value = rootCtx.searchTerm.value || displayString(rootCtx.modelValue.value);
}
if (autoFocus) setTimeout(() => el?.focus(), 1);
});
onBeforeUnmount(() => rootCtx.onInputChange(undefined));
watch(() => rootCtx.modelValue.value, () => {
if (rootCtx.isUserInputted.value) return;
if (!rootCtx.resetSearchTermOnSelect.value && rootCtx.searchTerm.value) return;
rootCtx.onSearchTermChange('');
syncDisplayValue();
}, { deep: true });
watch(() => rootCtx.searchTerm.value, (v) => {
const input = currentElement.value as HTMLInputElement | undefined;
if (!input) return;
if (!v && !rootCtx.isUserInputted.value) {
syncDisplayValue();
return;
}
if (input.value !== v) input.value = v;
});
watch(() => rootCtx.filterState.value, (newState, oldState) => {
if (oldState && oldState.count === 0 && newState.count > 0) {
rootCtx.highlightFirstItem();
}
});
function moveHighlight(delta: number) {
const els = rootCtx.getVisibleItemElements();
if (els.length === 0) return;
const curId = rootCtx.selectedValueId.value;
let idx = -1;
if (curId) {
for (let i = 0; i < els.length; i++) {
if (els[i]!.id === curId) {
idx = i;
break;
}
}
}
let nextIdx: number;
if (idx === -1) nextIdx = delta > 0 ? 0 : els.length - 1;
else nextIdx = (idx + delta + els.length) % els.length;
rootCtx.highlightItemById(els[nextIdx]!.id);
}
function commitHighlighted() {
const value = rootCtx.selectedValue.value;
if (value === undefined) return false;
rootCtx.onValueChange(value);
return true;
}
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
const next = target.value;
rootCtx.onUserInputtedChange(true);
rootCtx.onSearchTermChange(next);
if (!rootCtx.open.value) {
rootCtx.onOpenChange(true);
nextTick(() => rootCtx.highlightFirstItem());
}
else {
nextTick(() => rootCtx.highlightFirstItem());
}
}
function handleKeyDown(event: KeyboardEvent) {
if (isDisabled.value) return;
const { key } = event;
if (!rootCtx.open.value && OPEN_KEYS.includes(key)) {
event.preventDefault();
rootCtx.onOpenChange(true);
return;
}
if (!rootCtx.open.value) return;
switch (key) {
case 'ArrowDown':
event.preventDefault();
moveHighlight(1);
break;
case 'ArrowUp':
event.preventDefault();
moveHighlight(-1);
break;
case 'Home': {
event.preventDefault();
const first = rootCtx.getVisibleItemElements()[0];
if (first) rootCtx.highlightItemById(first.id);
break;
}
case 'End': {
event.preventDefault();
const list = rootCtx.getVisibleItemElements();
const last = list[list.length - 1];
if (last) rootCtx.highlightItemById(last.id);
break;
}
case 'Enter':
if (commitHighlighted()) event.preventDefault();
break;
case 'Escape':
event.preventDefault();
rootCtx.onOpenChange(false);
if (rootCtx.resetSearchTermOnBlur.value) rootCtx.onSearchTermChange('');
break;
case 'Tab':
rootCtx.onOpenChange(false);
break;
}
}
function handleFocus() {
if (openOnFocus && !rootCtx.open.value) rootCtx.onOpenChange(true);
}
function handleClick() {
if (openOnClick && !rootCtx.open.value) rootCtx.onOpenChange(true);
}
function handleBlur(event: FocusEvent) {
if (!rootCtx.open.value) return;
const nextFocus = event.relatedTarget as Element | null;
if (!nextFocus) return;
const parent = rootCtx.parentElement.value;
const content = rootCtx.contentElement.value;
if (parent?.contains(nextFocus) || content?.contains(nextFocus)) return;
rootCtx.onOpenChange(false);
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
type="text"
role="combobox"
autocomplete="off"
spellcheck="false"
aria-autocomplete="list"
:aria-expanded="rootCtx.open.value"
:aria-controls="rootCtx.contentId.value"
:aria-activedescendant="activeDescendant"
:aria-disabled="isDisabled || undefined"
:aria-required="rootCtx.required.value || undefined"
:disabled="isDisabled || undefined"
:data-state="rootCtx.open.value ? 'open' : 'closed'"
:data-disabled="isDisabled ? '' : undefined"
@input="handleInput"
@keydown="handleKeyDown"
@focus="handleFocus"
@click="handleClick"
@blur="handleBlur"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,120 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
import type { AcceptableValue } from './utils';
export interface ComboboxItemProps<T extends AcceptableValue = AcceptableValue> extends PrimitiveProps {
/** Item value. Selected/registered identity. */
value: T;
/** Optional explicit text for filter + typeahead. */
textValue?: string;
/** Disable this item. */
disabled?: boolean;
}
</script>
<script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useId } from '../config-provider';
import { Primitive } from '../primitive';
import { provideComboboxItemContext, useComboboxGroupContext, useComboboxRootContext } from './context';
const props = defineProps<ComboboxItemProps<T>>();
const { forwardRef, currentElement } = useForwardExpose();
const rootCtx = useComboboxRootContext();
let groupCtx: { id: { value: string } } | null = null;
try {
groupCtx = useComboboxGroupContext() as any;
}
catch {
groupCtx = null;
}
const id = useId(undefined, 'combobox-item');
const textValue = ref(props.textValue ?? '');
const isDisabled = computed(() => rootCtx.disabled.value || !!props.disabled);
const isSelected = computed(() => rootCtx.isSelected(props.value));
const isHighlighted = computed(() => rootCtx.selectedValueId.value === id.value);
const isVisible = computed(() => rootCtx.filterState.value.items.has(id.value));
function syncRegistration() {
rootCtx.onItemRegister(id.value, {
value: props.value,
textValue: textValue.value,
disabled: isDisabled.value,
});
}
onMounted(() => {
const el = currentElement.value as HTMLElement | undefined;
if (el && !props.textValue) {
textValue.value = el.textContent?.trim() ?? '';
}
syncRegistration();
if (groupCtx) rootCtx.onGroupItemRegister(groupCtx.id.value, id.value);
});
watch(() => [props.value, props.textValue, isDisabled.value], () => {
if (props.textValue) textValue.value = props.textValue;
syncRegistration();
});
onBeforeUnmount(() => {
rootCtx.onItemUnregister(id.value);
if (groupCtx) rootCtx.onGroupItemUnregister(groupCtx.id.value, id.value);
if (rootCtx.selectedValueId.value === id.value) {
rootCtx.onSelectedValueChange(undefined, undefined);
}
});
function handleClick(event: MouseEvent) {
if (isDisabled.value) return;
event.preventDefault();
rootCtx.onValueChange(props.value);
if (rootCtx.resetSearchTermOnSelect.value && !rootCtx.multiple.value) {
rootCtx.onSearchTermChange('');
rootCtx.onUserInputtedChange(false);
}
}
function handlePointerMove() {
if (isDisabled.value) return;
if (rootCtx.selectedValueId.value !== id.value) {
rootCtx.onSelectedValueChange(props.value, id.value);
}
}
provideComboboxItemContext({
id,
value: props.value,
textValue,
isSelected,
isDisabled,
});
defineExpose({ id, isVisible, isHighlighted });
</script>
<template>
<Primitive
v-show="isVisible"
:ref="forwardRef"
:id="id"
:as="props.as ?? 'div'"
role="option"
:aria-selected="isSelected"
:aria-disabled="isDisabled || undefined"
:data-state="isSelected ? 'checked' : 'unchecked'"
:data-highlighted="isHighlighted ? '' : undefined"
:data-disabled="isDisabled ? '' : undefined"
:tabindex="-1"
data-primitives-combobox-item
@click="handleClick"
@pointermove="handlePointerMove"
>
<slot :selected="isSelected" :highlighted="isHighlighted" />
</Primitive>
</template>
@@ -0,0 +1,27 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ComboboxItemIndicatorProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useComboboxItemContext } from './context';
const { as = 'span' } = defineProps<ComboboxItemIndicatorProps>();
const { forwardRef } = useForwardExpose();
const itemCtx = useComboboxItemContext();
</script>
<template>
<Primitive
v-if="itemCtx.isSelected.value"
:ref="forwardRef"
:as="as"
aria-hidden="true"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,26 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ComboboxLabelProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useComboboxGroupContext } from './context';
const { as = 'div' } = defineProps<ComboboxLabelProps>();
const { forwardRef } = useForwardExpose();
const groupCtx = useComboboxGroupContext();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:id="groupCtx.id.value"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { PortalProps } from '../teleport';
export interface ComboboxPortalProps extends PortalProps {}
</script>
<script setup lang="ts">
import { Portal } from '../teleport';
const { to, defer, disabled } = defineProps<ComboboxPortalProps>();
</script>
<template>
<Portal :to="to" :defer="defer" :disabled="disabled">
<slot />
</Portal>
</template>
@@ -0,0 +1,400 @@
<script lang="ts">
import type { Direction } from '../config-provider';
import type { AcceptableValue, ComboboxFilterFunction, ComboboxFilterItem } from './utils';
export interface ComboboxRootProps<T extends AcceptableValue = AcceptableValue> {
/** Controlled selected value. Use `v-model`. */
modelValue?: T | T[];
/** Uncontrolled initial value. */
defaultValue?: T | T[];
/** Controlled open state. Use `v-model:open`. */
open?: boolean;
/** Uncontrolled default open state. */
defaultOpen?: boolean;
/** Allow selecting multiple values. */
multiple?: boolean;
/** Reading direction. Falls back to `ConfigProvider`. */
dir?: Direction;
/** Disable the whole combobox. */
disabled?: boolean;
/** Mark as required for native form validation. */
required?: boolean;
/** Native input name for form submission. */
name?: string;
/** Reset the search term when the input is blurred. @default true */
resetSearchTermOnBlur?: boolean;
/** Reset the search term when a value is selected (single mode). @default true */
resetSearchTermOnSelect?: boolean;
/** Skip the built-in filter; render every item regardless of search term. */
ignoreFilter?: boolean;
/** Custom filter implementation. Overrides the default substring match. */
filterFunction?: ComboboxFilterFunction;
/** Map the current model value to the input's display value. */
displayValue?: (value: T | T[] | undefined) => string;
/** Compare values by key, or via a custom comparator. */
by?: string | ((a: T, b: T) => boolean);
}
export interface ComboboxRootEmits<T extends AcceptableValue = AcceptableValue> {
'update:modelValue': [value: T | T[] | undefined];
'update:open': [open: boolean];
}
</script>
<script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue">
import type { ShallowRef } from 'vue';
import type { ComboboxFilterState, ComboboxItemInfo } from './context';
import { computed, nextTick, ref, shallowRef, toRef, triggerRef, watch } from 'vue';
import { useConfig, useId } from '../config-provider';
import { PopperRoot } from '../popper';
import { provideComboboxRootContext } from './context';
import { defaultFilter, valueComparator } from './utils';
defineOptions({ inheritAttrs: false });
const {
modelValue,
defaultValue,
defaultOpen = false,
multiple = false,
dir,
disabled = false,
required = false,
name,
resetSearchTermOnBlur = true,
resetSearchTermOnSelect = true,
ignoreFilter = false,
filterFunction,
displayValue,
by,
} = defineProps<ComboboxRootProps<T>>();
const emit = defineEmits<ComboboxRootEmits<T>>();
const config = useConfig();
const direction = computed(() => dir ?? config.dir.value);
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 initial = (modelValue ?? defaultValue) as T | T[] | undefined;
const localValue = shallowRef<T | T[] | undefined>(
multiple
? (Array.isArray(initial) ? initial.slice() : (initial === undefined ? [] : [initial]))
: (Array.isArray(initial) ? initial[0] : initial),
);
const value = defineModel<T | T[] | undefined>('modelValue', {
default: undefined,
get: v => v ?? localValue.value,
set: (v) => {
localValue.value = v;
return v;
},
});
const searchTerm = ref('');
const isUserInputted = ref(false);
const contentId = useId(undefined, 'combobox-content');
const triggerElement = shallowRef<HTMLElement | undefined>(undefined);
const inputElement = shallowRef<HTMLInputElement | undefined>(undefined);
const contentElement = shallowRef<HTMLElement | undefined>(undefined);
const parentElement = shallowRef<HTMLElement | undefined>(undefined);
const selectedValue = shallowRef<T | undefined>(undefined) as ShallowRef<T | undefined>;
const selectedValueId = ref<string | undefined>(undefined);
const allItems = shallowRef(new Map<string, ComboboxItemInfo<T>>());
const allGroups = shallowRef(new Map<string, Set<string>>());
function onItemRegister(id: string, info: ComboboxItemInfo<T>) {
allItems.value.set(id, info);
triggerRef(allItems);
}
function onItemUnregister(id: string) {
allItems.value.delete(id);
triggerRef(allItems);
}
function onGroupRegister(groupId: string) {
if (!allGroups.value.has(groupId)) {
allGroups.value.set(groupId, new Set());
triggerRef(allGroups);
}
}
function onGroupUnregister(groupId: string) {
allGroups.value.delete(groupId);
triggerRef(allGroups);
}
function onGroupItemRegister(groupId: string, itemId: string) {
let set = allGroups.value.get(groupId);
if (!set) {
set = new Set();
allGroups.value.set(groupId, set);
}
set.add(itemId);
triggerRef(allGroups);
}
function onGroupItemUnregister(groupId: string, itemId: string) {
const set = allGroups.value.get(groupId);
if (set) {
set.delete(itemId);
triggerRef(allGroups);
}
}
const filterRef = toRef(() => filterFunction);
const ignoreFilterRef = toRef(() => ignoreFilter);
const filterState = computed<ComboboxFilterState>(() => {
const items = allItems.value;
const groups = allGroups.value;
if (!searchTerm.value || ignoreFilterRef.value || !isUserInputted.value) {
return {
count: items.size,
items: new Set(items.keys()),
groups: new Set(groups.keys()),
};
}
const candidates: ComboboxFilterItem[] = [];
for (const [id, info] of items) candidates.push({ id, textValue: info.textValue });
const fn = filterRef.value ?? defaultFilter;
const filtered = fn(candidates, searchTerm.value);
const visibleItems = new Set<string>();
for (let i = 0; i < filtered.length; i++) visibleItems.add(filtered[i]!.id);
const visibleGroups = new Set<string>();
for (const [groupId, set] of groups) {
for (const itemId of set) {
if (visibleItems.has(itemId)) {
visibleGroups.add(groupId);
break;
}
}
}
return {
count: visibleItems.size,
items: visibleItems,
groups: visibleGroups,
};
});
function isSelected(v: T): boolean {
return valueComparator(value.value as T | T[] | undefined, v, by);
}
function commitValue(next: T | T[] | undefined) {
value.value = next;
emit('update:modelValue', next);
}
function onValueChange(v: T) {
if (multiple) {
const cur = Array.isArray(value.value) ? [...(value.value as T[])] : [];
const idx = cur.findIndex(i => valueComparator(i, v, by));
if (idx === -1) cur.push(v);
else cur.splice(idx, 1);
commitValue(cur);
inputElement.value?.focus();
}
else {
commitValue(v);
open.value = false;
}
}
function onOpenChange(next: boolean) {
open.value = next;
if (next) {
isUserInputted.value = false;
searchTerm.value = '';
nextTick(() => {
inputElement.value?.focus();
highlightSelectedOrFirst();
});
}
else {
setTimeout(() => {
if (resetSearchTermOnBlur) searchTerm.value = '';
isUserInputted.value = false;
}, 1);
}
}
function onSelectedValueChange(v: T | undefined, id?: string) {
selectedValue.value = v;
selectedValueId.value = id;
}
function getVisibleItemElements(): HTMLElement[] {
const root = contentElement.value ?? parentElement.value;
if (!root) return [];
const all = Array.from(root.querySelectorAll<HTMLElement>('[data-primitives-combobox-item]'));
const visible: HTMLElement[] = [];
const filterIds = filterState.value.items;
for (let i = 0; i < all.length; i++) {
const el = all[i]!;
if (el.dataset['disabled'] === '') continue;
const id = el.id;
if (!id || filterIds.has(id)) visible.push(el);
}
return visible;
}
function readValueFromElement(el: HTMLElement): T | undefined {
const id = el.id;
if (!id) return undefined;
return allItems.value.get(id)?.value;
}
function highlightItemById(id: string | undefined) {
if (!id) {
selectedValue.value = undefined;
selectedValueId.value = undefined;
return;
}
const info = allItems.value.get(id);
if (!info) return;
selectedValue.value = info.value;
selectedValueId.value = id;
const root = contentElement.value ?? parentElement.value;
const el = root?.querySelector<HTMLElement>(`#${CSS.escape(id)}`);
el?.scrollIntoView({ block: 'nearest' });
}
function highlightFirstItem() {
const els = getVisibleItemElements();
if (els.length === 0) {
selectedValue.value = undefined;
selectedValueId.value = undefined;
return;
}
highlightItemById(els[0]!.id);
}
function highlightSelectedOrFirst() {
const cur = value.value;
if (cur !== undefined && !Array.isArray(cur)) {
for (const [id, info] of allItems.value) {
if (valueComparator(cur, info.value, by) && !info.disabled) {
highlightItemById(id);
return;
}
}
}
highlightFirstItem();
}
watch(open, (isOpen) => {
if (!isOpen) {
selectedValue.value = undefined;
selectedValueId.value = undefined;
}
});
function onSearchTermChange(v: string) {
searchTerm.value = v;
}
function onUserInputtedChange(v: boolean) {
isUserInputted.value = v;
}
provideComboboxRootContext({
modelValue: value,
onValueChange,
multiple: toRef(() => multiple),
open,
onOpenChange,
disabled: toRef(() => disabled),
dir: direction,
name: toRef(() => name),
required: toRef(() => required),
by,
isSelected,
searchTerm,
onSearchTermChange,
resetSearchTermOnBlur: toRef(() => resetSearchTermOnBlur),
resetSearchTermOnSelect: toRef(() => resetSearchTermOnSelect),
ignoreFilter: ignoreFilterRef,
filterFunction: filterRef,
displayValue: displayValue as ((v: unknown) => string) | undefined,
isUserInputted,
onUserInputtedChange,
contentId,
triggerElement,
onTriggerChange: (el) => { triggerElement.value = el; },
inputElement,
onInputChange: (el) => { inputElement.value = el; },
contentElement,
onContentChange: (el) => { contentElement.value = el; },
parentElement,
onParentChange: (el) => { parentElement.value = el; },
selectedValue,
selectedValueId,
onSelectedValueChange,
allItems,
onItemRegister,
onItemUnregister,
allGroups,
onGroupRegister,
onGroupUnregister,
onGroupItemRegister,
onGroupItemUnregister,
filterState,
getVisibleItemElements,
highlightItemById,
highlightFirstItem,
});
defineExpose({
filterState,
highlightFirstItem,
highlightItemById,
// Avoid unused warnings — surfaced for advanced consumers
readValueFromElement,
});
</script>
<template>
<PopperRoot>
<slot :open="open" :model-value="value" />
<input
v-if="name"
type="hidden"
:name="name"
:value="Array.isArray(value) ? JSON.stringify(value) : (value ?? '')"
:required="required"
:disabled="disabled"
aria-hidden="true"
style="display: none"
tabindex="-1"
/>
</PopperRoot>
</template>
@@ -0,0 +1,26 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ComboboxSeparatorProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
const { as = 'div' } = defineProps<ComboboxSeparatorProps>();
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="separator"
aria-orientation="horizontal"
aria-hidden="true"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,53 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ComboboxTriggerProps extends PrimitiveProps {
/** Disable the trigger independently from the root. */
disabled?: boolean;
}
</script>
<script setup lang="ts">
import { computed, onBeforeUnmount, watchPostEffect } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useComboboxRootContext } from './context';
import { getOpenState } from './utils';
const { as = 'button', disabled = false } = defineProps<ComboboxTriggerProps>();
const { forwardRef, currentElement } = useForwardExpose();
const rootCtx = useComboboxRootContext();
const isDisabled = computed(() => disabled || rootCtx.disabled.value);
watchPostEffect(() => rootCtx.onTriggerChange(currentElement.value));
onBeforeUnmount(() => rootCtx.onTriggerChange(undefined));
function handleClick(event: MouseEvent) {
if (isDisabled.value) return;
event.preventDefault();
rootCtx.onOpenChange(!rootCtx.open.value);
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
tabindex="-1"
aria-haspopup="listbox"
aria-label="Show options"
:aria-controls="rootCtx.contentId.value"
:aria-expanded="rootCtx.open.value"
:aria-disabled="isDisabled || undefined"
:disabled="isDisabled || undefined"
:data-state="getOpenState(rootCtx.open.value)"
:data-disabled="isDisabled ? '' : undefined"
@click="handleClick"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,32 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ComboboxViewportProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { watchPostEffect } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useComboboxContentContext } from './context';
const { as = 'div' } = defineProps<ComboboxViewportProps>();
const { forwardRef, currentElement } = useForwardExpose();
const contentCtx = useComboboxContentContext();
watchPostEffect(() => contentCtx.onViewportChange(currentElement.value));
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="presentation"
data-primitives-combobox-viewport
style="position: relative; flex: 1 1 0%; overflow: hidden auto"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,81 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { userEvent } from 'vitest/browser';
import { defineComponent, h, nextTick, ref } from 'vue';
import {
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxPortal,
ComboboxRoot,
ComboboxTrigger,
ComboboxViewport,
} from '../index';
function mountCombobox() {
const search = ref('');
const Harness = defineComponent({
setup: () => () => h(ComboboxRoot, { defaultOpen: true, multiple: false }, {
default: () => [
h(ComboboxTrigger, { id: 'trigger' }, {
default: () => h(ComboboxInput, {
id: 'input',
'onUpdate:searchTerm': (v: string) => { search.value = v; },
}),
}),
h(ComboboxPortal, {}, {
default: () => h(ComboboxContent, {}, {
default: () => h(ComboboxViewport, {}, {
default: () => [
h(ComboboxItem, { value: 'apple', textValue: 'Apple' }, { default: () => 'Apple' }),
h(ComboboxItem, { value: 'banana', textValue: 'Banana' }, { default: () => 'Banana' }),
h(ComboboxItem, { value: 'cherry', textValue: 'Cherry' }, { default: () => 'Cherry' }),
],
}),
}),
}),
],
}),
});
return { wrapper: mount(Harness, { attachTo: document.body }), search };
}
function getLiveRegion(): HTMLElement | null {
return document.querySelector('[data-primitives-combobox-announce]');
}
describe('Combobox — filtered-results live region', () => {
it('announces "N results available." reflecting the unfiltered count on open', async () => {
const { wrapper } = mountCombobox();
await nextTick();
await nextTick();
await nextTick();
const live = getLiveRegion();
expect(live).toBeTruthy();
expect(live!.getAttribute('role')).toBe('status');
expect(live!.getAttribute('aria-live')).toBe('polite');
expect(live!.textContent?.trim()).toBe('3 results available.');
wrapper.unmount();
});
it('updates the count as the search term filters items', async () => {
const { wrapper } = mountCombobox();
await nextTick();
await nextTick();
await nextTick();
const input = document.querySelector<HTMLInputElement>('#input')!;
await userEvent.click(input);
await userEvent.type(input, 'app');
await nextTick();
await nextTick();
expect(getLiveRegion()!.textContent?.trim()).toBe('1 result available.');
await userEvent.clear(input);
await userEvent.type(input, 'zz');
await nextTick();
await nextTick();
expect(getLiveRegion()!.textContent?.trim()).toBe('0 results available.');
wrapper.unmount();
});
});
+112
View File
@@ -0,0 +1,112 @@
import type { ComputedRef, Ref, ShallowRef } from 'vue';
import type { Direction } from '../config-provider';
import type { AcceptableValue, ComboboxFilterFunction } from './utils';
import { useContextFactory } from '@robonen/vue';
export interface ComboboxItemInfo<T = AcceptableValue> {
value: T;
textValue: string;
disabled: boolean;
}
export interface ComboboxFilterState {
count: number;
items: Set<string>;
groups: Set<string>;
}
export interface ComboboxRootContext<T = AcceptableValue> {
modelValue: Ref<T | T[] | undefined>;
onValueChange: (value: T) => void;
multiple: Ref<boolean>;
open: Ref<boolean>;
onOpenChange: (open: boolean) => void;
disabled: Ref<boolean>;
dir: Ref<Direction>;
name: Ref<string | undefined>;
required: Ref<boolean>;
by?: string | ((a: T, b: T) => boolean);
isSelected: (value: T) => boolean;
searchTerm: Ref<string>;
onSearchTermChange: (value: string) => void;
resetSearchTermOnBlur: Ref<boolean>;
resetSearchTermOnSelect: Ref<boolean>;
ignoreFilter: Ref<boolean>;
filterFunction: Ref<ComboboxFilterFunction | undefined>;
displayValue?: (value: T | T[] | undefined) => string;
isUserInputted: Ref<boolean>;
onUserInputtedChange: (value: boolean) => void;
contentId: Ref<string>;
triggerElement: ShallowRef<HTMLElement | undefined>;
onTriggerChange: (el: HTMLElement | undefined) => void;
inputElement: ShallowRef<HTMLInputElement | undefined>;
onInputChange: (el: HTMLInputElement | undefined) => void;
contentElement: ShallowRef<HTMLElement | undefined>;
onContentChange: (el: HTMLElement | undefined) => void;
parentElement: ShallowRef<HTMLElement | undefined>;
onParentChange: (el: HTMLElement | undefined) => void;
selectedValue: ShallowRef<T | undefined>;
selectedValueId: Ref<string | undefined>;
onSelectedValueChange: (value: T | undefined, id?: string) => void;
allItems: ShallowRef<Map<string, ComboboxItemInfo<T>>>;
onItemRegister: (id: string, info: ComboboxItemInfo<T>) => void;
onItemUnregister: (id: string) => void;
allGroups: ShallowRef<Map<string, Set<string>>>;
onGroupRegister: (groupId: string) => void;
onGroupUnregister: (groupId: string) => void;
onGroupItemRegister: (groupId: string, itemId: string) => void;
onGroupItemUnregister: (groupId: string, itemId: string) => void;
filterState: ComputedRef<ComboboxFilterState>;
/** Returns visible, enabled item elements in DOM order. */
getVisibleItemElements: () => HTMLElement[];
/** Highlights an item element by its id. */
highlightItemById: (id: string | undefined) => void;
/** Highlights the first visible item. */
highlightFirstItem: () => void;
}
export interface ComboboxContentContext {
viewportElement: ShallowRef<HTMLElement | undefined>;
onViewportChange: (el: HTMLElement | undefined) => void;
position: Ref<'inline' | 'popper'>;
}
export interface ComboboxGroupContext {
id: Ref<string>;
}
export interface ComboboxItemContext<T = AcceptableValue> {
id: Ref<string>;
value: T;
textValue: Ref<string>;
isSelected: Ref<boolean>;
isDisabled: Ref<boolean>;
}
export const {
inject: useComboboxRootContext,
provide: provideComboboxRootContext,
} = useContextFactory<ComboboxRootContext<any>>('ComboboxRoot');
export const {
inject: useComboboxContentContext,
provide: provideComboboxContentContext,
} = useContextFactory<ComboboxContentContext>('ComboboxContent');
export const {
inject: useComboboxGroupContext,
provide: provideComboboxGroupContext,
} = useContextFactory<ComboboxGroupContext>('ComboboxGroup');
export const {
inject: useComboboxItemContext,
provide: provideComboboxItemContext,
} = useContextFactory<ComboboxItemContext<any>>('ComboboxItem');
+51
View File
@@ -0,0 +1,51 @@
export { default as ComboboxAnchor } from './ComboboxAnchor.vue';
export { default as ComboboxArrow } from './ComboboxArrow.vue';
export { default as ComboboxCancel } from './ComboboxCancel.vue';
export { default as ComboboxContent } from './ComboboxContent.vue';
export { default as ComboboxContentImpl } from './ComboboxContentImpl.vue';
export { default as ComboboxEmpty } from './ComboboxEmpty.vue';
export { default as ComboboxGroup } from './ComboboxGroup.vue';
export { default as ComboboxInput } from './ComboboxInput.vue';
export { default as ComboboxItem } from './ComboboxItem.vue';
export { default as ComboboxItemIndicator } from './ComboboxItemIndicator.vue';
export { default as ComboboxLabel } from './ComboboxLabel.vue';
export { default as ComboboxPortal } from './ComboboxPortal.vue';
export { default as ComboboxRoot } from './ComboboxRoot.vue';
export { default as ComboboxSeparator } from './ComboboxSeparator.vue';
export { default as ComboboxTrigger } from './ComboboxTrigger.vue';
export { default as ComboboxViewport } from './ComboboxViewport.vue';
export {
useComboboxContentContext,
useComboboxGroupContext,
useComboboxItemContext,
useComboboxRootContext,
} from './context';
export type {
ComboboxContentContext,
ComboboxFilterState,
ComboboxGroupContext,
ComboboxItemContext,
ComboboxItemInfo,
ComboboxRootContext,
} from './context';
export type { AcceptableValue, ComboboxFilterFunction, ComboboxFilterItem } from './utils';
export type { ComboboxAnchorProps } from './ComboboxAnchor.vue';
export type { ComboboxArrowProps } from './ComboboxArrow.vue';
export type { ComboboxCancelProps } from './ComboboxCancel.vue';
export type { ComboboxContentEmits, ComboboxContentProps } from './ComboboxContent.vue';
export type { ComboboxContentImplEmits, ComboboxContentImplProps } from './ComboboxContentImpl.vue';
export type { ComboboxEmptyProps } from './ComboboxEmpty.vue';
export type { ComboboxGroupProps } from './ComboboxGroup.vue';
export type { ComboboxInputProps } from './ComboboxInput.vue';
export type { ComboboxItemIndicatorProps } from './ComboboxItemIndicator.vue';
export type { ComboboxItemProps } from './ComboboxItem.vue';
export type { ComboboxLabelProps } from './ComboboxLabel.vue';
export type { ComboboxPortalProps } from './ComboboxPortal.vue';
export type { ComboboxRootEmits, ComboboxRootProps } from './ComboboxRoot.vue';
export type { ComboboxSeparatorProps } from './ComboboxSeparator.vue';
export type { ComboboxTriggerProps } from './ComboboxTrigger.vue';
export type { ComboboxViewportProps } from './ComboboxViewport.vue';
+57
View File
@@ -0,0 +1,57 @@
export type AcceptableValue = string | number | boolean | Record<string, unknown>;
export const OPEN_KEYS = ['Enter', ' ', 'ArrowDown', 'ArrowUp', 'PageUp', 'PageDown', 'Home', 'End'];
export const SELECTION_KEYS = ['Enter', ' '];
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
export function getOpenState(open: boolean): 'open' | 'closed' {
return open ? 'open' : 'closed';
}
export function compare<T>(
a: T | undefined,
b: T | undefined,
by?: string | ((a: T, b: T) => boolean),
): boolean {
if (a === undefined || b === undefined) return false;
if (by === undefined) return a === b;
if (typeof by === 'function') return by(a as T, b as T);
return (a as any)?.[by] === (b as any)?.[by];
}
export function valueComparator<T>(
value: T | T[] | undefined,
current: T,
by?: string | ((a: T, b: T) => boolean),
): boolean {
if (value === undefined) return false;
if (!Array.isArray(value)) return compare(value, current, by);
for (const v of value) {
if (compare(v, current, by)) return true;
}
return false;
}
export interface ComboboxFilterItem {
id: string;
textValue: string;
}
export type ComboboxFilterFunction = (
items: ComboboxFilterItem[],
searchTerm: string,
) => ComboboxFilterItem[];
export const defaultFilter: ComboboxFilterFunction = (items, searchTerm) => {
const term = searchTerm.toLowerCase();
if (!term) return items;
const out: ComboboxFilterItem[] = [];
for (let i = 0; i < items.length; i++) {
const it = items[i]!;
if (it.textValue.toLowerCase().includes(term)) out.push(it);
}
return out;
};
@@ -0,0 +1,39 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CommandEmptyProps extends PrimitiveProps {
/** Render even while there is no active search term. */
always?: boolean;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useCommandContext } from './context';
const { as = 'div', always = false } = defineProps<CommandEmptyProps>();
const { forwardRef } = useForwardExpose();
const ctx = useCommandContext();
const shouldRender = computed(() => {
if (ctx.filteredItems.value.size !== 0) return false;
if (always) return true;
return ctx.searchTerm.value.length > 0;
});
</script>
<template>
<Primitive
v-if="shouldRender"
:ref="forwardRef"
:as="as"
role="presentation"
data-primitives-command-empty
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,73 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CommandGroupProps extends PrimitiveProps {
/** Group heading text (rendered when the default slot doesn't override it). */
heading?: string;
/** Stable identifier for the group. Auto-generated when omitted. */
value?: string;
/** Render the group even when all of its items are filtered out. */
forceMount?: boolean;
}
</script>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, toRef } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useId } from '../config-provider';
import { Primitive } from '../primitive';
import { provideCommandGroupContext, useCommandContext } from './context';
const {
as = 'div',
heading,
value,
forceMount = false,
} = defineProps<CommandGroupProps>();
const { forwardRef } = useForwardExpose();
const ctx = useCommandContext();
const id = useId(() => value, 'command-group');
const headingId = useId(undefined, 'command-group-heading');
const hasVisibleItem = computed(() => {
const set = ctx.allGroups.value.get(id.value);
if (!set || set.size === 0) return false;
for (const v of set) {
const info = ctx.allItems.value.get(v);
if (!info || info.disabled) continue;
if (ctx.filteredItems.value.has(v)) return true;
}
return false;
});
const isVisible = computed(() => forceMount || hasVisibleItem.value);
onMounted(() => ctx.registerGroup(id.value));
onBeforeUnmount(() => ctx.unregisterGroup(id.value));
provideCommandGroupContext({
id,
forceMount: toRef(() => forceMount),
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="presentation"
:data-primitives-state="isVisible ? 'visible' : 'hidden'"
:hidden="!isVisible || undefined"
data-primitives-command-group
>
<div v-if="heading" :id="headingId" data-primitives-command-group-heading>
{{ heading }}
</div>
<div role="group" :aria-labelledby="heading ? headingId : undefined">
<slot />
</div>
</Primitive>
</template>
+165
View File
@@ -0,0 +1,165 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CommandInputProps extends PrimitiveProps {
/** Controlled value; falls back to root `searchTerm`. */
modelValue?: string;
/** Disable the input. */
disabled?: boolean;
/** Focus the input on mount. */
autoFocus?: boolean;
}
export interface CommandInputEmits {
'update:modelValue': [value: string];
'update:searchTerm': [value: string];
}
</script>
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useCommandContext } from './context';
const {
as = 'input',
modelValue,
disabled = false,
autoFocus = false,
} = defineProps<CommandInputProps>();
const emit = defineEmits<CommandInputEmits>();
const { forwardRef, currentElement } = useForwardExpose();
const ctx = useCommandContext();
const activeDescendant = computed(() => {
const v = ctx.selectedValue.value;
return v === undefined ? undefined : ctx.getItemId(v);
});
onMounted(() => {
const el = currentElement.value as HTMLInputElement | undefined;
if (!el) return;
if (modelValue !== undefined && modelValue !== ctx.searchTerm.value) {
ctx.setSearchTerm(modelValue);
}
if (el.value !== ctx.searchTerm.value) el.value = ctx.searchTerm.value;
if (autoFocus) setTimeout(() => el.focus(), 0);
});
watch(
() => modelValue,
(v) => {
if (v === undefined) return;
if (v !== ctx.searchTerm.value) ctx.setSearchTerm(v);
},
);
watch(
() => ctx.searchTerm.value,
(v) => {
const el = currentElement.value as HTMLInputElement | undefined;
if (el && el.value !== v) el.value = v;
},
);
function moveBy(delta: number) {
const items = ctx.getSelectableItems();
if (items.length === 0) return;
const cur = ctx.selectedValue.value;
const idx = cur === undefined ? -1 : items.indexOf(cur);
let next: number;
if (idx === -1) {
next = delta > 0 ? 0 : items.length - 1;
}
else {
next = idx + delta;
if (ctx.loop.value) {
next = (next + items.length) % items.length;
}
else {
if (next < 0) next = 0;
if (next > items.length - 1) next = items.length - 1;
}
}
ctx.setSelectedValue(items[next]);
scrollSelectedIntoView();
}
function moveTo(position: 'first' | 'last') {
const items = ctx.getSelectableItems();
if (items.length === 0) return;
ctx.setSelectedValue(position === 'first' ? items[0] : items[items.length - 1]);
scrollSelectedIntoView();
}
function scrollSelectedIntoView() {
const v = ctx.selectedValue.value;
const root = ctx.listElement.value;
if (v === undefined || !root) return;
const id = ctx.getItemId(v);
const el = root.querySelector<HTMLElement>(`#${CSS.escape(id)}`);
el?.scrollIntoView({ block: 'nearest' });
}
function handleInput(event: Event) {
const next = (event.target as HTMLInputElement).value;
ctx.setSearchTerm(next);
emit('update:modelValue', next);
emit('update:searchTerm', next);
}
function handleKeyDown(event: KeyboardEvent) {
if (disabled) return;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
moveBy(1);
break;
case 'ArrowUp':
event.preventDefault();
moveBy(-1);
break;
case 'Home':
event.preventDefault();
moveTo('first');
break;
case 'End':
event.preventDefault();
moveTo('last');
break;
case 'Enter':
if (ctx.selectedValue.value !== undefined) {
event.preventDefault();
ctx.commitSelected();
}
break;
}
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
type="text"
role="combobox"
autocomplete="off"
spellcheck="false"
aria-autocomplete="list"
:aria-expanded="true"
:aria-controls="ctx.listId.value"
:aria-activedescendant="activeDescendant"
:aria-disabled="disabled || undefined"
:disabled="disabled || undefined"
:data-disabled="disabled ? '' : undefined"
data-primitives-command-input
@input="handleInput"
@keydown="handleKeyDown"
>
<slot />
</Primitive>
</template>
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CommandItemProps extends PrimitiveProps {
/** Item value — used by filter, selection, and `data-value`. */
value: string;
/** Extra terms the default filter should match against. */
keywords?: string[];
/** Disable this item — it is skipped by keyboard nav and filtering. */
disabled?: boolean;
/** Render even when filtered out. */
forceMount?: boolean;
}
export interface CommandItemEmits {
select: [value: string];
}
</script>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useCommandContext, useCommandGroupContext } from './context';
const {
as = 'div',
value,
keywords,
disabled = false,
forceMount = false,
} = defineProps<CommandItemProps>();
const emit = defineEmits<CommandItemEmits>();
const { forwardRef } = useForwardExpose();
const ctx = useCommandContext();
let groupCtx: ReturnType<typeof useCommandGroupContext> | null = null;
try {
groupCtx = useCommandGroupContext();
}
catch {
groupCtx = null;
}
const itemId = computed(() => ctx.getItemId(value));
const isVisible = computed(() => forceMount || ctx.filteredItems.value.has(value));
const isHighlighted = computed(() => ctx.selectedValue.value === value);
const isSelected = computed(() => ctx.modelValue.value === value);
function syncRegistration() {
ctx.registerItem({
value,
keywords: keywords ?? [],
disabled,
onSelect: () => emit('select', value),
});
}
onMounted(() => {
syncRegistration();
if (groupCtx) ctx.registerGroupItem(groupCtx.id.value, value);
});
watch(
() => [value, disabled, (keywords ?? []).join('\u0001')] as const,
(_next, prev) => {
const [prevValue] = prev ?? [];
if (prevValue !== undefined && prevValue !== value) {
ctx.unregisterItem(prevValue);
if (groupCtx) ctx.unregisterGroupItem(groupCtx.id.value, prevValue);
syncRegistration();
if (groupCtx) ctx.registerGroupItem(groupCtx.id.value, value);
}
else {
syncRegistration();
}
},
);
onBeforeUnmount(() => {
ctx.unregisterItem(value);
if (groupCtx) ctx.unregisterGroupItem(groupCtx.id.value, value);
});
function handlePointerMove(event: PointerEvent) {
if (disabled) return;
// Only react to genuine mouse / pen movement; keyboard nav already manages highlight.
if (event.pointerType === 'touch') return;
if (ctx.selectedValue.value !== value) ctx.setSelectedValue(value);
}
function handleClick(event: MouseEvent) {
if (disabled) {
event.preventDefault();
return;
}
event.preventDefault();
ctx.setSelectedValue(value);
ctx.commitSelected();
}
</script>
<template>
<Primitive
v-show="isVisible"
:ref="forwardRef"
:id="itemId"
:as="as"
role="option"
:aria-selected="isHighlighted || undefined"
:aria-disabled="disabled || undefined"
:data-state="isHighlighted ? 'selected' : ''"
:data-selected="isSelected ? '' : undefined"
:data-disabled="disabled ? '' : undefined"
:data-primitives-state="isVisible ? 'visible' : 'hidden'"
:tabindex="-1"
data-primitives-command-item
:data-value="value"
@click="handleClick"
@pointermove="handlePointerMove"
>
<slot :highlighted="isHighlighted" :selected="isSelected" :disabled="disabled" />
</Primitive>
</template>
@@ -0,0 +1,92 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CommandListProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useCommandContext } from './context';
const { as = 'div' } = defineProps<CommandListProps>();
const { forwardRef, currentElement } = useForwardExpose();
const ctx = useCommandContext();
let resizeObserver: ResizeObserver | undefined;
let observedChild: Element | undefined;
function setHeight(height: number) {
const list = currentElement.value as HTMLElement | undefined;
if (!list) return;
list.style.setProperty('--primitives-command-list-height', `${height}px`);
}
function observeFirstChild() {
const list = currentElement.value as HTMLElement | undefined;
if (!list) return;
const child = list.firstElementChild ?? undefined;
if (child === observedChild) return;
if (resizeObserver && observedChild) resizeObserver.unobserve(observedChild);
observedChild = child;
if (!child) {
setHeight(0);
return;
}
resizeObserver?.observe(child);
setHeight((child as HTMLElement).offsetHeight);
}
onMounted(() => {
const list = currentElement.value as HTMLElement | undefined;
if (!list) return;
ctx.setListElement(list);
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const target = entry.target as HTMLElement;
setHeight(target.offsetHeight);
}
});
}
observeFirstChild();
// React to subtree changes (items added/removed/reordered).
const mo = new MutationObserver(observeFirstChild);
mo.observe(list, { childList: true });
onBeforeUnmount(() => {
mo.disconnect();
resizeObserver?.disconnect();
resizeObserver = undefined;
observedChild = undefined;
ctx.setListElement(undefined);
});
});
// Re-evaluate the observed child whenever the filter result changes (items hide/show).
watch(
() => ctx.filteredItems.value,
() => observeFirstChild(),
);
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:id="ctx.listId.value"
role="listbox"
:aria-labelledby="ctx.labelId.value"
data-primitives-command-list
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,40 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CommandLoadingProps extends PrimitiveProps {
/** Accessible label describing the loading state. */
label?: string;
/** Optional 0..100 progress value — published via `aria-valuenow`. */
progress?: number;
}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
const {
as = 'div',
label = 'Loading',
progress,
} = defineProps<CommandLoadingProps>();
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="progressbar"
:aria-valuetext="label"
:aria-valuenow="progress"
:aria-valuemin="progress === undefined ? undefined : 0"
:aria-valuemax="progress === undefined ? undefined : 100"
aria-live="polite"
data-primitives-command-loading
>
<slot :progress="progress" />
</Primitive>
</template>
+287
View File
@@ -0,0 +1,287 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
import type { CommandFilterFunction } from './utils';
export interface CommandRootProps extends PrimitiveProps {
/** Controlled selected value. Use `v-model`. */
modelValue?: string;
/** Uncontrolled initial selected value. */
defaultValue?: string;
/** Controlled search term. Use `v-model:searchTerm`. */
searchTerm?: string;
/** Uncontrolled initial search term. */
defaultSearchTerm?: string;
/** Custom scoring filter. Returns 0..1 (0 = hide). */
filter?: CommandFilterFunction;
/** Run the filter automatically. Set false to perform filtering yourself. @default true */
shouldFilter?: boolean;
/** Loop keyboard navigation at the ends of the list. @default false */
loop?: boolean;
/** Accessible label announced to assistive tech. */
label?: string;
}
export interface CommandRootEmits {
'update:modelValue': [value: string | undefined];
'update:searchTerm': [value: string];
}
</script>
<script setup lang="ts">
import { computed, ref, shallowRef, toRef, triggerRef, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useId } from '../config-provider';
import { Primitive } from '../primitive';
import { VisuallyHidden } from '../visually-hidden';
import { provideCommandContext } from './context';
import type { CommandItemInfo } from './context';
import { COMMAND_ITEM_ATTR, COMMAND_VALUE_ATTR, defaultFilter } from './utils';
defineOptions({ inheritAttrs: false });
const {
as = 'div',
defaultValue,
defaultSearchTerm = '',
filter,
shouldFilter = true,
loop = false,
label,
} = defineProps<CommandRootProps>();
const emit = defineEmits<CommandRootEmits>();
const { forwardRef } = useForwardExpose();
const localValue = ref<string | undefined>(defaultValue);
const value = defineModel<string | undefined>('modelValue', {
default: undefined,
get: v => v ?? localValue.value,
set: (v) => {
localValue.value = v;
return v;
},
});
const localSearch = ref<string>(defaultSearchTerm);
const search = defineModel<string>('searchTerm', {
default: undefined,
get: v => v ?? localSearch.value,
set: (v) => {
localSearch.value = v;
return v;
},
});
const selectedValue = ref<string | undefined>(undefined);
const listId = useId(undefined, 'command-list');
const labelId = useId(undefined, 'command-label');
const listElement = shallowRef<HTMLElement | undefined>(undefined);
const allItems = shallowRef(new Map<string, CommandItemInfo>());
const allGroups = shallowRef(new Map<string, Set<string>>());
function registerItem(info: CommandItemInfo) {
allItems.value.set(info.value, info);
triggerRef(allItems);
}
function unregisterItem(value: string) {
if (allItems.value.delete(value)) triggerRef(allItems);
if (selectedValue.value === value) selectedValue.value = undefined;
}
function registerGroup(groupId: string) {
if (!allGroups.value.has(groupId)) {
allGroups.value.set(groupId, new Set());
triggerRef(allGroups);
}
}
function unregisterGroup(groupId: string) {
if (allGroups.value.delete(groupId)) triggerRef(allGroups);
}
function registerGroupItem(groupId: string, val: string) {
let set = allGroups.value.get(groupId);
if (!set) {
set = new Set();
allGroups.value.set(groupId, set);
}
set.add(val);
triggerRef(allGroups);
}
function unregisterGroupItem(groupId: string, val: string) {
const set = allGroups.value.get(groupId);
if (set?.delete(val)) triggerRef(allGroups);
}
const filterRef = toRef(() => filter);
const shouldFilterRef = toRef(() => shouldFilter);
const filteredItems = computed<Map<string, number>>(() => {
const out = new Map<string, number>();
const term = search.value;
const useFilter = shouldFilterRef.value && term.length > 0;
const fn = filterRef.value ?? defaultFilter;
for (const [val, info] of allItems.value) {
const score = useFilter ? fn(val, term, info.keywords) : 1;
if (score > 0) out.set(val, score);
}
return out;
});
function escapeAttr(v: string): string {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') return CSS.escape(v);
return v.replace(/["\\]/g, '\\$&');
}
function getSelectableItems(): string[] {
const filtered = filteredItems.value;
const root = listElement.value;
const candidates: Array<{ value: string; score: number; idx: number }> = [];
if (root) {
const els = Array.from(root.querySelectorAll<HTMLElement>(`[${COMMAND_ITEM_ATTR}]`));
const indexOf = new Map<string, number>();
for (let i = 0; i < els.length; i++) {
const v = els[i]!.getAttribute(COMMAND_VALUE_ATTR);
if (v !== null) indexOf.set(v, i);
}
for (const [val, score] of filtered) {
const info = allItems.value.get(val);
if (!info || info.disabled) continue;
candidates.push({ value: val, score, idx: indexOf.get(val) ?? Number.MAX_SAFE_INTEGER });
}
}
else {
let i = 0;
for (const [val, score] of filtered) {
const info = allItems.value.get(val);
if (!info || info.disabled) continue;
candidates.push({ value: val, score, idx: i++ });
}
}
candidates.sort((a, b) => b.score - a.score || a.idx - b.idx);
return candidates.map(c => c.value);
}
function getItemId(val: string): string {
return `${listId.value}-item-${escapeAttr(val)}`;
}
function setModelValue(v: string | undefined) {
value.value = v;
emit('update:modelValue', v);
}
function setSearchTerm(v: string) {
search.value = v;
emit('update:searchTerm', v);
}
function setSelectedValue(v: string | undefined) {
selectedValue.value = v;
}
function setListElement(el: HTMLElement | undefined) {
listElement.value = el;
}
function commitSelected() {
const v = selectedValue.value;
if (v === undefined) return;
const info = allItems.value.get(v);
if (!info || info.disabled) return;
setModelValue(v);
info.onSelect?.();
}
// Auto-highlight the highest-scored visible item when items or search change.
watch(
[() => search.value, filteredItems, allItems],
() => {
const current = selectedValue.value;
if (current && filteredItems.value.has(current)) {
const info = allItems.value.get(current);
if (info && !info.disabled) return;
}
const items = getSelectableItems();
selectedValue.value = items[0];
},
{ flush: 'post' },
);
const announceCount = computed(() => {
const n = filteredItems.value.size;
return n === 1 ? '1 result available.' : `${n} results available.`;
});
provideCommandContext({
modelValue: value,
setModelValue,
searchTerm: search,
setSearchTerm,
selectedValue,
setSelectedValue,
shouldFilter: toRef(() => shouldFilter),
loop: toRef(() => loop),
filterFunction: filterRef,
listId,
labelId,
getItemId,
allItems,
filteredItems,
registerItem,
unregisterItem,
allGroups,
registerGroup,
unregisterGroup,
registerGroupItem,
unregisterGroupItem,
listElement,
setListElement,
getSelectableItems,
commitSelected,
});
defineExpose({
filteredItems,
getSelectableItems,
selectedValue,
setSelectedValue,
commitSelected,
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="application"
:aria-label="label"
:aria-labelledby="label ? undefined : labelId"
data-primitives-command-root
v-bind="$attrs"
>
<VisuallyHidden :id="labelId" aria-hidden="true">
{{ label ?? 'Command palette' }}
</VisuallyHidden>
<VisuallyHidden role="status" aria-live="polite">
{{ announceCount }}
</VisuallyHidden>
<slot
:search-term="search"
:selected-value="selectedValue"
:model-value="value"
:filtered-count="filteredItems.size"
/>
</Primitive>
</template>
@@ -0,0 +1,37 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CommandSeparatorProps extends PrimitiveProps {
/** Render the separator even while the search term is active. */
alwaysRender?: boolean;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useCommandContext } from './context';
const { as = 'div', alwaysRender = false } = defineProps<CommandSeparatorProps>();
const { forwardRef } = useForwardExpose();
const ctx = useCommandContext();
const isVisible = computed(() => alwaysRender || ctx.searchTerm.value.length === 0);
</script>
<template>
<Primitive
v-if="isVisible"
:ref="forwardRef"
:as="as"
role="separator"
aria-orientation="horizontal"
aria-hidden="true"
data-primitives-command-separator
>
<slot />
</Primitive>
</template>
+71
View File
@@ -0,0 +1,71 @@
import type { ComputedRef, Ref, ShallowRef } from 'vue';
import type { CommandFilterFunction } from './utils';
import { useContextFactory } from '@robonen/vue';
export interface CommandItemInfo {
value: string;
keywords: string[];
disabled: boolean;
onSelect?: () => void;
}
export interface CommandContext {
/** Committed selected value (v-model). */
modelValue: Ref<string | undefined>;
setModelValue: (value: string | undefined) => void;
/** Current search term (v-model:searchTerm). */
searchTerm: Ref<string>;
setSearchTerm: (value: string) => void;
/** Currently highlighted item value (keyboard / pointer focus). */
selectedValue: Ref<string | undefined>;
setSelectedValue: (value: string | undefined) => void;
/** Behavior flags. */
shouldFilter: Ref<boolean>;
loop: Ref<boolean>;
filterFunction: Ref<CommandFilterFunction | undefined>;
/** A11y identifiers. */
listId: Ref<string>;
labelId: Ref<string>;
getItemId: (value: string) => string;
/** Registries. */
allItems: ShallowRef<Map<string, CommandItemInfo>>;
filteredItems: ComputedRef<Map<string, number>>;
registerItem: (info: CommandItemInfo) => void;
unregisterItem: (value: string) => void;
allGroups: ShallowRef<Map<string, Set<string>>>;
registerGroup: (groupId: string) => void;
unregisterGroup: (groupId: string) => void;
registerGroupItem: (groupId: string, value: string) => void;
unregisterGroupItem: (groupId: string, value: string) => void;
/** DOM. */
listElement: ShallowRef<HTMLElement | undefined>;
setListElement: (el: HTMLElement | undefined) => void;
/** Returns selectable item values sorted by score desc, then DOM order. */
getSelectableItems: () => string[];
/** Commits the currently-highlighted item: updates modelValue + fires its select callback. */
commitSelected: () => void;
}
export interface CommandGroupContext {
id: Ref<string>;
forceMount: Ref<boolean>;
}
export const {
inject: useCommandContext,
provide: provideCommandContext,
} = useContextFactory<CommandContext>('Command');
export const {
inject: useCommandGroupContext,
provide: provideCommandGroupContext,
} = useContextFactory<CommandGroupContext>('CommandGroup');
+30
View File
@@ -0,0 +1,30 @@
export { default as CommandEmpty } from './CommandEmpty.vue';
export { default as CommandGroup } from './CommandGroup.vue';
export { default as CommandInput } from './CommandInput.vue';
export { default as CommandItem } from './CommandItem.vue';
export { default as CommandList } from './CommandList.vue';
export { default as CommandLoading } from './CommandLoading.vue';
export { default as CommandRoot } from './CommandRoot.vue';
export { default as CommandSeparator } from './CommandSeparator.vue';
export {
useCommandContext,
useCommandGroupContext,
} from './context';
export type {
CommandContext,
CommandGroupContext,
CommandItemInfo,
} from './context';
export type { CommandFilterFunction } from './utils';
export type { CommandEmptyProps } from './CommandEmpty.vue';
export type { CommandGroupProps } from './CommandGroup.vue';
export type { CommandInputEmits, CommandInputProps } from './CommandInput.vue';
export type { CommandItemEmits, CommandItemProps } from './CommandItem.vue';
export type { CommandListProps } from './CommandList.vue';
export type { CommandLoadingProps } from './CommandLoading.vue';
export type { CommandRootEmits, CommandRootProps } from './CommandRoot.vue';
export type { CommandSeparatorProps } from './CommandSeparator.vue';
+34
View File
@@ -0,0 +1,34 @@
export type CommandFilterFunction = (
value: string,
search: string,
keywords?: string[],
) => number;
export const COMMAND_ITEM_ATTR = 'data-primitives-command-item';
export const COMMAND_VALUE_ATTR = 'data-value';
/**
* Default scoring filter.
*
* - Empty search → score 1 (item visible).
* - Case-insensitive substring match across `value` + `keywords` → 1.
* - In-order subsequence (loose fuzzy) match → 0.5.
* - Otherwise → 0 (hide).
*/
export const defaultFilter: CommandFilterFunction = (value, search, keywords) => {
if (!search) return 1;
const needle = search.toLowerCase();
const haystackParts = keywords && keywords.length > 0 ? [value, ...keywords] : [value];
const haystack = haystackParts.join(' ').toLowerCase();
if (haystack.includes(needle)) return 1;
let i = 0;
for (let h = 0; h < haystack.length && i < needle.length; h++) {
if (haystack[h] === needle[i]) i++;
}
if (i === needle.length) return 0.5;
return 0;
};
@@ -1,10 +1,11 @@
import { describe, expect, it } from 'vitest';
import { defineComponent, h } from 'vue';
import { computed, defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils';
import {
provideAppConfig,
provideConfig,
useConfig,
useId,
} from '..';
// --- useConfig ---
@@ -128,3 +129,69 @@ describe('provideAppConfig', () => {
wrapper.unmount();
});
});
// --- useId override ---
describe('useId (config override)', () => {
it('uses the toolkit fallback when no override is provided', () => {
const Child = defineComponent({
setup() {
const id = useId();
return { id };
},
render() {
return h('div', { 'data-id': this.id });
},
});
const wrapper = mount(Child);
expect(wrapper.find('div').attributes('data-id')).toMatch(/^robonen-/);
wrapper.unmount();
});
it('routes through a provided useId override', () => {
let count = 0;
const customUseId = (_deterministic?: unknown, prefix = 'x') => {
count += 1;
const n = count;
return computed(() => `${prefix}-${n}`);
};
const Child = defineComponent({
setup() {
const a = useId();
const b = useId(undefined, 'custom');
return { a, b };
},
render() {
return h('div', { 'data-a': this.a, 'data-b': this.b });
},
});
const wrapper = mount(Child, {
global: {
plugins: [app => provideAppConfig(app, { useId: customUseId })],
},
});
expect(wrapper.find('div').attributes('data-a')).toBe('x-1');
expect(wrapper.find('div').attributes('data-b')).toBe('custom-2');
wrapper.unmount();
});
it('respects deterministic id passed through the override', () => {
const Child = defineComponent({
setup() {
const id = useId(() => 'fixed-id');
return { id };
},
render() {
return h('div', { 'data-id': this.id });
},
});
const wrapper = mount(Child);
expect(wrapper.find('div').attributes('data-id')).toBe('fixed-id');
wrapper.unmount();
});
});
+13 -5
View File
@@ -1,22 +1,29 @@
import type { App, MaybeRefOrGetter, Ref, ShallowRef, UnwrapRef } from 'vue';
import type { App, ComputedRef, MaybeRefOrGetter, Ref, ShallowRef } from 'vue';
import { ref, shallowRef, toValue } from 'vue';
import { useContextFactory } from '@robonen/vue';
import { useId as toolkitUseId, useContextFactory } from '@robonen/vue';
export type Direction = 'ltr' | 'rtl';
export type UseIdFn = (
deterministic?: MaybeRefOrGetter<string | undefined>,
prefix?: string,
) => ComputedRef<string>;
export interface ConfigContext {
dir: Ref<Direction>;
teleportTarget: ShallowRef<string | HTMLElement>;
useId: UseIdFn;
}
export interface ConfigOptions {
dir?: MaybeRefOrGetter<Direction>;
teleportTarget?: MaybeRefOrGetter<string | HTMLElement>;
useId?: UseIdFn;
}
const DEFAULT_CONFIG: UnwrapRef<ConfigContext> = {
dir: 'ltr',
teleportTarget: 'body',
const DEFAULT_CONFIG = {
dir: 'ltr' as Direction,
teleportTarget: 'body' as string | HTMLElement,
};
const ConfigCtx = useContextFactory<ConfigContext>('ConfigContext');
@@ -25,6 +32,7 @@ function resolveContext(options?: ConfigOptions): ConfigContext {
return {
dir: ref(toValue(options?.dir) ?? DEFAULT_CONFIG.dir),
teleportTarget: shallowRef(toValue(options?.teleportTarget) ?? DEFAULT_CONFIG.teleportTarget),
useId: options?.useId ?? toolkitUseId,
};
}
@@ -5,4 +5,6 @@ export {
type ConfigContext,
type ConfigOptions,
type Direction,
type UseIdFn,
} from './context';
export { useId } from './useId';
@@ -0,0 +1,16 @@
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
import { useConfig } from './context';
/**
* Primitives-local `useId` that routes through the active `ConfigContext`.
* Falls back to the toolkit's default implementation when no override is
* configured via `provideConfig({ useId })`.
*
* Signature matches `@robonen/vue`'s `useId`: `(deterministic?, prefix?)`.
*/
export function useId(
deterministic?: MaybeRefOrGetter<string | undefined>,
prefix?: string,
): ComputedRef<string> {
return useConfig().useId(deterministic, prefix);
}
@@ -14,7 +14,7 @@ import { MenuAnchor, useMenuContext } from '../menu';
import { Primitive } from '../primitive';
import { useContextMenuRootContext } from './context';
const { disabled = false, as = 'span', asChild } = defineProps<ContextMenuTriggerProps>();
const { disabled = false, as = 'span' } = defineProps<ContextMenuTriggerProps>();
const menuCtx = useMenuContext();
const ctxMenuCtx = useContextMenuRootContext();
@@ -72,11 +72,10 @@ function handlePointerUp() {
</script>
<template>
<MenuAnchor as-child :reference="virtualEl">
<MenuAnchor :reference="virtualEl">
<Primitive
:ref="forwardRef"
:as="as"
:as-child="asChild"
:data-state="menuCtx.open.value ? 'open' : 'closed'"
:data-disabled="disabled ? '' : undefined"
@contextmenu="handleContextMenu"
@@ -0,0 +1,28 @@
<script lang="ts">
import type { PopperAnchorProps } from '../popper';
export interface DatePickerAnchorProps extends PopperAnchorProps {}
</script>
<script setup lang="ts">
import { onBeforeMount, onUnmounted } from 'vue';
import { PopperAnchor } from '../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,17 @@
<script lang="ts">
import type { PopperArrowProps } from '../popper';
export interface DatePickerArrowProps extends PopperArrowProps {}
</script>
<script setup lang="ts">
import { PopperArrow } from '../popper';
const { width = 10, height = 5 } = defineProps<DatePickerArrowProps>();
</script>
<template>
<PopperArrow :width="width" :height="height">
<slot />
</PopperArrow>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface DatePickerCalendarProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
const { as = 'div' } = defineProps<DatePickerCalendarProps>();
</script>
<template>
<Primitive :as="as" :data-primitives-date-picker-calendar="''">
<slot />
</Primitive>
</template>
@@ -0,0 +1,25 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface DatePickerCloseProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../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,75 @@
<script lang="ts">
import type { DismissableLayerEmits } from '../dismissable-layer';
import type { FocusScopeEmits } from '../focus-scope';
import type { PopperContentProps } from '../popper';
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 '../dismissable-layer';
import { FocusScope } from '../focus-scope';
import { PopperContent } from '../popper';
import { Presence } from '../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,67 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
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 { formatDate } from '../calendar';
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 displayValue = computed(() => {
if (!ctx.modelValue.value) return '';
return formatDate(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 = new Date(text);
if (!Number.isNaN(parsed.getTime()))
ctx.modelValue.value = new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate());
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,17 @@
<script lang="ts">
import type { TeleportPrimitiveProps } from '../teleport';
export interface DatePickerPortalProps extends TeleportPrimitiveProps {}
</script>
<script setup lang="ts">
import PortalPrimitive from '../teleport/Teleport.vue';
const props = defineProps<DatePickerPortalProps>();
</script>
<template>
<PortalPrimitive v-bind="props">
<slot />
</PortalPrimitive>
</template>
@@ -0,0 +1,333 @@
<script lang="ts">
import type { CalendarMonth, CalendarRootProps, WeekDayFormat } from '../calendar';
import type { PrimitiveProps } from '../primitive';
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;
/** Format used to serialize the hidden input value. @default 'iso' */
valueFormat?: 'iso' | ((d: Date) => string);
/** Close popover on selection. @default true */
closeOnSelect?: boolean;
}
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, toRef, watch } from 'vue';
import {
addMonths,
addYears,
clamp,
createMonths,
formatMonthYear,
getWeekdayLabels,
isAfter,
isBefore,
isSameDay,
isSameMonth,
isDateUnavailable as isUnavailable,
provideCalendarRootContext,
toDateOnly,
} from '../calendar';
import { useId } from '../config-provider';
import { PopperRoot } from '../popper';
import { Primitive } from '../primitive';
import { provideDatePickerRootContext } from './context';
defineOptions({ inheritAttrs: false });
const {
as = 'div',
defaultOpen = false,
modal = false,
name,
valueFormat = 'iso',
closeOnSelect = true,
defaultValue,
defaultPlaceholder,
minValue,
maxValue,
isDateUnavailable: propsIsDateUnavailable,
isDateDisabled: propsIsDateDisabled,
pagedNavigation = false,
weekStartsOn = 0,
weekdayFormat = 'short',
fixedWeeks = true,
numberOfMonths = 1,
disabled = false,
readonly = false,
initialFocus = false,
locale = 'en',
dir = 'ltr',
nextPage: propsNextPage,
prevPage: propsPrevPage,
calendarLabel = 'Calendar',
} = defineProps<DatePickerRootProps>();
defineEmits<DatePickerRootEmits>();
const { forwardRef, currentElement: parentElement } = useForwardExpose();
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>(
toDateOnly(defaultPlaceholder ?? modelValue.value ?? new Date()),
);
const placeholder = defineModel<Date>('placeholder', {
default: undefined,
get: v => v ?? localPlaceholder.value,
set: (v) => {
localPlaceholder.value = toDateOnly(v);
return localPlaceholder.value;
},
});
const triggerId = useId(undefined, 'date-picker-trigger');
const contentId = useId(undefined, 'date-picker-content');
const triggerElement = ref<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 grid = computed<CalendarMonth[]>(() => createMonths({
date: placeholder.value,
numberOfMonths,
weekStartsOn,
}));
const weekDays = computed(() => getWeekdayLabels(weekStartsOn, locale, weekdayFormat));
const headingValue = computed(() => {
const months = grid.value;
if (!months.length) return '';
if (months.length === 1) return formatMonthYear(months[0]!.value, locale);
const first = formatMonthYear(months[0]!.value, locale);
const last = 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 && isBefore(date, minValue)) return true;
if (maxValue && isAfter(date, maxValue)) return true;
return false;
}
function isDateUnavailableLocal(date: Date): boolean {
return isUnavailable(date, propsIsDateUnavailable, minValue, maxValue);
}
function isDateSelected(date: Date): boolean {
return modelValue.value ? isSameDay(modelValue.value, date) : false;
}
function isOutsideVisibleView(date: Date): boolean {
return !grid.value.some(m => isSameMonth(m.value, date));
}
const isInvalid = computed(() => {
if (!modelValue.value) return false;
return isDateDisabled(modelValue.value) || isDateUnavailableLocal(modelValue.value);
});
function setDate(date: Date | undefined) {
if (readonly) return;
if (date && (isDateDisabled(date) || isDateUnavailableLocal(date))) return;
modelValue.value = date ? toDateOnly(date) : undefined;
if (date && closeOnSelect) open.value = false;
}
function setPlaceholder(date: Date) {
placeholder.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
? toDateOnly(fnToUse(placeholder.value))
: addMonths(placeholder.value, pageStep());
}
function prevPage(fn?: (placeholder: Date) => Date) {
const fnToUse = fn ?? propsPrevPage;
placeholder.value = fnToUse
? toDateOnly(fnToUse(placeholder.value))
: addMonths(placeholder.value, -pageStep());
}
function nextYear() {
placeholder.value = addYears(placeholder.value, 1);
}
function prevYear() {
placeholder.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
? toDateOnly(fnToUse(placeholder.value))
: addMonths(lastMonth, 1);
return 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
? toDateOnly(fnToUse(placeholder.value))
: addMonths(firstMonth, -1);
return isBefore(probe, minValue);
}
watch(modelValue, (v) => {
if (v && !isSameMonth(v, placeholder.value))
placeholder.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 modelValue.value.toISOString().slice(0, 10);
});
provideDatePickerRootContext({
open,
modal: modalRef,
name: nameRef,
modelValue,
locale: localeRef,
triggerId,
contentId,
triggerElement,
hasCustomAnchor,
onOpenChange: (v) => { open.value = v; },
onOpenToggle: () => { open.value = !open.value; },
});
provideCalendarRootContext({
modelValue,
placeholder,
locale: localeRef,
dir: dirRef,
grid,
weekDays,
headingValue,
fullCalendarLabel,
weekStartsOn: weekStartsOnRef,
weekdayFormat: weekdayFormatRef,
fixedWeeks: fixedWeeksRef,
numberOfMonths: numberOfMonthsRef,
disabled: disabledRef,
readonly: readonlyRef,
pagedNavigation: pagedNavigationRef,
minValue: minValueRef,
maxValue: maxValueRef,
isDateDisabled,
isDateUnavailable: isDateUnavailableLocal,
isDateSelected,
isOutsideVisibleView,
isInvalid,
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"
>
</Primitive>
</PopperRoot>
</template>
@@ -0,0 +1,41 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface DatePickerTriggerProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { onMounted } from 'vue';
import { PopperAnchor } from '../popper';
import { Primitive } from '../primitive';
import { useDatePickerRootContext } from './context';
const { as = 'button' } = defineProps<DatePickerTriggerProps>();
const ctx = useDatePickerRootContext();
const { forwardRef, currentElement } = useForwardExpose();
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"
:data-state="ctx.open.value ? 'open' : 'closed'"
:data-primitives-date-picker-trigger="''"
@click="ctx.onOpenToggle"
>
<slot />
</Primitive>
</component>
</template>
+20
View File
@@ -0,0 +1,20 @@
import type { ComputedRef, Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
export interface DatePickerRootContext {
open: Ref<boolean>;
modal: Ref<boolean>;
name: Ref<string | undefined>;
modelValue: Ref<Date | undefined>;
locale: Ref<string>;
triggerId: ComputedRef<string>;
contentId: ComputedRef<string>;
triggerElement: Ref<HTMLElement | undefined>;
hasCustomAnchor: Ref<boolean>;
onOpenChange: (value: boolean) => void;
onOpenToggle: () => void;
}
const ctx = useContextFactory<DatePickerRootContext>('DatePickerRoot');
export const provideDatePickerRootContext = ctx.provide;
export const useDatePickerRootContext = ctx.inject;

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