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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Ref } from 'vue';
|
||||
import { useContextFactory } from '@robonen/vue';
|
||||
|
||||
export type AvatarImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error';
|
||||
|
||||
export interface AvatarContext {
|
||||
imageLoadingStatus: Ref<AvatarImageLoadingStatus>;
|
||||
onImageLoadingStatusChange: (status: AvatarImageLoadingStatus) => void;
|
||||
}
|
||||
|
||||
const ctx = useContextFactory<AvatarContext>('AvatarContext');
|
||||
|
||||
export const provideAvatarContext = ctx.provide;
|
||||
export const useAvatarContext = ctx.inject;
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export { default as CalendarRoot } from './CalendarRoot.vue';
|
||||
export { default as CalendarHeader } from './CalendarHeader.vue';
|
||||
export { default as CalendarHeading } from './CalendarHeading.vue';
|
||||
export { default as CalendarPrev } from './CalendarPrev.vue';
|
||||
export { default as CalendarNext } from './CalendarNext.vue';
|
||||
export { default as CalendarGrid } from './CalendarGrid.vue';
|
||||
export { default as CalendarGridHead } from './CalendarGridHead.vue';
|
||||
export { default as CalendarGridBody } from './CalendarGridBody.vue';
|
||||
export { default as CalendarGridRow } from './CalendarGridRow.vue';
|
||||
export { default as CalendarHeadCell } from './CalendarHeadCell.vue';
|
||||
export { default as CalendarCell } from './CalendarCell.vue';
|
||||
export { default as CalendarCellTrigger } from './CalendarCellTrigger.vue';
|
||||
|
||||
export {
|
||||
provideCalendarRootContext,
|
||||
useCalendarRootContext,
|
||||
provideCalendarGridContext,
|
||||
useCalendarGridContext,
|
||||
} from './context';
|
||||
|
||||
export type {
|
||||
CalendarRootContext,
|
||||
CalendarGridContext,
|
||||
} from './context';
|
||||
|
||||
export * from './utils';
|
||||
|
||||
export type { CalendarRootEmits, CalendarRootProps } from './CalendarRoot.vue';
|
||||
export type { CalendarHeaderProps } from './CalendarHeader.vue';
|
||||
export type { CalendarHeadingProps } from './CalendarHeading.vue';
|
||||
export type { CalendarPrevProps } from './CalendarPrev.vue';
|
||||
export type { CalendarNextProps } from './CalendarNext.vue';
|
||||
export type { CalendarGridProps } from './CalendarGrid.vue';
|
||||
export type { CalendarGridHeadProps } from './CalendarGridHead.vue';
|
||||
export type { CalendarGridBodyProps } from './CalendarGridBody.vue';
|
||||
export type { CalendarGridRowProps } from './CalendarGridRow.vue';
|
||||
export type { CalendarHeadCellProps } from './CalendarHeadCell.vue';
|
||||
export type { CalendarCellProps } from './CalendarCell.vue';
|
||||
export type {
|
||||
CalendarCellTriggerProps,
|
||||
CalendarCellTriggerSlotProps,
|
||||
} from './CalendarCellTrigger.vue';
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
@@ -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');
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user