Merge pull request #143 from robonen/docs

feat(navigation-menu): enhance context handling and lifecycle management
This commit is contained in:
2026-06-10 16:36:24 +07:00
committed by GitHub
56 changed files with 2012 additions and 179 deletions
+15
View File
@@ -85,3 +85,18 @@ jobs:
- name: Test
run: pnpm --filter "${{ matrix.package }}" --if-present run test
# Sentinel job — aggregates all matrix results into a single status check.
# Add "CI" as the required check in branch protection rules.
ci:
name: CI
needs: check
if: always()
runs-on: ubuntu-latest
steps:
- name: All checks passed
run: |
if [[ "${{ needs.check.result }}" != "success" ]]; then
echo "One or more package checks failed: ${{ needs.check.result }}"
exit 1
fi
+16 -1
View File
@@ -154,11 +154,26 @@ function getDescription(jsdocs: JSDoc[], tags: JSDocTag[]): string {
return '';
}
/**
* Example text straight from the tag SOURCE. `getCommentText()` runs through
* the TS JSDoc parser, which strips each line's leading whitespace — code
* indentation is gone. Instead take the raw tag text and remove only the
* comment scaffolding (`@example` head, per-line ` * ` prefixes).
*/
function rawExampleText(tag: JSDocTag): string {
return tag.getText()
.replace(/^@example[ \t]?/, '')
.split('\n')
.map(line => line.replace(/^\s*\*(?: |\/\s*$)?/, ''))
.join('\n')
.replace(/\*\/?$/, '').trimEnd();
}
function getExamples(tags: JSDocTag[]): string[] {
return tags
.filter(t => t.getTagName() === 'example')
.map((t) => {
let text = t.getCommentText()?.trim() ?? '';
let text = rawExampleText(t).trim();
// A leading `<caption>…</caption>` (JSDoc example title) isn't valid code —
// turn it into a leading comment so the snippet stays clean & highlightable.
let caption = '';
@@ -17,6 +17,7 @@ export interface AspectRatioProps extends PrimitiveProps {
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { Primitive } from '../primitive';
import { useForwardExpose } from '@robonen/vue';
@@ -24,11 +25,11 @@ const { forwardRef } = useForwardExpose();
const { ratio = 1, as = 'div' } = defineProps<AspectRatioProps>();
const wrapperStyle = {
const wrapperStyle = computed(() => ({
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.
@@ -15,6 +15,18 @@ describe('AspectRatio', () => {
expect(outer.style.paddingBottom).toMatch(/^56\.25%$/);
});
it('updates padding-bottom when ratio prop changes', async () => {
const wrapper = mount(AspectRatio, { props: { ratio: 16 / 9 } });
const outer = wrapper.element as HTMLElement;
expect(outer.style.paddingBottom).toBe('56.25%');
await wrapper.setProps({ ratio: 1 });
expect(outer.style.paddingBottom).toBe('100%');
await wrapper.setProps({ ratio: 4 / 3 });
expect(outer.style.paddingBottom).toBe('75%');
});
it('places inner element absolutely covering the wrapper', () => {
const wrapper = mount(AspectRatio, { props: { ratio: 4 / 3 }, slots: { default: '<img />' } });
const inner = wrapper.element.firstElementChild as HTMLElement;
@@ -29,7 +29,7 @@ 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';
import { addDays, addMonths, addYears, formatFullDate, isAfter, isBefore, isSameDay, isSameMonth, toIsoDate } from './utils';
const { as = 'div', day, month } = defineProps<CalendarCellTriggerProps>();
@@ -39,7 +39,7 @@ defineSlots<{
const ctx = useCalendarRootContext();
const gridCtx = useCalendarGridContext();
const { forwardRef, currentElement } = useForwardExpose();
const { forwardRef } = useForwardExpose();
const monthValue = computed(() => month ?? gridCtx.month.value);
@@ -79,7 +79,7 @@ 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])`,
`[data-primitives-calendar-cell-trigger][data-value="${toIsoDate(target)}"]:not([data-outside-view])`,
);
if (el) {
el.focus();
@@ -89,8 +89,8 @@ function focusByDataValue(target: Date) {
}
function shiftFocus(target: Date) {
if (ctx.minValue.value && target < ctx.minValue.value) return;
if (ctx.maxValue.value && target > ctx.maxValue.value) return;
if (ctx.minValue.value && isBefore(target, ctx.minValue.value)) return;
if (ctx.maxValue.value && isAfter(target, ctx.maxValue.value)) return;
ctx.focusedDate.value = target;
if (focusByDataValue(target)) return;
// Crossed visible range — page placeholder and retry.
@@ -159,14 +159,12 @@ function handleFocus() {
ctx.focusedDate.value = day;
}
const dataValue = computed(() => day.toISOString().slice(0, 10));
const dataValue = computed(() => toIsoDate(day));
const tabindex = computed(() => {
if (isFocusedDate.value) return 0;
if (isOutsideView.value || isDisabled.value) return undefined;
return -1;
});
defineExpose({ currentElement });
</script>
<template>
+1 -2
View File
@@ -80,8 +80,6 @@ import {
toDateOnly,
} from './utils';
defineOptions({ inheritAttrs: false });
const {
as = 'div',
defaultValue,
@@ -159,6 +157,7 @@ const grid = computed<CalendarMonth[]>(() => createMonths({
date: placeholder.value,
numberOfMonths,
weekStartsOn,
fixedWeeks,
}));
const weekDays = computed(() => getWeekdayLabels(weekStartsOn, locale, weekdayFormat));
@@ -0,0 +1,107 @@
import type { CalendarMonth } from '../utils';
import { mount } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import {
CalendarCell,
CalendarCellTrigger,
CalendarGrid,
CalendarGridBody,
CalendarGridRow,
CalendarRoot,
} from '../index';
import { createMonths, toIsoDate } from '../utils';
function mountCalendar(
props: Record<string, unknown> = {},
options: Record<string, unknown> = {},
) {
return mount(defineComponent({
setup: () => () => h(CalendarRoot, props, {
default: ({ grid }: { grid: CalendarMonth[] }) => grid.map(month =>
h(CalendarGrid, { key: month.value.toString(), month: month.value }, {
default: () => h(CalendarGridBody, null, {
default: () => month.weeks.map((week, w) =>
h(CalendarGridRow, { key: w }, {
default: () => week.map(day =>
h(CalendarCell, { key: day.toString(), date: day }, {
default: () => h(CalendarCellTrigger, { day, month: month.value }),
})),
})),
}),
})),
}),
}), options);
}
describe('Calendar', () => {
it('forwards consumer attrs (class) to the root element', () => {
const w = mountCalendar({
class: 'my-cal',
'data-x': 'y',
defaultPlaceholder: new Date(2026, 5, 1),
});
const root = w.find('[data-primitives-calendar-root]');
expect(root.classes()).toContain('my-cal');
expect(root.attributes('data-x')).toBe('y');
});
it('mounts cell triggers without "expose() should be called only once" warnings', () => {
const warn = vi.spyOn(console, 'warn');
mountCalendar({ defaultPlaceholder: new Date(2026, 5, 1) });
const exposeWarnings = warn.mock.calls
.filter(args => String(args[0]).includes('expose() should be called only once'));
expect(exposeWarnings).toHaveLength(0);
warn.mockRestore();
});
it('data-value matches the local calendar date of each cell', () => {
const w = mountCalendar({ defaultPlaceholder: new Date(2026, 5, 1) });
const triggers = w.findAll(
'[data-primitives-calendar-cell-trigger]:not([data-outside-view])',
);
expect(triggers).toHaveLength(30); // June 2026
for (const t of triggers)
expect(t.attributes('data-value')).toBe(`2026-06-${t.text().padStart(2, '0')}`);
});
it('renders 6 weeks by default; :fixed-weeks="false" trims trailing outside-month weeks', () => {
// February 2026 starts on Sunday and has 28 days — exactly 4 weeks.
const placeholder = new Date(2026, 1, 1);
const fixed = mountCalendar({ defaultPlaceholder: placeholder });
expect(fixed.findAll('[data-primitives-calendar-grid-row]')).toHaveLength(6);
const trimmed = mountCalendar({ defaultPlaceholder: placeholder, fixedWeeks: false });
expect(trimmed.findAll('[data-primitives-calendar-grid-row]')).toHaveLength(4);
expect(trimmed.findAll('[data-primitives-calendar-cell-trigger]')).toHaveLength(28);
});
it('allows arrow-key focus onto the min-value day when minValue has a time component', async () => {
const w = mountCalendar({
defaultPlaceholder: new Date(2026, 5, 1),
minValue: new Date(2026, 5, 10, 12, 30),
}, { attachTo: document.body });
const from = w.find('[data-value="2026-06-11"]:not([data-outside-view])');
(from.element as HTMLElement).focus();
await from.trigger('keydown', { key: 'ArrowLeft' });
await nextTick();
expect(document.activeElement?.getAttribute('data-value')).toBe('2026-06-10');
w.unmount();
});
});
describe('createMonths', () => {
it('keeps 6 weeks when fixedWeeks (default) and trims trailing outside-month weeks otherwise', () => {
const feb = new Date(2026, 1, 10); // February 2026: exactly 4 in-month weeks
expect(createMonths({ date: feb, numberOfMonths: 1, weekStartsOn: 0 })[0]!.weeks)
.toHaveLength(6);
const trimmed = createMonths({ date: feb, numberOfMonths: 1, weekStartsOn: 0, fixedWeeks: false })[0]!.weeks;
expect(trimmed).toHaveLength(4);
expect(toIsoDate(trimmed[0]![0]!)).toBe('2026-02-01');
expect(toIsoDate(trimmed[3]![6]!)).toBe('2026-02-28');
// August 2026 genuinely spans 6 weeks — nothing to trim.
const aug = createMonths({ date: new Date(2026, 7, 1), numberOfMonths: 1, weekStartsOn: 0, fixedWeeks: false })[0]!.weeks;
expect(aug).toHaveLength(6);
});
});
@@ -5,6 +5,7 @@ import {
isDateUnavailable,
isSameDay,
startOfWeek,
toIsoDate,
} from '../date-utils';
describe('date-utils', () => {
@@ -35,6 +36,13 @@ describe('date-utils', () => {
expect(isSameDay(a, new Date(2024, 5, 2))).toBe(false);
});
it('toIsoDate formats from local date fields, regardless of timezone', () => {
// toISOString would shift local midnight to the previous UTC day east of UTC.
expect(toIsoDate(new Date(2026, 5, 15))).toBe('2026-06-15');
expect(toIsoDate(new Date(2026, 0, 5))).toBe('2026-01-05');
expect(toIsoDate(new Date(2026, 5, 15, 23, 59, 59))).toBe('2026-06-15');
});
it('isDateUnavailable honors min/max and predicate', () => {
const min = new Date(2024, 0, 5);
const max = new Date(2024, 0, 25);
+10
View File
@@ -9,6 +9,16 @@ export function toDateOnly(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
}
/**
* `YYYY-MM-DD` from local date fields — unlike `toISOString`, which shifts
* local-midnight Dates to the previous UTC day in positive-offset timezones.
*/
export function toIsoDate(d: Date): string {
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${month}-${day}`;
}
export function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
+11 -2
View File
@@ -4,6 +4,7 @@ import {
formatDate,
formatWeekday,
getWeeks,
isSameMonth,
startOfMonth,
startOfWeek,
} from './date-utils';
@@ -13,7 +14,7 @@ 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. */
/** N×7 grid of dates including leading/trailing adjacent-month days. */
weeks: Date[][];
}
@@ -21,14 +22,22 @@ export interface CreateMonthsOptions {
date: Date;
numberOfMonths: number;
weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6;
/** Always render 6 weeks per month. @default true */
fixedWeeks?: boolean;
}
/** Build N consecutive months starting from `date`'s month. */
export function createMonths(opts: CreateMonthsOptions): CalendarMonth[] {
const { fixedWeeks = true } = opts;
const months: CalendarMonth[] = [];
for (let i = 0; i < opts.numberOfMonths; i++) {
const m = startOfMonth(addMonths(opts.date, i));
months.push({ value: m, weeks: getWeeks(m, opts.weekStartsOn) });
let weeks = getWeeks(m, opts.weekStartsOn);
// Only trailing weeks can be entirely outside the month — the first week
// always contains the 1st.
if (!fixedWeeks)
weeks = weeks.filter(week => week.some(d => isSameMonth(d, m)));
months.push({ value: m, weeks });
}
return months;
}
@@ -0,0 +1,106 @@
import type { VueWrapper } from '@vue/test-utils';
import type { CollectionContext } from '../useCollection';
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import { useCollectionInjector, useCollectionProvider } from '../useCollection';
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
function makeProvider(onCreated: (ctx: CollectionContext) => void, key?: string) {
return defineComponent({
setup(_, { slots }) {
const ctx = key === undefined ? useCollectionProvider() : useCollectionProvider(key);
onCreated(ctx);
return () => h(ctx.CollectionSlot, null, { default: () => h('div', null, slots.default?.()) });
},
});
}
function makeItem(id: string, key?: string) {
return defineComponent({
setup() {
const { CollectionItem } = key === undefined
? useCollectionInjector()
: useCollectionInjector(key);
return () => h(CollectionItem, null, { default: () => h('button', { id }) });
},
});
}
describe('useCollection — default key', () => {
it('registers items into the nearest provider and returns them in DOM order', async () => {
let ctx!: CollectionContext;
const Provider = makeProvider(c => (ctx = c));
const Harness = defineComponent({
setup() {
return () => h(Provider, null, {
default: () => [h(makeItem('one')), h(makeItem('two'))],
});
},
});
track(mount(Harness, { attachTo: document.body }));
await nextTick();
expect(ctx.getItems().map(i => i.ref.id)).toEqual(['one', 'two']);
});
});
describe('useCollection — namespaced keys', () => {
it('a nested default-key provider does not shadow an outer namespaced provider', async () => {
let outer!: CollectionContext;
let inner!: CollectionContext;
const Outer = makeProvider(c => (outer = c), 'TestOuterCollection');
const Inner = makeProvider(c => (inner = c)); // default key, in between
const Harness = defineComponent({
setup() {
return () => h(Outer, null, {
default: () => h(Inner, null, {
default: () => [
// Registers into the *outer* collection despite the inner provider.
h(makeItem('outer-item', 'TestOuterCollection')),
h(makeItem('inner-item')),
],
}),
});
},
});
track(mount(Harness, { attachTo: document.body }));
await nextTick();
expect(outer.getItems().map(i => i.ref.id)).toEqual(['outer-item']);
expect(inner.getItems().map(i => i.ref.id)).toEqual(['inner-item']);
});
it('distinct keys keep fully independent registries', async () => {
let a!: CollectionContext;
let b!: CollectionContext;
const A = makeProvider(c => (a = c), 'TestKeyA');
const B = makeProvider(c => (b = c), 'TestKeyB');
const Harness = defineComponent({
setup() {
return () => h(A, null, {
default: () => h(B, null, {
default: () => [
h(makeItem('a-item', 'TestKeyA')),
h(makeItem('b-item', 'TestKeyB')),
],
}),
});
},
});
track(mount(Harness, { attachTo: document.body }));
await nextTick();
expect(a.getItems().map(i => i.ref.id)).toEqual(['a-item']);
expect(b.getItems().map(i => i.ref.id)).toEqual(['b-item']);
});
});
+32 -6
View File
@@ -157,29 +157,55 @@ function createCollectionState<Value = unknown>(): CollectionContext<Value> {
};
}
const CollectionCtx = useContextFactory<CollectionContext>('CollectionContext');
const DEFAULT_COLLECTION_KEY = 'CollectionContext';
// One context factory per namespace key (`useContextFactory` mints a unique
// Symbol per call). Without namespacing, a collection provider nested inside
// another (e.g. `RovingFocusGroup` between `NavigationMenuRoot` and
// `NavigationMenuTrigger`) shadows the outer collection for every descendant.
const collectionContextFactories = new Map<
string,
ReturnType<typeof useContextFactory<CollectionContext>>
>();
function getCollectionContextFactory(key: string) {
let factory = collectionContextFactories.get(key);
if (!factory) {
factory = useContextFactory<CollectionContext>(key);
collectionContextFactories.set(key, factory);
}
return factory;
}
/**
* Creates a new collection state and provides it to descendants.
* Call this in the parent (e.g. `RovingFocusGroup`, `ListboxRoot`).
*
* Pass a dedicated `key` when the component tree may nest another collection
* provider between this one and its injectors, so they don't shadow each other.
*
* @example
* ```ts
* const { getItems, CollectionSlot } = useCollectionProvider();
* ```
*/
export function useCollectionProvider<Value = unknown>(): CollectionContext<Value> {
export function useCollectionProvider<Value = unknown>(
key: string = DEFAULT_COLLECTION_KEY,
): CollectionContext<Value> {
const ctx = createCollectionState<Value>();
CollectionCtx.provide(ctx as CollectionContext);
getCollectionContextFactory(key).provide(ctx as CollectionContext);
return ctx;
}
/**
* Injects the collection context from the nearest `useCollectionProvider()`.
* Injects the collection context from the nearest `useCollectionProvider()`
* called with the same `key`.
* 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>;
export function useCollectionInjector<Value = unknown>(
key: string = DEFAULT_COLLECTION_KEY,
): CollectionContext<Value> {
return getCollectionContextFactory(key).inject() as CollectionContext<Value>;
}
@@ -59,16 +59,21 @@ function handleEscape(event: KeyboardEvent) {
emit('escapeKeyDown', event);
}
function handlePointerDownOutside(event: any) {
// Interactions within the anchor (input, trigger, cancel button, padding) must not
// dismiss the popup — e.g. the root focuses the input right after opening, which
// fires a focus-outside from the content layer's perspective.
function handleInteractOutside(event: PointerEvent | MouseEvent | FocusEvent) {
const target = event.target as Element | null;
const parent = rootCtx.parentElement.value;
const input = rootCtx.inputElement.value;
const trigger = rootCtx.triggerElement.value;
if (target && (input?.contains(target) || trigger?.contains(target))) {
if (target && (parent?.contains(target) || input?.contains(target) || trigger?.contains(target))) {
event.preventDefault();
return;
}
}
function handlePointerDownOutside(event: any) {
emit('pointerDownOutside', event);
if (!event.defaultPrevented) rootCtx.onOpenChange(false);
}
function handleFocusOutside(event: any) {
@@ -92,6 +97,7 @@ function handleCloseAutoFocus(event: Event) {
as="template"
:disable-outside-pointer-events="props.disableOutsidePointerEvents ?? false"
@escape-key-down="handleEscape"
@interact-outside="handleInteractOutside"
@pointer-down-outside="handlePointerDownOutside"
@focus-outside="handleFocusOutside"
@dismiss="rootCtx.onOpenChange(false)"
@@ -23,7 +23,7 @@ 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';
import { INPUT_OPEN_KEYS } from './utils';
const {
as = 'input',
@@ -132,7 +132,7 @@ function handleKeyDown(event: KeyboardEvent) {
if (isDisabled.value) return;
const { key } = event;
if (!rootCtx.open.value && OPEN_KEYS.includes(key)) {
if (!rootCtx.open.value && INPUT_OPEN_KEYS.includes(key)) {
event.preventDefault();
rootCtx.onOpenChange(true);
return;
+6 -3
View File
@@ -26,7 +26,6 @@ import { provideComboboxItemContext, useComboboxGroupContext, useComboboxRootCon
const props = defineProps<ComboboxItemProps<T>>();
const { forwardRef, currentElement } = useForwardExpose();
const rootCtx = useComboboxRootContext();
let groupCtx: { id: { value: string } } | null = null;
try {
@@ -44,6 +43,12 @@ 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));
// defineExpose must run BEFORE useForwardExpose: the composable absorbs a prior
// expose() into the forwarded object, while a later one would trigger Vue's
// "expose() should be called only once" warning and clobber the forwarded API.
defineExpose({ id, isVisible, isHighlighted });
const { forwardRef, currentElement } = useForwardExpose();
function syncRegistration() {
rootCtx.onItemRegister(id.value, {
value: props.value,
@@ -98,8 +103,6 @@ provideComboboxItemContext({
isSelected,
isDisabled,
});
defineExpose({ id, isVisible, isHighlighted });
</script>
<template>
+12 -25
View File
@@ -79,31 +79,17 @@ const {
const config = useConfig();
const direction = computed(() => dir ?? config.dir.value);
const localOpen = ref<boolean>(defaultOpen);
/** Controlled open state. Use `v-model:open`. */
const open = defineModel<boolean>('open', {
default: undefined,
get: v => v ?? localOpen.value,
set: (v) => {
localOpen.value = v;
return v;
},
});
const open = defineModel<boolean>('open', { default: false });
if (defaultOpen && !open.value) open.value = true;
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;
},
});
/** Controlled selected value. Use `v-model`. `undefined` from the parent means "no selection". */
const value = defineModel<T | T[] | undefined>('modelValue');
if (modelValue === undefined && defaultValue !== undefined) {
value.value = multiple
? (Array.isArray(defaultValue) ? defaultValue.slice() : [defaultValue]) as T[]
: (Array.isArray(defaultValue) ? defaultValue[0] : defaultValue) as T;
}
const searchTerm = ref('');
const isUserInputted = ref(false);
@@ -228,8 +214,9 @@ function onValueChange(v: T) {
function onOpenChange(next: boolean) {
open.value = next;
if (next) {
isUserInputted.value = false;
searchTerm.value = '';
// When the open was initiated by typing, ComboboxInput already set
// searchTerm/isUserInputted — resetting here would wipe the first keystroke.
if (!isUserInputted.value) searchTerm.value = '';
nextTick(() => {
inputElement.value?.focus();
highlightSelectedOrFirst();
@@ -0,0 +1,228 @@
import type { Ref } from 'vue';
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { userEvent } from 'vitest/browser';
import { defineComponent, h, nextTick, ref } from 'vue';
import {
ComboboxAnchor,
ComboboxCancel,
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxPortal,
ComboboxRoot,
ComboboxTrigger,
ComboboxViewport,
} from '../index';
interface MountOptions {
defaultOpen?: boolean;
model?: Ref<string | undefined>;
}
function mountCombobox(options: MountOptions = {}) {
const Harness = defineComponent({
setup: () => () => h(ComboboxRoot, {
defaultOpen: options.defaultOpen ?? false,
...(options.model
? {
modelValue: options.model.value,
'onUpdate:modelValue': (v: unknown) => { options.model!.value = v as string | undefined; },
}
: {}),
}, {
default: () => [
h(ComboboxAnchor, { id: 'anchor' }, {
default: () => [
h(ComboboxInput, { id: 'input' }),
h(ComboboxCancel, { id: 'cancel' }, { default: () => 'x' }),
h(ComboboxTrigger, { id: 'trigger' }, { default: () => '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 mount(Harness, { attachTo: document.body });
}
function getListbox(): HTMLElement | null {
return document.querySelector('[data-primitives-combobox-content]');
}
function getInput(): HTMLInputElement {
return document.querySelector<HTMLInputElement>('#input')!;
}
function visibleItemTexts(): string[] {
return Array.from(document.querySelectorAll<HTMLElement>('[data-primitives-combobox-item]'))
.filter(el => el.style.display !== 'none')
.map(el => el.textContent?.trim() ?? '');
}
async function flush(times = 3) {
for (let i = 0; i < times; i++) await nextTick();
}
describe('Combobox — open / dismiss / filtering', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('stays open and focuses the input after clicking the trigger', async () => {
const w = mountCombobox();
await nextTick();
await userEvent.click(document.querySelector('#trigger')!);
await flush();
// The popup must survive the input auto-focus (focus lands in the anchor,
// outside the content layer) instead of dismissing itself immediately.
expect(getListbox()).toBeTruthy();
expect(document.activeElement).toBe(getInput());
w.unmount();
});
it('keeps the popup open and clears the search when the cancel button is clicked', async () => {
const w = mountCombobox();
await nextTick();
const input = getInput();
await userEvent.click(input);
await userEvent.type(input, 'ban');
await flush();
expect(getListbox()).toBeTruthy();
expect(visibleItemTexts()).toEqual(['Banana']);
await userEvent.click(document.querySelector('#cancel')!);
await flush();
expect(getListbox()).toBeTruthy();
expect(input.value).toBe('');
expect(visibleItemTexts()).toEqual(['Apple', 'Banana', 'Cherry']);
w.unmount();
});
it('closes on outside pointerdown', async () => {
const w = mountCombobox();
await nextTick();
await userEvent.click(document.querySelector('#trigger')!);
await flush();
expect(getListbox()).toBeTruthy();
document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true }));
await flush();
expect(getListbox()).toBeNull();
w.unmount();
});
it('preserves the first keystroke when typing opens the combobox', async () => {
const w = mountCombobox();
await nextTick();
const input = getInput();
await userEvent.click(input);
await userEvent.type(input, 'b');
await flush();
expect(getListbox()).toBeTruthy();
expect(input.value).toBe('b');
expect(visibleItemTexts()).toEqual(['Banana']);
w.unmount();
});
it('lets Space type into a closed input instead of swallowing it', async () => {
const w = mountCombobox();
await nextTick();
const input = getInput();
await userEvent.click(input);
await userEvent.type(input, ' ');
await flush();
expect(input.value).toBe(' ');
// Typing (any printable character) still opens the list.
expect(getListbox()).toBeTruthy();
w.unmount();
});
it('does not open on caret keys (Home/End/PageDown) while closed', async () => {
const w = mountCombobox();
await nextTick();
await userEvent.click(getInput());
await userEvent.keyboard('{Home}{End}{PageDown}');
await flush();
expect(getListbox()).toBeNull();
w.unmount();
});
it('opens on ArrowDown while closed', async () => {
const w = mountCombobox();
await nextTick();
await userEvent.click(getInput());
await userEvent.keyboard('{ArrowDown}');
await flush();
expect(getListbox()).toBeTruthy();
w.unmount();
});
it('clears the selection when the parent clears v-model', async () => {
const model = ref<string | undefined>(undefined);
const w = mountCombobox({ model });
await nextTick();
await userEvent.click(document.querySelector('#trigger')!);
await flush();
const banana = Array.from(document.querySelectorAll<HTMLElement>('[data-primitives-combobox-item]'))
.find(el => el.textContent?.includes('Banana'))!;
await userEvent.click(banana);
await flush();
// Let the close-path setTimeout(1) reset isUserInputted before asserting.
await new Promise(resolve => setTimeout(resolve, 10));
expect(model.value).toBe('banana');
expect(getInput().value).toBe('banana');
model.value = undefined;
await flush();
expect(getInput().value).toBe('');
await userEvent.click(document.querySelector('#trigger')!);
await flush();
const bananaReopened = Array.from(document.querySelectorAll<HTMLElement>('[data-primitives-combobox-item]'))
.find(el => el.textContent?.includes('Banana'))!;
expect(bananaReopened.getAttribute('aria-selected')).toBe('false');
w.unmount();
});
it('does not warn "expose() should be called only once" when mounting items', async () => {
const warn = vi.spyOn(console, 'warn');
const w = mountCombobox({ defaultOpen: true });
await flush();
const exposeWarnings = warn.mock.calls.filter(args =>
args.some(arg => typeof arg === 'string' && arg.includes('expose() should be called only once')),
);
expect(exposeWarnings).toEqual([]);
w.unmount();
warn.mockRestore();
});
});
+3
View File
@@ -1,6 +1,9 @@
export type AcceptableValue = string | number | boolean | Record<string, unknown>;
export const OPEN_KEYS = ['Enter', ' ', 'ArrowDown', 'ArrowUp', 'PageUp', 'PageDown', 'Home', 'End'];
// The input is a text field: Space must type a space and Home/End/Page* must move
// the caret, so only the arrow keys open a closed list (typing opens it via input).
export const INPUT_OPEN_KEYS = ['ArrowDown', 'ArrowUp'];
export const SELECTION_KEYS = ['Enter', ' '];
export function clamp(value: number, min: number, max: number): number {
@@ -2,7 +2,7 @@
import type { PrimitiveProps } from '../primitive';
/**
* The region that captures right-click (and touch long-press), preventing the
* The region that captures right-click (and touch/pen long-press), preventing the
* native context menu and opening the menu anchored at the pointer position.
* Wrap whatever area should respond to a secondary click.
*/
@@ -59,7 +59,8 @@ function handleContextMenu(event: MouseEvent) {
function handlePointerDown(event: PointerEvent) {
if (disabled || event.button !== 0) return;
if (event.pointerType !== 'touch') return;
// Long-press applies to touch AND pen; mouse uses the native contextmenu event.
if (event.pointerType === 'mouse') return;
clearLongPress();
longPressTimer = setTimeout(() => {
point.value = { x: event.clientX, y: event.clientY };
@@ -67,6 +68,11 @@ function handlePointerDown(event: PointerEvent) {
}, LONG_PRESS_DELAY);
}
function handlePointerMove(event: PointerEvent) {
// A drag/scroll gesture must not open the menu after the delay.
if (event.pointerType !== 'mouse') clearLongPress();
}
function handlePointerCancel() {
clearLongPress();
}
@@ -77,7 +83,7 @@ function handlePointerUp() {
</script>
<template>
<MenuAnchor :reference="virtualEl">
<MenuAnchor as="template" :reference="virtualEl">
<Primitive
:ref="forwardRef"
:as="as"
@@ -85,6 +91,7 @@ function handlePointerUp() {
:data-disabled="disabled ? '' : undefined"
@contextmenu="handleContextMenu"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointercancel="handlePointerCancel"
@pointerup="handlePointerUp"
>
@@ -0,0 +1,175 @@
import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import {
ContextMenuContent,
ContextMenuItem,
ContextMenuPortal,
ContextMenuRoot,
ContextMenuTrigger,
} from '../../index';
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
document.body.removeAttribute('style');
vi.useRealTimers();
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
function mountContextMenu(options: {
triggerAttrs?: Record<string, unknown>;
onUpdateOpen?: (v: boolean) => void;
} = {}) {
const Harness = defineComponent({
setup() {
return () =>
h(
ContextMenuRoot,
{ 'onUpdate:open': options.onUpdateOpen },
{
default: () => [
h(
ContextMenuTrigger,
{ 'data-testid': 'trigger', ...options.triggerAttrs },
{ default: () => 'Right-click me' },
),
h(ContextMenuPortal, null, {
default: () =>
h(ContextMenuContent, null, {
default: () => h(ContextMenuItem, null, { default: () => 'Item' }),
}),
}),
],
},
);
},
});
return track(mount(Harness, { attachTo: document.body }));
}
function getTrigger(): HTMLElement {
return document.querySelector('[data-testid="trigger"]') as HTMLElement;
}
function dispatchContextMenu(el: HTMLElement, x = 100, y = 80) {
el.dispatchEvent(new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
}));
}
describe('context-menu — trigger element', () => {
it('merges fallthrough attrs onto the element carrying data-state (no anchor wrapper div)', () => {
mountContextMenu({ triggerAttrs: { id: 'trigger-el', class: 'canvas-area' } });
const trigger = getTrigger();
expect(trigger).toBeTruthy();
expect(trigger.id).toBe('trigger-el');
expect(trigger.classList.contains('canvas-area')).toBe(true);
expect(trigger.getAttribute('data-state')).toBe('closed');
// No intermediate anchor element between the harness root and the trigger.
expect(trigger.parentElement).toBe(wrappers[0]!.element);
});
it('opens the menu when contextmenu is dispatched on the attr-bearing element', async () => {
const onUpdateOpen = vi.fn();
mountContextMenu({ triggerAttrs: { class: 'canvas-area' }, onUpdateOpen });
const trigger = getTrigger();
dispatchContextMenu(trigger);
await nextTick();
await nextTick();
expect(onUpdateOpen).toHaveBeenCalledWith(true);
expect(trigger.getAttribute('data-state')).toBe('open');
expect(document.querySelector('[role="menu"]')).toBeTruthy();
});
});
describe('context-menu — long-press', () => {
function pointerDown(el: HTMLElement, pointerType: string, x = 50, y = 60) {
el.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true,
button: 0,
pointerType,
clientX: x,
clientY: y,
}));
}
it('opens after a 700ms touch long-press', async () => {
vi.useFakeTimers();
mountContextMenu();
const trigger = getTrigger();
pointerDown(trigger, 'touch');
expect(trigger.getAttribute('data-state')).toBe('closed');
vi.advanceTimersByTime(700);
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('open');
});
it('opens after a pen long-press', async () => {
vi.useFakeTimers();
mountContextMenu();
const trigger = getTrigger();
pointerDown(trigger, 'pen');
vi.advanceTimersByTime(700);
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('open');
});
it('cancels the long-press when the pointer moves (drag/scroll gesture)', async () => {
vi.useFakeTimers();
mountContextMenu();
const trigger = getTrigger();
pointerDown(trigger, 'touch');
vi.advanceTimersByTime(300);
trigger.dispatchEvent(new PointerEvent('pointermove', {
bubbles: true,
pointerType: 'touch',
clientX: 50,
clientY: 120,
}));
vi.advanceTimersByTime(700);
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('closed');
});
it('cancels the long-press on pointerup', async () => {
vi.useFakeTimers();
mountContextMenu();
const trigger = getTrigger();
pointerDown(trigger, 'touch');
vi.advanceTimersByTime(300);
trigger.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerType: 'touch' }));
vi.advanceTimersByTime(700);
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('closed');
});
it('does not start a long-press for mouse pointers', async () => {
vi.useFakeTimers();
mountContextMenu();
const trigger = getTrigger();
pointerDown(trigger, 'mouse');
vi.advanceTimersByTime(700);
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('closed');
});
});
@@ -60,17 +60,22 @@ onBeforeUnmount(() => {
dismissableLayerStack.remove(layer);
});
function createInteractEvent(event: PointerEvent | MouseEvent | FocusEvent): { defaultPrevented: boolean } {
// Emit `interactOutside` first so consumers can cancel before the specific event fires.
// `focusin` is non-cancelable (and synthetic pointer events may be too), so the
// native `defaultPrevented` flag can never flip — track prevention via a patched
// `preventDefault` instead, keeping the "Preventable." emit contract honest.
function emitPreventable<E extends PointerEvent | MouseEvent | FocusEvent>(
event: E,
emitEvent: (event: E) => void,
): boolean {
let prevented = false;
const original = event.preventDefault;
event.preventDefault = () => {
prevented = true;
original.call(event);
};
emit('interactOutside', event);
emitEvent(event);
event.preventDefault = original;
return { defaultPrevented: prevented };
return prevented || event.defaultPrevented;
}
useEscapeKey((event) => {
@@ -81,10 +86,10 @@ useEscapeKey((event) => {
useClickOutside(nodeRef, (event) => {
if (!dismissableLayerStack.isTopmost(layer)) return;
const interact = createInteractEvent(event);
if (interact.defaultPrevented) return;
emit('pointerDownOutside', event);
if (!event.defaultPrevented) emit('dismiss');
// Emit `interactOutside` first so consumers can cancel before the specific event fires.
if (emitPreventable(event, e => emit('interactOutside', e))) return;
if (emitPreventable(event, e => emit('pointerDownOutside', e))) return;
emit('dismiss');
});
// Focus outside detection — fires when focus leaves this layer to an element
@@ -96,11 +101,9 @@ useEventListener(document, 'focusin', (event: FocusEvent) => {
if (el === target || el.contains(target)) return;
if (!dismissableLayerStack.isTopmost(layer)) return;
const interact = createInteractEvent(event);
if (interact.defaultPrevented) return;
emit('focusOutside', event);
if (!event.defaultPrevented) emit('dismiss');
if (emitPreventable(event, e => emit('interactOutside', e))) return;
if (emitPreventable(event, e => emit('focusOutside', e))) return;
emit('dismiss');
});
// When this layer disables outside pointer events, the body gets a data
@@ -72,6 +72,103 @@ describe('DismissableLayer', () => {
w.unmount();
});
it('does not dismiss when pointerDownOutside.preventDefault() is called on a non-cancelable event', async () => {
const outside = document.createElement('button');
document.body.appendChild(outside);
const w = mount(DismissableLayer, {
attachTo: document.body,
slots: { default: '<button>in</button>' },
props: {
onPointerDownOutside: (e: Event) => e.preventDefault(),
},
});
await nextTick();
// PointerEvent constructor defaults to cancelable: false — native
// defaultPrevented can never flip, prevention must be tracked separately.
outside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true }));
expect(w.emitted('pointerDownOutside')).toBeTruthy();
expect(w.emitted('dismiss')).toBeFalsy();
w.unmount();
});
it('emits focusOutside and dismiss when focus moves outside', async () => {
const outside = document.createElement('button');
document.body.appendChild(outside);
const w = mount(DismissableLayer, {
attachTo: document.body,
slots: { default: '<button data-testid="inside">in</button>' },
});
await nextTick();
outside.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
expect(w.emitted('focusOutside')).toBeTruthy();
expect(w.emitted('dismiss')).toBeTruthy();
w.unmount();
});
it('does not emit focusOutside when focus moves inside', async () => {
const w = mount(DismissableLayer, {
attachTo: document.body,
slots: { default: '<button data-testid="inside">in</button>' },
});
await nextTick();
const inside = w.find('[data-testid=inside]').element as HTMLElement;
inside.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
expect(w.emitted('focusOutside')).toBeFalsy();
expect(w.emitted('dismiss')).toBeFalsy();
w.unmount();
});
it('does not dismiss when focusOutside.preventDefault() is called (focusin is non-cancelable)', async () => {
const outside = document.createElement('button');
document.body.appendChild(outside);
const w = mount(DismissableLayer, {
attachTo: document.body,
slots: { default: '<button>in</button>' },
props: {
onFocusOutside: (e: Event) => e.preventDefault(),
},
});
await nextTick();
outside.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
expect(w.emitted('focusOutside')).toBeTruthy();
expect(w.emitted('dismiss')).toBeFalsy();
w.unmount();
});
it('does not dismiss nor emit the specific event when interactOutside.preventDefault() is called', async () => {
const outside = document.createElement('button');
document.body.appendChild(outside);
const w = mount(DismissableLayer, {
attachTo: document.body,
slots: { default: '<button>in</button>' },
props: {
onInteractOutside: (e: Event) => e.preventDefault(),
},
});
await nextTick();
outside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true }));
outside.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
expect(w.emitted('interactOutside')).toBeTruthy();
expect(w.emitted('pointerDownOutside')).toBeFalsy();
expect(w.emitted('focusOutside')).toBeFalsy();
expect(w.emitted('dismiss')).toBeFalsy();
w.unmount();
});
it('sets body pointer-events: none when disableOutsidePointerEvents is true', async () => {
const w = mount(DismissableLayer, {
attachTo: document.body,
@@ -26,7 +26,15 @@ const ddCtx = useDropdownMenuRootContext();
:aria-labelledby="ddCtx.triggerId.value"
@close-auto-focus="emit('closeAutoFocus', $event)"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@pointer-down-outside="(event: PointerEvent | MouseEvent) => {
const target = event.target as Node
// The trigger owns pointerdown toggling — letting the layer also dismiss
// here would close the menu before the trigger handler runs and make its
// toggle reopen it.
const isTriggerPointerDown = ddCtx.triggerRef.value?.contains(target)
if (isTriggerPointerDown) event.preventDefault()
emit('pointerDownOutside', event)
}"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
@dismiss="emit('dismiss')"
@@ -36,10 +36,15 @@ onUnmounted(() => {
function handlePointerDown(event: PointerEvent) {
if (disabled) return;
if (event.button !== 0 || event.ctrlKey) return;
if (!menuCtx.open.value) {
menuCtx.onOpenChange(true);
event.preventDefault();
}
// Toggle on the pre-interaction state: DropdownMenuContent prevents the
// dismissable layer from closing on trigger pointerdown, so this handler is
// the single owner of the open state for trigger interactions (otherwise
// dismiss-then-toggle would immediately reopen the menu).
const wasOpen = menuCtx.open.value;
menuCtx.onOpenChange(!wasOpen);
// Prevent trigger focusing when opening so the content can take focus
// without competition.
if (!wasOpen) event.preventDefault();
}
function handleKeyDown(event: KeyboardEvent) {
@@ -52,7 +57,7 @@ function handleKeyDown(event: KeyboardEvent) {
</script>
<template>
<MenuAnchor>
<MenuAnchor as="template">
<Primitive
:ref="forwardRef"
:as="as"
@@ -0,0 +1,146 @@
import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuTrigger,
} from '../index';
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
function mountMenu(opts: { modal?: boolean } = {}) {
const Harness = defineComponent({
setup() {
return () => h(
DropdownMenuRoot,
{ modal: opts.modal },
{
default: () => [
h(
DropdownMenuTrigger,
{ 'data-testid': 'trigger', class: 'demo-trigger' },
{ default: () => 'Open' },
),
h(DropdownMenuPortal, null, {
default: () => h(DropdownMenuContent, null, {
default: () => [
h(DropdownMenuItem, null, { default: () => 'One' }),
h(DropdownMenuItem, null, { default: () => 'Two' }),
],
}),
}),
],
},
);
},
});
return track(mount(Harness, { attachTo: document.body }));
}
function trigger(): HTMLElement {
return document.querySelector<HTMLElement>('[data-testid="trigger"]')!;
}
function menu(): HTMLElement | null {
return document.querySelector<HTMLElement>('[role="menu"]');
}
function pointerDown(el: EventTarget) {
el.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true,
cancelable: true,
composed: true,
button: 0,
pointerId: 1,
pointerType: 'mouse',
}));
}
async function flush() {
await nextTick();
await nextTick();
}
describe('dropdownMenu — trigger renders as the anchor itself', () => {
it('merges fallthrough attrs onto the trigger button (no anchor wrapper element)', () => {
mountMenu();
const el = trigger();
// Pre-fix, MenuAnchor rendered a real <div> wrapper that swallowed
// fallthrough attrs while data-state/aria stayed on the inner button.
expect(el.tagName).toBe('BUTTON');
expect(el.classList.contains('demo-trigger')).toBe(true);
expect(el.getAttribute('aria-haspopup')).toBe('menu');
expect(el.getAttribute('data-state')).toBe('closed');
expect(el.querySelector('button')).toBeNull();
});
it('flips data-state/aria-expanded on the attr-bearing element when opened', async () => {
mountMenu({ modal: false });
pointerDown(trigger());
await flush();
expect(menu()).toBeTruthy();
expect(trigger().getAttribute('data-state')).toBe('open');
expect(trigger().getAttribute('aria-expanded')).toBe('true');
});
});
describe('dropdownMenu — trigger pointerdown toggling (non-modal)', () => {
it('closes on trigger pointerdown while open and does not reopen from the dismiss race', async () => {
mountMenu({ modal: false });
pointerDown(trigger());
await flush();
expect(menu()).toBeTruthy();
// The outside-pointerdown dismiss (window capture) runs before the
// trigger's own handler — without the content-side guard the menu would
// close via dismiss and instantly reopen via the trigger toggle.
pointerDown(trigger());
await flush();
expect(menu()).toBeNull();
expect(trigger().getAttribute('data-state')).toBe('closed');
await flush();
expect(menu()).toBeNull();
});
it('reopens on the next trigger pointerdown after a toggle-close', async () => {
mountMenu({ modal: false });
pointerDown(trigger());
await flush();
pointerDown(trigger());
await flush();
expect(menu()).toBeNull();
pointerDown(trigger());
await flush();
expect(menu()).toBeTruthy();
expect(trigger().getAttribute('data-state')).toBe('open');
});
});
describe('dropdownMenu — trigger keyboard open', () => {
it('opens the menu on Enter', async () => {
mountMenu({ modal: false });
trigger().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
await flush();
expect(menu()).toBeTruthy();
expect(trigger().getAttribute('data-state')).toBe('open');
});
});
@@ -28,6 +28,7 @@ const { forwardRef } = useForwardExpose();
:disabled="ctx.disabled.value || undefined"
:data-disabled="ctx.disabled.value ? '' : undefined"
:hidden="ctx.isEditing.value ? undefined : ''"
:style="ctx.isEditing.value ? undefined : { display: 'none' }"
@click="ctx.cancel"
>
<slot>Cancel</slot>
@@ -28,6 +28,7 @@ const { forwardRef } = useForwardExpose();
:disabled="ctx.disabled.value || undefined"
:data-disabled="ctx.disabled.value ? '' : undefined"
:hidden="ctx.isEditing.value ? '' : undefined"
:style="ctx.isEditing.value ? { display: 'none' } : undefined"
@click="ctx.edit"
>
<slot>Edit</slot>
@@ -80,7 +80,7 @@ function onKeyDown(event: KeyboardEvent): void {
all: 'unset',
gridArea: '1 / 1 / auto / auto',
visibility: !ctx.isEditing.value ? 'hidden' : undefined,
} : undefined"
} : (!ctx.isEditing.value ? { display: 'none' } : undefined)"
aria-label="editable input"
@input="onInput"
@keydown="onKeyDown"
@@ -49,7 +49,7 @@ function onDoubleClick(): void {
visibility: ctx.isEditing.value ? 'hidden' : undefined,
overflow: 'hidden',
textOverflow: 'ellipsis',
} : undefined"
} : (ctx.isEditing.value ? { display: 'none' } : undefined)"
@focusin="onFocus"
@dblclick="onDoubleClick"
>
+15 -1
View File
@@ -38,7 +38,7 @@ export interface EditableRootEmits {
</script>
<script setup lang="ts">
import { computed, ref, shallowRef, toRef, watch } from 'vue';
import { computed, onBeforeUnmount, ref, shallowRef, toRef, watch } from 'vue';
import { Primitive } from '../primitive';
import { provideEditableContext } from './context';
import { useForwardExpose } from '@robonen/vue';
@@ -105,25 +105,39 @@ function edit(): void {
}
function cancel(): void {
if (!isEditing.value) return;
isEditing.value = false;
inputValue.value = model.value;
emit('update:state', 'cancel');
}
function submit(): void {
if (!isEditing.value) return;
commitModel(inputValue.value);
isEditing.value = false;
emit('update:state', 'submit');
emit('submit', inputValue.value);
}
let blurTimer: ReturnType<typeof setTimeout> | undefined;
onBeforeUnmount(() => clearTimeout(blurTimer));
function onFocusOutCapture(event: FocusEvent): void {
if (!isEditing.value) return;
const root = currentElement.value;
const next = event.relatedTarget as Node | null;
if (root && next && root.contains(next)) return;
// Hiding the focused preview/trigger on entering edit mode fires a
// synchronous focusout with relatedTarget=null before the input's autofocus
// lands — defer the decision and re-check where focus actually ended up.
clearTimeout(blurTimer);
blurTimer = setTimeout(() => {
if (!isEditing.value) return;
const active = document.activeElement;
if (root && active && root.contains(active)) return;
if (submitMode === 'blur' || submitMode === 'both') submit();
else cancel();
}, 0);
}
provideEditableContext({
@@ -28,6 +28,7 @@ const { forwardRef } = useForwardExpose();
:disabled="ctx.disabled.value || undefined"
:data-disabled="ctx.disabled.value ? '' : undefined"
:hidden="ctx.isEditing.value ? undefined : ''"
:style="ctx.isEditing.value ? undefined : { display: 'none' }"
@click="ctx.submit"
>
<slot>Submit</slot>
@@ -40,6 +40,11 @@ function press(el: Element, key: string) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
}
// EditableRoot defers its outside-blur decision by a macrotask.
function waitForBlurTimers() {
return new Promise(resolve => setTimeout(resolve, 20));
}
describe('Editable', () => {
it('renders preview with default placeholder when empty', () => {
const w = createEditable({ placeholder: 'Click to edit' });
@@ -169,4 +174,101 @@ describe('Editable', () => {
expect((w.find('input').element as HTMLInputElement).hidden).toBe(true);
w.unmount();
});
it('keeps edit mode when the focused edit trigger hides itself (real focus)', async () => {
const w = createEditable({ defaultValue: 'v', activationMode: 'none' });
const editBtn = w.findAll('button').find(b => b.attributes('aria-label') === 'edit')!;
(editBtn.element as HTMLButtonElement).focus();
(editBtn.element as HTMLButtonElement).click();
await nextTick();
await waitForBlurTimers();
expect((w.find('input').element as HTMLInputElement).hidden).toBe(false);
expect(document.activeElement).toBe(w.find('input').element);
expect(w.findComponent(EditableRoot).emitted('update:state')?.flat()).toEqual(['edit']);
w.unmount();
});
it('keeps edit mode when the really-focused preview hides itself (focus activation)', async () => {
const w = createEditable({ defaultValue: 'v', activationMode: 'focus' });
(w.find('span').element as HTMLElement).focus();
await nextTick();
await waitForBlurTimers();
expect((w.find('input').element as HTMLInputElement).hidden).toBe(false);
expect(document.activeElement).toBe(w.find('input').element);
expect(w.findComponent(EditableRoot).emitted('update:state')?.flat()).toEqual(['edit']);
w.unmount();
});
it('still submits when focus genuinely leaves the root (submitMode blur)', async () => {
const outside = document.createElement('button');
document.body.appendChild(outside);
const w = createEditable({ defaultValue: 'v1', startWithEditMode: true, submitMode: 'blur' });
await nextTick();
const input = w.find('input');
(input.element as HTMLInputElement).focus();
(input.element as HTMLInputElement).value = 'v2';
await input.trigger('input');
outside.focus();
await waitForBlurTimers();
const root = w.findComponent(EditableRoot);
expect(root.emitted('submit')?.at(-1)).toEqual(['v2']);
expect((w.find('input').element as HTMLInputElement).hidden).toBe(true);
outside.remove();
w.unmount();
});
it('hides parts even when consumer display utility classes override [hidden]', async () => {
const style = document.createElement('style');
style.textContent = '.u-block { display: block; } .u-inline-flex { display: inline-flex; }';
document.head.appendChild(style);
const w = mount(
defineComponent({
setup() {
return () => h(
EditableRoot,
{ defaultValue: 'v', activationMode: 'none' },
{
default: () => h(EditableArea, null, {
default: () => [
h(EditablePreview, { class: 'u-block' }),
h(EditableInput, { class: 'u-block' }),
h(EditableEditTrigger, { class: 'u-inline-flex' }),
h(EditableSubmitTrigger, { class: 'u-inline-flex' }),
h(EditableCancelTrigger, { class: 'u-inline-flex' }),
],
}),
},
);
},
}),
{ attachTo: document.body },
);
const button = (label: string) =>
w.findAll('button').find(b => b.attributes('aria-label') === label)!.element as HTMLElement;
expect(getComputedStyle(w.find('input').element).display).toBe('none');
expect(getComputedStyle(button('submit')).display).toBe('none');
expect(getComputedStyle(button('cancel')).display).toBe('none');
expect(getComputedStyle(button('edit')).display).toBe('inline-flex');
await w.findAll('button').find(b => b.attributes('aria-label') === 'edit')!.trigger('click');
await nextTick();
expect(getComputedStyle(w.find('span').element).display).toBe('none');
expect(getComputedStyle(button('edit')).display).toBe('none');
expect(getComputedStyle(w.find('input').element).display).toBe('block');
expect(getComputedStyle(button('submit')).display).toBe('inline-flex');
expect(getComputedStyle(button('cancel')).display).toBe('inline-flex');
style.remove();
w.unmount();
});
it('submit and cancel triggers are no-ops while not editing', async () => {
const w = createEditable({ defaultValue: 'v1' });
const submitBtn = w.findAll('button').find(b => b.attributes('aria-label') === 'submit')!;
const cancelBtn = w.findAll('button').find(b => b.attributes('aria-label') === 'cancel')!;
await submitBtn.trigger('click');
await cancelBtn.trigger('click');
const root = w.findComponent(EditableRoot);
expect(root.emitted('submit')).toBeUndefined();
expect(root.emitted('update:state')).toBeUndefined();
w.unmount();
});
});
+5 -3
View File
@@ -45,9 +45,11 @@ function handleSelect(event: Event) {
local.value = next;
emit('update:checked', next);
const selectEvent = new CustomEvent(ITEM_SELECT, { bubbles: true, cancelable: true })
;(event.currentTarget as HTMLElement).dispatchEvent(selectEvent);
emit('select', event);
const target = event.currentTarget as HTMLElement;
const selectEvent = new CustomEvent(ITEM_SELECT, { bubbles: true, cancelable: true });
// Emit the cancelable ITEM_SELECT event so `@select` preventDefault works.
target.addEventListener(ITEM_SELECT, e => emit('select', e), { once: true });
target.dispatchEvent(selectEvent);
if (!selectEvent.defaultPrevented) rootCtx.onClose();
}
</script>
+14 -3
View File
@@ -43,7 +43,7 @@ import { PopperContent } from '../popper';
import { RovingFocusGroup } from '../roving-focus';
import { useForwardExpose } from '@robonen/vue';
import { provideMenuContentContext, useMenuContext, useMenuRootContext } from './context';
import { FIRST_LAST_KEYS, getNextMatch, getOpenState, isPointerInGraceArea } from './utils';
import { FIRST_LAST_KEYS, LAST_KEYS, focusFirst, getNextMatch, getOpenState, isPointerInGraceArea } from './utils';
const {
loop = false,
@@ -100,9 +100,9 @@ provideMenuContentContext({
function handleMountAutoFocus(event: Event) {
event.preventDefault();
if (rootCtx.isUsingKeyboardRef.value) {
// Always focus the content so key events reach the menu even after a
// pointer-open; entryFocus decides whether the first item gets focus.
contentElement.value?.focus({ preventScroll: true });
}
emit('openAutoFocus', event);
}
@@ -125,6 +125,17 @@ function handleKeyDown(event: KeyboardEvent) {
if (FIRST_LAST_KEYS.includes(event.key)) {
event.stopPropagation();
// While the content itself is focused (e.g. right after a pointer-open),
// arrow/Home/End must move focus into the items.
const content = contentElement.value;
if (content && event.target === content) {
event.preventDefault();
const items = Array.from(
content.querySelectorAll<HTMLElement>('[data-primitives-menu-item]:not([data-disabled])'),
);
if (LAST_KEYS.includes(event.key)) items.reverse();
focusFirst(items);
}
}
}
+6 -3
View File
@@ -21,9 +21,12 @@ const emit = defineEmits<MenuItemEmits>();
const rootCtx = useMenuRootContext();
function handleSelect(event: Event) {
const selectEvent = new CustomEvent(ITEM_SELECT, { bubbles: true, cancelable: true })
;(event.currentTarget as HTMLElement).dispatchEvent(selectEvent);
emit('select', event);
const target = event.currentTarget as HTMLElement;
const selectEvent = new CustomEvent(ITEM_SELECT, { bubbles: true, cancelable: true });
// The consumer must receive the cancelable ITEM_SELECT event (not the click)
// so `event.preventDefault()` in `@select` actually keeps the menu open.
target.addEventListener(ITEM_SELECT, e => emit('select', e), { once: true });
target.dispatchEvent(selectEvent);
if (!selectEvent.defaultPrevented) {
rootCtx.onClose();
}
+8 -2
View File
@@ -75,10 +75,17 @@ function handleKeyDown(event: KeyboardEvent) {
el.click();
}
}
// RovingFocusItem renders as="template" so its tab stop (tabindex, focus and
// keydown handlers, collection registration) merges onto the menu-item element
// itself — a real wrapper element would split focus handling across two nodes.
// NB: the template must stay single-root with no top-level comments; consumers
// resolve this component's element via `$el`/functional refs, and a dev-mode
// fragment root would point them at the fragment anchor instead.
</script>
<template>
<RovingFocusItem :focusable="!disabled" :active="isHighlighted">
<RovingFocusItem as="template" :focusable="!disabled" :active="isHighlighted">
<Primitive
:ref="(el: unknown) => { itemRef = el as HTMLElement | null }"
:as="as"
@@ -88,7 +95,6 @@ function handleKeyDown(event: KeyboardEvent) {
:data-highlighted="isHighlighted ? '' : undefined"
:data-disabled="disabled ? '' : undefined"
:aria-disabled="disabled || undefined"
:tabindex="isHighlighted ? 0 : -1"
@pointermove="handlePointerMove"
@pointerleave="handlePointerLeave"
@focus="handleFocus"
+5 -3
View File
@@ -32,9 +32,11 @@ provideMenuItemIndicatorContext({ checkedState });
function handleSelect(event: Event) {
radioCtx.onValueChange(value);
const selectEvent = new CustomEvent(ITEM_SELECT, { bubbles: true, cancelable: true })
;(event.currentTarget as HTMLElement).dispatchEvent(selectEvent);
emit('select', event);
const target = event.currentTarget as HTMLElement;
const selectEvent = new CustomEvent(ITEM_SELECT, { bubbles: true, cancelable: true });
// Emit the cancelable ITEM_SELECT event so `@select` preventDefault works.
target.addEventListener(ITEM_SELECT, e => emit('select', e), { once: true });
target.dispatchEvent(selectEvent);
if (!selectEvent.defaultPrevented) rootCtx.onClose();
}
</script>
@@ -35,7 +35,7 @@ useHideOthers(contentRef);
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
@dismiss="emit('dismiss')"
@dismiss="() => { menuCtx.onOpenChange(false); emit('dismiss') }"
@entry-focus="emit('entryFocus', $event)"
@open-auto-focus="emit('openAutoFocus', $event)"
>
@@ -29,7 +29,7 @@ watchEffect(() => menuCtx.onContentChange(contentRef.value));
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
@dismiss="emit('dismiss')"
@dismiss="() => { menuCtx.onOpenChange(false); emit('dismiss') }"
@entry-focus="emit('entryFocus', $event)"
@open-auto-focus="emit('openAutoFocus', $event)"
>
+14 -2
View File
@@ -65,10 +65,22 @@ function handleKeyDown(event: KeyboardEvent) {
close();
}
}
function handleSelect(event: Event) {
// Sub triggers open their submenu instead of closing the menu tree —
// this is also the only open path for touch pointers.
event.preventDefault();
if (!menuCtx.open.value) open();
}
// PopperAnchor renders as="template" so the item element itself becomes the
// popper anchor and fallthrough attrs land on the element carrying
// data-state/highlight (a wrapper div would swallow them). The template must
// stay single-root without top-level comments — see MenuItemImpl.
</script>
<template>
<PopperAnchor>
<PopperAnchor as="template">
<MenuItemImpl
v-bind="props"
:id="subCtx.triggerId.value"
@@ -81,7 +93,7 @@ function handleKeyDown(event: KeyboardEvent) {
@pointermove="handlePointerMove"
@pointerleave="handlePointerLeave"
@keydown="handleKeyDown"
@select.prevent
@select="handleSelect"
>
<slot />
</MenuItemImpl>
@@ -0,0 +1,277 @@
import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import {
MenuAnchor,
MenuContent,
MenuItem,
MenuRoot,
MenuSub,
MenuSubContent,
MenuSubTrigger,
} from '../index';
import { ITEM_SELECT } from '../utils';
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
document.body.style.pointerEvents = '';
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
interface MountMenuOptions {
modal?: boolean;
onSelect?: (event: Event) => void;
items?: () => unknown;
}
function mountMenu(options: MountMenuOptions = {}) {
const open = ref(false);
const Harness = defineComponent({
setup() {
return () => h(
MenuRoot,
{
open: open.value,
'onUpdate:open': (v: boolean) => { open.value = v; },
modal: options.modal,
},
{
default: () => [
h(MenuAnchor, null, { default: () => h('button', { type: 'button' }, 'Anchor') }),
h(MenuContent, null, {
default: () => options.items?.() ?? [
h(MenuItem, { class: 'consumer-item', onSelect: options.onSelect }, { default: () => 'Alpha' }),
h(MenuItem, null, { default: () => 'Bravo' }),
h(MenuItem, null, { default: () => 'Charlie' }),
],
}),
],
},
);
},
});
track(mount(Harness, { attachTo: document.body }));
return { open };
}
async function openMenu(open: { value: boolean }) {
open.value = true;
await nextTick();
await nextTick();
}
function content(): HTMLElement {
return document.querySelector<HTMLElement>('[role="menu"]')!;
}
function items(): HTMLElement[] {
return Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]'));
}
function usePointer() {
// Flip the shared isUsingKeyboard ref into "pointer" mode.
document.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }));
}
function keydown(el: HTMLElement, key: string) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
}
describe('menu — item rendering (roving focus merged onto the item element)', () => {
it('puts consumer class, roving tab stop, and collection registration on the menuitem itself', async () => {
const { open } = mountMenu();
await openMenu(open);
const [alpha] = items();
expect(alpha).toBeTruthy();
expect(alpha!.classList.contains('consumer-item')).toBe(true);
expect(alpha!.hasAttribute('data-collection-item')).toBe(true);
expect(alpha!.hasAttribute('tabindex')).toBe(true);
// No wrapper span between the content and the item.
expect(alpha!.parentElement?.getAttribute('role')).toBe('menu');
});
it('sets data-highlighted on the same element that carries consumer attrs on hover', async () => {
const { open } = mountMenu();
await openMenu(open);
const [alpha] = items();
alpha!.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, pointerType: 'mouse' }));
await nextTick();
expect(document.activeElement).toBe(alpha);
expect(alpha!.hasAttribute('data-highlighted')).toBe(true);
expect(alpha!.classList.contains('consumer-item')).toBe(true);
});
});
describe('menu — keyboard navigation after a pointer-open', () => {
it('focuses the content on mount so key events reach the menu', async () => {
usePointer();
const { open } = mountMenu();
await openMenu(open);
expect(document.activeElement).toBe(content());
});
it('ArrowDown from the content focuses the first item, then roves to the next', async () => {
usePointer();
const { open } = mountMenu();
await openMenu(open);
keydown(content(), 'ArrowDown');
await nextTick();
expect(document.activeElement).toBe(items()[0]);
keydown(items()[0]!, 'ArrowDown');
await nextTick();
expect(document.activeElement).toBe(items()[1]);
});
it('End from the content focuses the last item', async () => {
usePointer();
const { open } = mountMenu();
await openMenu(open);
keydown(content(), 'End');
await nextTick();
expect(document.activeElement).toBe(items().at(-1));
});
it('Enter on the focused item selects it and closes the menu', async () => {
usePointer();
const selected: Event[] = [];
const { open } = mountMenu({ onSelect: e => selected.push(e) });
await openMenu(open);
keydown(content(), 'ArrowDown');
keydown(items()[0]!, 'Enter');
await nextTick();
expect(selected).toHaveLength(1);
expect(open.value).toBe(false);
});
});
describe('menu — dismissal', () => {
it('closes on Escape and releases the modal body pointer-events lock', async () => {
const { open } = mountMenu();
await openMenu(open);
expect(document.body.style.pointerEvents).toBe('none');
keydown(document.body, 'Escape');
await nextTick();
await nextTick();
expect(open.value).toBe(false);
expect(content()).toBeNull();
expect(document.body.style.pointerEvents).not.toBe('none');
});
it('closes on pointerdown outside the content', async () => {
const { open } = mountMenu();
await openMenu(open);
document.documentElement.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
await nextTick();
await nextTick();
expect(open.value).toBe(false);
expect(content()).toBeNull();
});
it('closes a non-modal menu on Escape too', async () => {
const { open } = mountMenu({ modal: false });
await openMenu(open);
expect(document.body.style.pointerEvents).not.toBe('none');
keydown(document.body, 'Escape');
await nextTick();
expect(open.value).toBe(false);
});
});
describe('menu — @select contract', () => {
it('emits the cancelable ITEM_SELECT event to the consumer', async () => {
const selected: Event[] = [];
const { open } = mountMenu({ onSelect: e => selected.push(e) });
await openMenu(open);
items()[0]!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await nextTick();
expect(selected).toHaveLength(1);
expect(selected[0]!.type).toBe(ITEM_SELECT);
expect(open.value).toBe(false);
});
it('keeps the menu open when the consumer calls event.preventDefault() in @select', async () => {
const { open } = mountMenu({ onSelect: e => e.preventDefault() });
await openMenu(open);
items()[0]!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await nextTick();
expect(open.value).toBe(true);
expect(content()).toBeTruthy();
});
});
describe('menu — submenu trigger', () => {
function mountWithSub() {
const subOpen = ref(false);
const menu = mountMenu({
items: () => [
h(MenuItem, null, { default: () => 'Alpha' }),
h(MenuSub, {
open: subOpen.value,
'onUpdate:open': (v: boolean) => { subOpen.value = v; },
}, {
default: () => [
h(MenuSubTrigger, { class: 'sub-trigger' }, { default: () => 'More' }),
h(MenuSubContent, null, {
default: () => h(MenuItem, null, { default: () => 'Nested' }),
}),
],
}),
],
});
return { ...menu, subOpen };
}
it('renders as a single element: consumer class and data-state on the menuitem, no anchor wrapper', async () => {
const { open } = mountWithSub();
await openMenu(open);
const trigger = document.querySelector<HTMLElement>('.sub-trigger')!;
expect(trigger.getAttribute('role')).toBe('menuitem');
expect(trigger.getAttribute('aria-haspopup')).toBe('menu');
expect(trigger.getAttribute('data-state')).toBe('closed');
expect(trigger.parentElement?.getAttribute('role')).toBe('menu');
});
it('opens the submenu on click', async () => {
const { open, subOpen } = mountWithSub();
await openMenu(open);
const trigger = document.querySelector<HTMLElement>('.sub-trigger')!;
trigger.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await nextTick();
await nextTick();
expect(subOpen.value).toBe(true);
expect(trigger.getAttribute('data-state')).toBe('open');
const menus = document.querySelectorAll('[role="menu"]');
expect(menus.length).toBe(2);
});
});
@@ -25,7 +25,6 @@ import NavigationMenuContentImpl from './NavigationMenuContentImpl.vue';
defineOptions({ inheritAttrs: false });
const { forceMount = false, ...rest } = defineProps<NavigationMenuContentProps>();
void rest;
const emit = defineEmits<NavigationMenuContentEmits>();
@@ -45,7 +44,19 @@ watch(
},
);
const present = computed(() => open.value || isLastActiveValue.value);
// The latch never resets when the whole menu closes, so gate it on the viewport
// still existing — otherwise the Teleport falls back to disabled (inline) and
// the closed panel would stay mounted in the nav forever.
watch(
() => menuContext.viewport.value,
(viewport) => {
if (!viewport) isLastActiveValue.value = false;
},
);
const present = computed(
() => open.value || (isLastActiveValue.value && !!menuContext.viewport.value),
);
function handlePointerEnter() {
menuContext.onContentEnter(itemContext.value);
@@ -62,7 +73,7 @@ function handlePointerLeave() {
<Teleport :to="menuContext.viewport.value ?? 'body'" :disabled="!menuContext.viewport.value">
<Presence :present="present" :force-mount="forceMount || !menuContext.unmountOnHide.value">
<NavigationMenuContentImpl
v-bind="$attrs"
v-bind="{ ...rest, ...$attrs }"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@@ -28,7 +28,7 @@ import { COLLECTION_ITEM_ATTR, EVENT_ROOT_CONTENT_DISMISS, getOpenState } from '
defineOptions({ inheritAttrs: false });
defineProps<NavigationMenuContentImplProps>();
const { as } = defineProps<NavigationMenuContentImplProps>();
const emit = defineEmits<NavigationMenuContentImplEmits>();
@@ -133,6 +133,20 @@ function handlePointerDownOutside(ev: PointerEvent | MouseEvent) {
if (isTrigger || isRootViewport || !menuContext.isRootMenu) ev.preventDefault();
}
function handleDismiss() {
emit('dismiss');
const el = currentElement.value;
if (menuContext.isRootMenu && el) {
// Bubbles up to NavigationMenuRoot's listener (closes the menu) and hits
// our own EVENT_ROOT_CONTENT_DISMISS listener (restores content tab order).
el.dispatchEvent(new CustomEvent(EVENT_ROOT_CONTENT_DISMISS, { bubbles: true, cancelable: true }));
}
else {
// Submenus: the root listener isn't on an ancestor of this element.
menuContext.onItemDismiss();
}
}
// Listen for sibling/global EVENT_ROOT_CONTENT_DISMISS for root menus so links
// inside content can request the whole root close.
watchEffect((onCleanup) => {
@@ -155,6 +169,7 @@ watchEffect((onCleanup) => {
<DismissableLayer
:id="itemContext.contentId"
:ref="forwardRef"
:as="as"
:aria-labelledby="itemContext.triggerId"
:data-motion="motionAttribute"
:data-state="getOpenState(menuContext.modelValue.value, itemContext.value)"
@@ -167,7 +182,7 @@ watchEffect((onCleanup) => {
@pointer-down-outside="handlePointerDownOutside"
@focus-outside="handleFocusOutside"
@interact-outside="emit('interactOutside', $event)"
@dismiss="emit('dismiss')"
@dismiss="handleDismiss"
@pointerenter="emit('pointerEnterContent')"
@pointerleave="emit('pointerLeaveContent')"
>
@@ -52,7 +52,7 @@ import { useCollectionProvider } from '../collection';
import { useConfig } from '../config-provider';
import { Primitive } from '../primitive';
import { provideNavigationMenuContext } from './context';
import { EVENT_ROOT_CONTENT_DISMISS } from './utils';
import { EVENT_ROOT_CONTENT_DISMISS, NAVIGATION_MENU_COLLECTION_KEY } from './utils';
defineOptions({ inheritAttrs: false });
@@ -98,7 +98,7 @@ const indicatorTrack = shallowRef<HTMLElement | undefined>(undefined);
const viewport = shallowRef<HTMLElement | undefined>(undefined);
const activeTrigger = shallowRef<HTMLElement | undefined>(undefined);
const { getItems, CollectionSlot } = useCollectionProvider<{ value: string }>();
const { getItems, CollectionSlot } = useCollectionProvider<{ value: string }>(NAVIGATION_MENU_COLLECTION_KEY);
// Manual debounce — open delay shrinks to 150ms once the menu is open or while
// the skip window is active (so moving between triggers feels instantaneous).
@@ -28,6 +28,7 @@ import { useForwardExpose, useId } from '@robonen/vue';
import { useCollectionProvider } from '../collection';
import { Primitive } from '../primitive';
import { provideNavigationMenuContext, useNavigationMenuContext } from './context';
import { NAVIGATION_MENU_COLLECTION_KEY } from './utils';
defineOptions({ inheritAttrs: false });
@@ -60,7 +61,7 @@ const indicatorTrack = shallowRef<HTMLElement | undefined>(undefined);
const viewport = shallowRef<HTMLElement | undefined>(undefined);
const activeTrigger = shallowRef<HTMLElement | undefined>(undefined);
const { getItems, CollectionSlot } = useCollectionProvider<{ value: string }>();
const { getItems, CollectionSlot } = useCollectionProvider<{ value: string }>(NAVIGATION_MENU_COLLECTION_KEY);
const baseId = useId(undefined, 'primitives-navigation-menu-sub');
@@ -22,7 +22,7 @@ import { Primitive } from '../primitive';
import { RovingFocusItem } from '../roving-focus';
import { VisuallyHidden } from '../visually-hidden';
import { useNavigationMenuContext, useNavigationMenuItemContext } from './context';
import { getOpenState } from './utils';
import { NAVIGATION_MENU_COLLECTION_KEY, getOpenState } from './utils';
defineOptions({ inheritAttrs: false });
@@ -31,20 +31,12 @@ const { disabled = false } = defineProps<NavigationMenuTriggerProps>();
const menuContext = useNavigationMenuContext();
const itemContext = useNavigationMenuItemContext();
const { CollectionItem } = useCollectionInjector<{ value: string }>();
const { CollectionItem } = useCollectionInjector<{ value: string }>(NAVIGATION_MENU_COLLECTION_KEY);
const { forwardRef, currentElement: triggerElement } = useForwardExpose();
// Auto-reset flag that suppresses click→toggle right after a pointermove open.
// Set after a pointermove open so further pointermoves don't re-fire
// onTriggerEnter; reset on pointerleave.
const hasPointerMoveOpened = ref(false);
let pointerMoveResetTimer: ReturnType<typeof setTimeout> | undefined;
function markPointerMoveOpened() {
hasPointerMoveOpened.value = true;
if (pointerMoveResetTimer !== undefined) clearTimeout(pointerMoveResetTimer);
pointerMoveResetTimer = setTimeout(() => {
hasPointerMoveOpened.value = false;
pointerMoveResetTimer = undefined;
}, 300);
}
const wasClickClose = ref(false);
@@ -69,7 +61,7 @@ function handlePointerMove(ev: PointerEvent) {
if (ev.pointerType !== 'mouse') return;
if (disabled || wasClickClose.value || itemContext.wasEscapeCloseRef.value || hasPointerMoveOpened.value) return;
menuContext.onTriggerEnter(itemContext.value);
markPointerMoveOpened();
hasPointerMoveOpened.value = true;
}
function handlePointerLeave(ev: PointerEvent) {
@@ -83,11 +75,11 @@ function handlePointerLeave(ev: PointerEvent) {
function handleClick(event: MouseEvent | PointerEvent) {
const isMouse = !('pointerType' in event) || (event as PointerEvent).pointerType === 'mouse';
if (isMouse && menuContext.disableClickTrigger.value) return;
// If pointermove already opened the menu, ignore the resulting click.
if (hasPointerMoveOpened.value) return;
if (open.value) menuContext.onItemSelect('');
else menuContext.onItemSelect(itemContext.value);
wasClickClose.value = open.value;
// Capture before onItemSelect mutates modelValue — `open` is a computed over
// it, so reading it afterwards would be inverted.
const wasOpen = open.value;
menuContext.onItemSelect(wasOpen ? '' : itemContext.value);
wasClickClose.value = wasOpen;
}
function handleKeydown(ev: KeyboardEvent) {
@@ -120,8 +112,11 @@ function handleVisuallyHiddenFocus(ev: FocusEvent) {
</script>
<template>
<CollectionItem :value="{ value: itemContext.value }">
<!-- CollectionItem must wrap the button itself (not RovingFocusItem, which
renders its own span) so the element registered in the nav collection
carries the trigger id that Root/Sub match `activeTrigger` against. -->
<RovingFocusItem :focusable="!disabled">
<CollectionItem :value="{ value: itemContext.value }">
<Primitive
:id="itemContext.triggerId"
:ref="forwardRef"
@@ -143,8 +138,8 @@ function handleVisuallyHiddenFocus(ev: FocusEvent) {
>
<slot />
</Primitive>
</RovingFocusItem>
</CollectionItem>
</RovingFocusItem>
<template v-if="open">
<VisuallyHidden
@@ -0,0 +1,200 @@
import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import {
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuRoot,
NavigationMenuTrigger,
NavigationMenuViewport,
} from '../index';
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
interface MountOptions {
withViewport?: boolean;
contentProps?: Record<string, unknown>;
}
function mountMenu(opts: MountOptions = {}) {
const { withViewport = true, contentProps = {} } = opts;
const items = ['products', 'company'];
const Harness = defineComponent({
setup() {
return () => h(NavigationMenuRoot, null, {
default: () => [
h(NavigationMenuList, null, {
default: () => items.map(value =>
h(NavigationMenuItem, { value }, {
default: () => [
h(NavigationMenuTrigger, { 'data-testid': `trigger-${value}` }, { default: () => value }),
h(NavigationMenuContent, contentProps, {
default: () => h(NavigationMenuLink, { href: '#' }, { default: () => `${value} link` }),
}),
],
}),
),
}),
withViewport ? h(NavigationMenuViewport) : null,
],
});
},
});
return track(mount(Harness, { attachTo: document.body }));
}
function trigger(value = 'products'): HTMLElement {
return document.querySelector<HTMLElement>(`[data-testid="trigger-${value}"]`)!;
}
function content(): HTMLElement | null {
return document.querySelector<HTMLElement>('[data-primitives-navigation-menu-content]');
}
function viewport(): HTMLElement | null {
return document.querySelector<HTMLElement>('[data-primitives-navigation-menu-viewport]');
}
describe('navigation-menu — active trigger collection (context shadowing)', () => {
it('registers the trigger button (not the roving-focus span) in the nav collection', async () => {
mountMenu();
trigger().click();
await nextTick();
// The viewport position vars are derived from `activeTrigger`, which is
// resolved by matching collection item ids against the trigger id pattern.
await sleep(50);
const vp = viewport()!;
expect(vp).toBeTruthy();
expect(vp.style.getPropertyValue('--primitives-navigation-menu-viewport-left')).not.toBe('');
expect(vp.style.getPropertyValue('--primitives-navigation-menu-viewport-top')).not.toBe('');
expect(vp.style.getPropertyValue('--primitives-navigation-menu-viewport-width')).not.toBe('');
expect(vp.style.getPropertyValue('--primitives-navigation-menu-viewport-height')).not.toBe('');
});
});
describe('navigation-menu — close lifecycle (content leak)', () => {
it('unmounts the content after a full open/close cycle instead of leaking it inline', async () => {
mountMenu();
trigger().click();
await nextTick();
await sleep(50);
expect(content()).toBeTruthy();
trigger().click();
await nextTick();
await sleep(50);
expect(trigger().getAttribute('data-state')).toBe('closed');
expect(viewport()).toBeNull();
// Regression: the isLastActiveValue latch used to keep the panel mounted
// forever; with the viewport gone, Teleport rendered it inline in the nav.
expect(content()).toBeNull();
});
it('keeps the previous content mounted during an item-to-item switch (crossfade)', async () => {
mountMenu();
trigger('products').click();
await nextTick();
await sleep(50);
trigger('company').click();
await nextTick();
const all = document.querySelectorAll('[data-primitives-navigation-menu-content]');
// Old panel is latched while the viewport is still mounted.
expect(all.length).toBe(2);
});
});
describe('navigation-menu — outside interaction dismiss', () => {
it('closes the menu on pointerdown outside', async () => {
mountMenu();
trigger().click();
await nextTick();
await sleep(50);
expect(trigger().getAttribute('data-state')).toBe('open');
document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
await nextTick();
await sleep(50);
expect(trigger().getAttribute('data-state')).toBe('closed');
expect(content()).toBeNull();
});
it('does not dismiss when the pointerdown is on the active trigger', async () => {
mountMenu();
trigger().click();
await nextTick();
await sleep(50);
trigger().dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
await nextTick();
expect(trigger().getAttribute('data-state')).toBe('open');
});
});
describe('navigation-menu — trigger click handling', () => {
it('click toggles open then closed', async () => {
mountMenu();
const btn = trigger();
btn.click();
await nextTick();
expect(btn.getAttribute('data-state')).toBe('open');
btn.click();
await nextTick();
expect(btn.getAttribute('data-state')).toBe('closed');
});
it('stays closed after a click-close even if the pointer keeps moving over the trigger', async () => {
mountMenu();
const btn = trigger();
btn.click();
await nextTick();
btn.click();
await nextTick();
expect(btn.getAttribute('data-state')).toBe('closed');
// Pointer is still hovering: a pointermove must not re-open the menu
// (wasClickClose must reflect the pre-click open state).
btn.dispatchEvent(new PointerEvent('pointermove', { pointerType: 'mouse', bubbles: true }));
await sleep(400); // > delayDuration (200ms)
expect(btn.getAttribute('data-state')).toBe('closed');
});
it('opens immediately on click even right after a pointermove', async () => {
mountMenu();
const btn = trigger();
btn.dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse' }));
btn.dispatchEvent(new PointerEvent('pointermove', { pointerType: 'mouse', bubbles: true }));
// Click before the 200ms hover debounce fires — must not be swallowed.
btn.click();
await nextTick();
expect(btn.getAttribute('data-state')).toBe('open');
});
});
describe('navigation-menu — content prop forwarding', () => {
it('forwards `as` from NavigationMenuContent down to the rendered element', async () => {
mountMenu({ contentProps: { as: 'section' } });
trigger().click();
await nextTick();
await sleep(50);
expect(content()).toBeTruthy();
expect(content()!.tagName).toBe('SECTION');
});
});
@@ -45,6 +45,14 @@ export function clamp(value: number, min: number, max: number): number {
/** Selector identifying the link/item nodes for arrow navigation inside content. */
export const COLLECTION_ITEM_ATTR = 'data-primitives-collection-item';
/**
* Namespaced collection key for the trigger collection owned by Root/Sub.
* `NavigationMenuList` renders a `RovingFocusGroup` (itself a collection
* provider) between Root/Sub and the triggers, so the default key would be
* shadowed and the triggers would register into the wrong collection.
*/
export const NAVIGATION_MENU_COLLECTION_KEY = 'NavigationMenuCollection';
/** Custom event dispatched by a `NavigationMenuLink` selection. */
export const LINK_SELECT_EVENT = 'navigationMenu.linkSelect';
/** Custom event bubbled to the root content when an item dismisses the menu. */
@@ -49,12 +49,13 @@ function commitCurrent(target: HTMLInputElement): void {
if (ok) target.value = '';
}
async function onEnter(event: KeyboardEvent): Promise<void> {
function onEnter(event: KeyboardEvent): void {
if (isComposing.value) return;
await nextTick();
if (event.defaultPrevented) return;
const target = event.target as HTMLInputElement;
if (!target.value) return;
// Must run synchronously: after an await the dispatch is over and Enter's
// implicit form submission has already happened.
event.preventDefault();
commitCurrent(target);
}
@@ -47,7 +47,7 @@ provideTagsInputItemContext({
<Primitive
:ref="forwardRef"
:as="as"
:aria-labelledby="textId.value || undefined"
:aria-labelledby="textId || undefined"
:aria-current="isSelected ? 'true' : undefined"
:data-state="isSelected ? 'active' : 'inactive'"
:data-disabled="isDisabled ? '' : undefined"
@@ -168,4 +168,73 @@ describe('TagsInput', () => {
expect((input.element as HTMLInputElement).disabled).toBe(true);
w.unmount();
});
it('tag item is labelled by its ItemText id', async () => {
const w = createTagsInput({ defaultValue: ['a'] });
// ItemText assigns the shared textId during its own setup, one tick after
// the item's first render.
await nextTick();
const item = w.findComponent(TagsInputItem as Component).element as HTMLElement;
const text = w.findComponent(TagsInputItemText as Component).element as HTMLElement;
expect(text.id).toBeTruthy();
expect(item.getAttribute('aria-labelledby')).toBe(text.id);
w.unmount();
});
it('Enter with pending text prevents default synchronously (blocks implicit form submit)', () => {
const w = createTagsInput();
const input = w.find('input').element as HTMLInputElement;
input.value = 'hello';
const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
input.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
w.unmount();
});
it('Enter on an empty input leaves the default action alone', () => {
const w = createTagsInput();
const input = w.find('input').element as HTMLInputElement;
const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
input.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
w.unmount();
});
// Mirrors demo.vue: Clear lives in a footer wrapper that is a deep
// descendant of Root, not a direct slot child.
it('Clear injects context when nested deeper inside Root (demo layout)', async () => {
const w = mount(
defineComponent({
setup() {
const tags = ref<string[]>(['a', 'b']);
return () => h(
TagsInputRoot,
{
modelValue: tags.value,
'onUpdate:modelValue': (v: TagValue[]) => (tags.value = v as string[]),
},
{
default: () => [
h('div', [
...tags.value.map(tag =>
h(TagsInputItem, { key: tag, value: tag }, {
default: () => [h(TagsInputItemText, null, { default: () => tag })],
}),
),
h(TagsInputInput),
]),
h('div', [h(TagsInputClear, null, { default: () => 'Clear all' })]),
],
},
);
},
}),
{ attachTo: document.body },
);
const clearBtn = w.findAll('button').find(b => b.text() === 'Clear all')!;
await clearBtn.trigger('click');
await nextTick();
expect(w.findAllComponents(TagsInputItem as Component)).toHaveLength(0);
w.unmount();
});
});
+5 -2
View File
@@ -29,15 +29,17 @@ function onInvalid() {
Type an address and press Enter, comma, or paste a list.
</p>
<!-- Root wraps the footer too: TagsInputClear must be a descendant to inject the context. -->
<TagsInputRoot
v-model="recipients"
add-on-paste
add-on-blur
:max="5"
delimiter=","
class="mt-4 flex flex-wrap items-center gap-1.5 rounded-lg border border-(--border) bg-(--bg) p-2 transition-colors focus-within:border-(--accent) focus-within:ring-2 focus-within:ring-(--ring) data-[invalid]:border-red-500 dark:data-[invalid]:border-red-400"
class="group"
@invalid="onInvalid"
>
<div class="mt-4 flex flex-wrap items-center gap-1.5 rounded-lg border border-(--border) bg-(--bg) p-2 transition-colors focus-within:border-(--accent) focus-within:ring-2 focus-within:ring-(--ring) group-data-[invalid]:border-red-500 dark:group-data-[invalid]:border-red-400">
<TagsInputItem
v-for="tag in recipients"
:key="tag"
@@ -59,7 +61,7 @@ function onInvalid() {
placeholder="name@company.com"
class="min-w-32 flex-1 bg-transparent px-1 py-0.5 text-sm text-(--fg) outline-none placeholder:text-(--fg-subtle)"
/>
</TagsInputRoot>
</div>
<div class="mt-3 flex items-center justify-between text-sm">
<p
@@ -76,5 +78,6 @@ function onInvalid() {
Clear all
</TagsInputClear>
</div>
</TagsInputRoot>
</div>
</template>
+15 -6
View File
@@ -33,24 +33,33 @@ const { forwardRef } = useForwardExpose();
// DOM-order items via Collection primitive. Survives `v-for` reorders and
// teleport/portal children, unlike a mount-order array.
// Enabled-only: a disabled button is unfocusable, so letting it into the
// roving list would freeze navigation on it and drop the toolbar's tab stop.
const { getItems, CollectionSlot } = useCollectionProvider();
const items = computed(() => getItems(true).map(i => i.ref));
const items = computed(() => getItems().map(i => i.ref));
const activeIndex = ref(0);
// Read fresh rather than through `items`: `getItems` filters on live
// `data-disabled`, which the computed cannot track across runtime toggles.
function enabledItems(): HTMLElement[] {
return getItems().map(i => i.ref);
}
function focusIndex(i: number): void {
const el = items.value[i];
if (el) {
activeIndex.value = i;
const el = enabledItems()[i];
if (!el) return;
el.focus();
}
// Commit only when focus actually landed, so the tab stop never moves
// onto an element that refused focus.
if (document.activeElement === el) activeIndex.value = i;
}
function onItemKeyDown(event: KeyboardEvent, el: HTMLElement): void {
const action = rovingKeyToAction(event, { orientation, dir, loop });
if (!action) return;
event.preventDefault();
const list = items.value;
const list = enabledItems();
const idx = list.indexOf(el);
if (action.absolute === 'home') return focusIndex(0);
if (action.absolute === 'end') return focusIndex(list.length - 1);
@@ -15,6 +15,7 @@ export interface ToolbarSeparatorProps extends PrimitiveProps {
<script setup lang="ts">
import { Primitive } from '../primitive';
import { computed } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useToolbarContext } from './context';
@@ -22,7 +23,7 @@ const { as = 'span', orientation } = defineProps<ToolbarSeparatorProps>();
const { forwardRef } = useForwardExpose();
const ctx = useToolbarContext();
// If no orientation passed, inherit from toolbar — but invert (horizontal toolbar needs vertical separator).
const effective = orientation ?? (ctx.orientation.value === 'horizontal' ? 'vertical' : 'horizontal');
const effective = computed(() => orientation ?? (ctx.orientation.value === 'horizontal' ? 'vertical' : 'horizontal'));
</script>
<template>
@@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import { defineComponent, h, nextTick, ref } from 'vue';
import { ToolbarButton, ToolbarRoot, ToolbarSeparator } from '../index';
function mountToolbar(opts: { orientation?: 'horizontal' | 'vertical'; dir?: 'ltr' | 'rtl'; loop?: boolean } = {}) {
@@ -99,4 +99,118 @@ describe('Toolbar', () => {
expect(document.activeElement).toBe(btns[0]);
w.unmount();
});
describe('disabled items', () => {
function mountWithDisabled(disabled: boolean[], opts: { loop?: boolean } = {}) {
const Harness = defineComponent({
setup: () => () => h(ToolbarRoot, opts, {
default: () => disabled.map((d, i) =>
h(ToolbarButton, { id: `b${i + 1}`, disabled: d }, { default: () => `Item ${i + 1}` }),
),
}),
});
return mount(Harness, { attachTo: document.body });
}
it('arrow navigation skips a disabled last item and wraps to the first enabled one', async () => {
const w = mountWithDisabled([false, false, true]);
await nextTick();
const btns = document.querySelectorAll<HTMLElement>('button');
btns[1]!.focus();
press(btns[1]!, 'ArrowRight');
await nextTick();
expect(document.activeElement).toBe(btns[0]);
w.unmount();
});
it('ArrowLeft from the first item wraps past a disabled last item', async () => {
const w = mountWithDisabled([false, false, true]);
await nextTick();
const btns = document.querySelectorAll<HTMLElement>('button');
btns[0]!.focus();
press(btns[0]!, 'ArrowLeft');
await nextTick();
expect(document.activeElement).toBe(btns[1]);
w.unmount();
});
it('End jumps to the last enabled item and keeps the tab stop on it', async () => {
const w = mountWithDisabled([false, false, true]);
await nextTick();
const btns = document.querySelectorAll<HTMLElement>('button');
btns[0]!.focus();
press(btns[0]!, 'End');
await nextTick();
expect(document.activeElement).toBe(btns[1]);
expect(btns[0]!.tabIndex).toBe(-1);
expect(btns[1]!.tabIndex).toBe(0);
expect(btns[2]!.tabIndex).toBe(-1);
w.unmount();
});
it('loop=false clamps at the last enabled item, not the disabled one', async () => {
const w = mountWithDisabled([false, false, true], { loop: false });
await nextTick();
const btns = document.querySelectorAll<HTMLElement>('button');
btns[1]!.focus();
press(btns[1]!, 'ArrowRight');
await nextTick();
expect(document.activeElement).toBe(btns[1]);
expect(btns[1]!.tabIndex).toBe(0);
w.unmount();
});
it('first enabled item carries the tab stop when item 0 is disabled', async () => {
const w = mountWithDisabled([true, false, false]);
await nextTick();
const btns = document.querySelectorAll<HTMLElement>('button');
expect(btns[0]!.tabIndex).toBe(-1);
expect(btns[1]!.tabIndex).toBe(0);
expect(btns[2]!.tabIndex).toBe(-1);
w.unmount();
});
});
describe('separator orientation reactivity', () => {
it('inherited orientation follows toolbar orientation changes', async () => {
const orientation = ref<'horizontal' | 'vertical'>('horizontal');
const Harness = defineComponent({
setup: () => () => h(ToolbarRoot, { orientation: orientation.value }, {
default: () => [
h(ToolbarButton, { id: 'b1' }, { default: () => 'One' }),
h(ToolbarSeparator),
h(ToolbarButton, { id: 'b2' }, { default: () => 'Two' }),
],
}),
});
const w = mount(Harness, { attachTo: document.body });
const sep = document.querySelector<HTMLElement>('[role="separator"]')!;
expect(sep.getAttribute('aria-orientation')).toBe('vertical');
orientation.value = 'vertical';
await nextTick();
expect(sep.getAttribute('aria-orientation')).toBe('horizontal');
expect(sep.getAttribute('data-orientation')).toBe('horizontal');
w.unmount();
});
it('explicit orientation prop updates reactively', async () => {
const sepOrientation = ref<'horizontal' | 'vertical' | undefined>(undefined);
const Harness = defineComponent({
setup: () => () => h(ToolbarRoot, {}, {
default: () => [
h(ToolbarButton, { id: 'b1' }, { default: () => 'One' }),
h(ToolbarSeparator, { orientation: sepOrientation.value }),
h(ToolbarButton, { id: 'b2' }, { default: () => 'Two' }),
],
}),
});
const w = mount(Harness, { attachTo: document.body });
const sep = document.querySelector<HTMLElement>('[role="separator"]')!;
expect(sep.getAttribute('aria-orientation')).toBe('vertical');
sepOrientation.value = 'horizontal';
await nextTick();
expect(sep.getAttribute('aria-orientation')).toBe('horizontal');
w.unmount();
});
});
});