diff --git a/docs/modules/extractor/extract.ts b/docs/modules/extractor/extract.ts
index 8dce030..035c6bd 100644
--- a/docs/modules/extractor/extract.ts
+++ b/docs/modules/extractor/extract.ts
@@ -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(/\s*\*?\/?\s*$/, '');
+}
+
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 `
…
` (JSDoc example title) isn't valid code —
// turn it into a leading comment so the snippet stays clean & highlightable.
let caption = '';
diff --git a/vue/primitives/src/aspect-ratio/AspectRatio.vue b/vue/primitives/src/aspect-ratio/AspectRatio.vue
index f3227ba..b0cfe3b 100644
--- a/vue/primitives/src/aspect-ratio/AspectRatio.vue
+++ b/vue/primitives/src/aspect-ratio/AspectRatio.vue
@@ -17,6 +17,7 @@ export interface AspectRatioProps extends PrimitiveProps {
diff --git a/vue/primitives/src/calendar/CalendarRoot.vue b/vue/primitives/src/calendar/CalendarRoot.vue
index 132c9d7..0e84c85 100644
--- a/vue/primitives/src/calendar/CalendarRoot.vue
+++ b/vue/primitives/src/calendar/CalendarRoot.vue
@@ -80,8 +80,6 @@ import {
toDateOnly,
} from './utils';
-defineOptions({ inheritAttrs: false });
-
const {
as = 'div',
defaultValue,
@@ -159,6 +157,7 @@ const grid = computed(() => createMonths({
date: placeholder.value,
numberOfMonths,
weekStartsOn,
+ fixedWeeks,
}));
const weekDays = computed(() => getWeekdayLabels(weekStartsOn, locale, weekdayFormat));
diff --git a/vue/primitives/src/calendar/__test__/Calendar.test.ts b/vue/primitives/src/calendar/__test__/Calendar.test.ts
new file mode 100644
index 0000000..e610de4
--- /dev/null
+++ b/vue/primitives/src/calendar/__test__/Calendar.test.ts
@@ -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 = {},
+ options: Record = {},
+) {
+ 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);
+ });
+});
diff --git a/vue/primitives/src/calendar/__test__/__screenshots__/Calendar.test.ts/Calendar-mounts-cell-triggers-without--expose---should-be-called-only-once--warnings-1.png b/vue/primitives/src/calendar/__test__/__screenshots__/Calendar.test.ts/Calendar-mounts-cell-triggers-without--expose---should-be-called-only-once--warnings-1.png
new file mode 100644
index 0000000..47767d2
Binary files /dev/null and b/vue/primitives/src/calendar/__test__/__screenshots__/Calendar.test.ts/Calendar-mounts-cell-triggers-without--expose---should-be-called-only-once--warnings-1.png differ
diff --git a/vue/primitives/src/calendar/__test__/date-utils.test.ts b/vue/primitives/src/calendar/__test__/date-utils.test.ts
index 4ca9fc1..71f4263 100644
--- a/vue/primitives/src/calendar/__test__/date-utils.test.ts
+++ b/vue/primitives/src/calendar/__test__/date-utils.test.ts
@@ -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);
diff --git a/vue/primitives/src/calendar/date-utils.ts b/vue/primitives/src/calendar/date-utils.ts
index 941a427..c07b3e5 100644
--- a/vue/primitives/src/calendar/date-utils.ts
+++ b/vue/primitives/src/calendar/date-utils.ts
@@ -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()
diff --git a/vue/primitives/src/calendar/utils.ts b/vue/primitives/src/calendar/utils.ts
index f3f347e..ae41e4f 100644
--- a/vue/primitives/src/calendar/utils.ts
+++ b/vue/primitives/src/calendar/utils.ts
@@ -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;
}
diff --git a/vue/primitives/src/collection/__test__/useCollection.test.ts b/vue/primitives/src/collection/__test__/useCollection.test.ts
new file mode 100644
index 0000000..687b868
--- /dev/null
+++ b/vue/primitives/src/collection/__test__/useCollection.test.ts
@@ -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> = [];
+
+afterEach(() => {
+ while (wrappers.length) wrappers.pop()!.unmount();
+ document.body.innerHTML = '';
+});
+
+function track>(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']);
+ });
+});
diff --git a/vue/primitives/src/collection/useCollection.ts b/vue/primitives/src/collection/useCollection.ts
index 014c557..1d2e9ee 100644
--- a/vue/primitives/src/collection/useCollection.ts
+++ b/vue/primitives/src/collection/useCollection.ts
@@ -157,29 +157,55 @@ function createCollectionState(): CollectionContext {
};
}
-const CollectionCtx = useContextFactory('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>
+>();
+
+function getCollectionContextFactory(key: string) {
+ let factory = collectionContextFactories.get(key);
+ if (!factory) {
+ factory = useContextFactory(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(): CollectionContext {
+export function useCollectionProvider(
+ key: string = DEFAULT_COLLECTION_KEY,
+): CollectionContext {
const ctx = createCollectionState();
- 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(): CollectionContext {
- return CollectionCtx.inject() as CollectionContext;
+export function useCollectionInjector(
+ key: string = DEFAULT_COLLECTION_KEY,
+): CollectionContext {
+ return getCollectionContextFactory(key).inject() as CollectionContext;
}
diff --git a/vue/primitives/src/combobox/ComboboxContentImpl.vue b/vue/primitives/src/combobox/ComboboxContentImpl.vue
index 41be878..b9991ae 100644
--- a/vue/primitives/src/combobox/ComboboxContentImpl.vue
+++ b/vue/primitives/src/combobox/ComboboxContentImpl.vue
@@ -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)"
diff --git a/vue/primitives/src/combobox/ComboboxInput.vue b/vue/primitives/src/combobox/ComboboxInput.vue
index ecf602b..3f16034 100644
--- a/vue/primitives/src/combobox/ComboboxInput.vue
+++ b/vue/primitives/src/combobox/ComboboxInput.vue
@@ -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;
diff --git a/vue/primitives/src/combobox/ComboboxItem.vue b/vue/primitives/src/combobox/ComboboxItem.vue
index 26e1e06..6af98e1 100644
--- a/vue/primitives/src/combobox/ComboboxItem.vue
+++ b/vue/primitives/src/combobox/ComboboxItem.vue
@@ -26,7 +26,6 @@ import { provideComboboxItemContext, useComboboxGroupContext, useComboboxRootCon
const props = defineProps>();
-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 });
diff --git a/vue/primitives/src/combobox/ComboboxRoot.vue b/vue/primitives/src/combobox/ComboboxRoot.vue
index da105ab..433c30b 100644
--- a/vue/primitives/src/combobox/ComboboxRoot.vue
+++ b/vue/primitives/src/combobox/ComboboxRoot.vue
@@ -79,31 +79,17 @@ const {
const config = useConfig();
const direction = computed(() => dir ?? config.dir.value);
-const localOpen = ref(defaultOpen);
/** Controlled open state. Use `v-model:open`. */
-const open = defineModel('open', {
- default: undefined,
- get: v => v ?? localOpen.value,
- set: (v) => {
- localOpen.value = v;
- return v;
- },
-});
+const open = defineModel('open', { default: false });
+if (defaultOpen && !open.value) open.value = true;
-const initial = (modelValue ?? defaultValue) as T | T[] | undefined;
-const localValue = shallowRef(
- multiple
- ? (Array.isArray(initial) ? initial.slice() : (initial === undefined ? [] : [initial]))
- : (Array.isArray(initial) ? initial[0] : initial),
-);
-const value = defineModel('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('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();
diff --git a/vue/primitives/src/combobox/__test__/Combobox.test.ts b/vue/primitives/src/combobox/__test__/Combobox.test.ts
new file mode 100644
index 0000000..efd9110
--- /dev/null
+++ b/vue/primitives/src/combobox/__test__/Combobox.test.ts
@@ -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;
+}
+
+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('#input')!;
+}
+
+function visibleItemTexts(): string[] {
+ return Array.from(document.querySelectorAll('[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(undefined);
+ const w = mountCombobox({ model });
+ await nextTick();
+
+ await userEvent.click(document.querySelector('#trigger')!);
+ await flush();
+ const banana = Array.from(document.querySelectorAll('[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('[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();
+ });
+});
diff --git a/vue/primitives/src/combobox/utils.ts b/vue/primitives/src/combobox/utils.ts
index cca782d..5ad775f 100644
--- a/vue/primitives/src/combobox/utils.ts
+++ b/vue/primitives/src/combobox/utils.ts
@@ -1,6 +1,9 @@
export type AcceptableValue = string | number | boolean | Record;
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 {
diff --git a/vue/primitives/src/context-menu/ContextMenuTrigger.vue b/vue/primitives/src/context-menu/ContextMenuTrigger.vue
index efca476..43031aa 100644
--- a/vue/primitives/src/context-menu/ContextMenuTrigger.vue
+++ b/vue/primitives/src/context-menu/ContextMenuTrigger.vue
@@ -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() {
-
+
diff --git a/vue/primitives/src/context-menu/__test__/ContextMenu.test.ts b/vue/primitives/src/context-menu/__test__/ContextMenu.test.ts
new file mode 100644
index 0000000..970e52a
--- /dev/null
+++ b/vue/primitives/src/context-menu/__test__/ContextMenu.test.ts
@@ -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> = [];
+
+afterEach(() => {
+ while (wrappers.length) wrappers.pop()!.unmount();
+ document.body.innerHTML = '';
+ document.body.removeAttribute('style');
+ vi.useRealTimers();
+});
+
+function track>(w: T): T {
+ wrappers.push(w);
+ return w;
+}
+
+function mountContextMenu(options: {
+ triggerAttrs?: Record;
+ 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');
+ });
+});
diff --git a/vue/primitives/src/dismissable-layer/DismissableLayer.vue b/vue/primitives/src/dismissable-layer/DismissableLayer.vue
index 38e740f..4062886 100644
--- a/vue/primitives/src/dismissable-layer/DismissableLayer.vue
+++ b/vue/primitives/src/dismissable-layer/DismissableLayer.vue
@@ -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(
+ 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
diff --git a/vue/primitives/src/dismissable-layer/__test__/DismissableLayer.test.ts b/vue/primitives/src/dismissable-layer/__test__/DismissableLayer.test.ts
index 9c0bf0c..63446ba 100644
--- a/vue/primitives/src/dismissable-layer/__test__/DismissableLayer.test.ts
+++ b/vue/primitives/src/dismissable-layer/__test__/DismissableLayer.test.ts
@@ -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: '' },
+ 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: '' },
+ });
+ 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: '' },
+ });
+ 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: '' },
+ 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: '' },
+ 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,
diff --git a/vue/primitives/src/dropdown-menu/DropdownMenuContent.vue b/vue/primitives/src/dropdown-menu/DropdownMenuContent.vue
index 6e985b6..5d69b1b 100644
--- a/vue/primitives/src/dropdown-menu/DropdownMenuContent.vue
+++ b/vue/primitives/src/dropdown-menu/DropdownMenuContent.vue
@@ -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')"
diff --git a/vue/primitives/src/dropdown-menu/DropdownMenuTrigger.vue b/vue/primitives/src/dropdown-menu/DropdownMenuTrigger.vue
index ef85841..7dac672 100644
--- a/vue/primitives/src/dropdown-menu/DropdownMenuTrigger.vue
+++ b/vue/primitives/src/dropdown-menu/DropdownMenuTrigger.vue
@@ -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) {
-
+ > = [];
+
+afterEach(() => {
+ while (wrappers.length) wrappers.pop()!.unmount();
+ document.body.innerHTML = '';
+});
+
+function track>(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('[data-testid="trigger"]')!;
+}
+
+function menu(): HTMLElement | null {
+ return document.querySelector('[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
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');
+ });
+});
diff --git a/vue/primitives/src/editable/EditableCancelTrigger.vue b/vue/primitives/src/editable/EditableCancelTrigger.vue
index a6565ff..199a27a 100644
--- a/vue/primitives/src/editable/EditableCancelTrigger.vue
+++ b/vue/primitives/src/editable/EditableCancelTrigger.vue
@@ -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"
>
Cancel
diff --git a/vue/primitives/src/editable/EditableEditTrigger.vue b/vue/primitives/src/editable/EditableEditTrigger.vue
index 8ff62c9..e287c75 100644
--- a/vue/primitives/src/editable/EditableEditTrigger.vue
+++ b/vue/primitives/src/editable/EditableEditTrigger.vue
@@ -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"
>
Edit
diff --git a/vue/primitives/src/editable/EditableInput.vue b/vue/primitives/src/editable/EditableInput.vue
index 58f9f5b..6b14ae1 100644
--- a/vue/primitives/src/editable/EditableInput.vue
+++ b/vue/primitives/src/editable/EditableInput.vue
@@ -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"
diff --git a/vue/primitives/src/editable/EditablePreview.vue b/vue/primitives/src/editable/EditablePreview.vue
index f20d060..24644c9 100644
--- a/vue/primitives/src/editable/EditablePreview.vue
+++ b/vue/primitives/src/editable/EditablePreview.vue
@@ -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"
>
diff --git a/vue/primitives/src/editable/EditableRoot.vue b/vue/primitives/src/editable/EditableRoot.vue
index 90da246..d61a37c 100644
--- a/vue/primitives/src/editable/EditableRoot.vue
+++ b/vue/primitives/src/editable/EditableRoot.vue
@@ -38,7 +38,7 @@ export interface EditableRootEmits {
diff --git a/vue/primitives/src/menu/MenuContentImpl.vue b/vue/primitives/src/menu/MenuContentImpl.vue
index 2ea96ed..507ea97 100644
--- a/vue/primitives/src/menu/MenuContentImpl.vue
+++ b/vue/primitives/src/menu/MenuContentImpl.vue
@@ -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) {
- contentElement.value?.focus({ preventScroll: true });
- }
+ // 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('[data-primitives-menu-item]:not([data-disabled])'),
+ );
+ if (LAST_KEYS.includes(event.key)) items.reverse();
+ focusFirst(items);
+ }
}
}
diff --git a/vue/primitives/src/menu/MenuItem.vue b/vue/primitives/src/menu/MenuItem.vue
index e46abb1..6125abb 100644
--- a/vue/primitives/src/menu/MenuItem.vue
+++ b/vue/primitives/src/menu/MenuItem.vue
@@ -21,9 +21,12 @@ const emit = defineEmits();
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();
}
diff --git a/vue/primitives/src/menu/MenuItemImpl.vue b/vue/primitives/src/menu/MenuItemImpl.vue
index 3e0979a..8e174d1 100644
--- a/vue/primitives/src/menu/MenuItemImpl.vue
+++ b/vue/primitives/src/menu/MenuItemImpl.vue
@@ -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.
-
+ emit('select', e), { once: true });
+ target.dispatchEvent(selectEvent);
if (!selectEvent.defaultPrevented) rootCtx.onClose();
}
diff --git a/vue/primitives/src/menu/MenuRootContentModal.vue b/vue/primitives/src/menu/MenuRootContentModal.vue
index 1ac212e..5477a06 100644
--- a/vue/primitives/src/menu/MenuRootContentModal.vue
+++ b/vue/primitives/src/menu/MenuRootContentModal.vue
@@ -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)"
>
diff --git a/vue/primitives/src/menu/MenuRootContentNonModal.vue b/vue/primitives/src/menu/MenuRootContentNonModal.vue
index c4cf633..4221b74 100644
--- a/vue/primitives/src/menu/MenuRootContentNonModal.vue
+++ b/vue/primitives/src/menu/MenuRootContentNonModal.vue
@@ -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)"
>
diff --git a/vue/primitives/src/menu/MenuSubTrigger.vue b/vue/primitives/src/menu/MenuSubTrigger.vue
index daf735e..d192bbf 100644
--- a/vue/primitives/src/menu/MenuSubTrigger.vue
+++ b/vue/primitives/src/menu/MenuSubTrigger.vue
@@ -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.
-
+
diff --git a/vue/primitives/src/menu/__test__/Menu.test.ts b/vue/primitives/src/menu/__test__/Menu.test.ts
new file mode 100644
index 0000000..39298d9
--- /dev/null
+++ b/vue/primitives/src/menu/__test__/Menu.test.ts
@@ -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> = [];
+
+afterEach(() => {
+ while (wrappers.length) wrappers.pop()!.unmount();
+ document.body.innerHTML = '';
+ document.body.style.pointerEvents = '';
+});
+
+function track>(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('[role="menu"]')!;
+}
+
+function items(): HTMLElement[] {
+ return Array.from(document.querySelectorAll('[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('.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('.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);
+ });
+});
diff --git a/vue/primitives/src/navigation-menu/NavigationMenuContent.vue b/vue/primitives/src/navigation-menu/NavigationMenuContent.vue
index ec48fda..10ebcab 100644
--- a/vue/primitives/src/navigation-menu/NavigationMenuContent.vue
+++ b/vue/primitives/src/navigation-menu/NavigationMenuContent.vue
@@ -25,7 +25,6 @@ import NavigationMenuContentImpl from './NavigationMenuContentImpl.vue';
defineOptions({ inheritAttrs: false });
const { forceMount = false, ...rest } = defineProps();
-void rest;
const emit = defineEmits();
@@ -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() {
();
+const { as } = defineProps();
const emit = defineEmits();
@@ -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) => {
{
@pointer-down-outside="handlePointerDownOutside"
@focus-outside="handleFocusOutside"
@interact-outside="emit('interactOutside', $event)"
- @dismiss="emit('dismiss')"
+ @dismiss="handleDismiss"
@pointerenter="emit('pointerEnterContent')"
@pointerleave="emit('pointerLeaveContent')"
>
diff --git a/vue/primitives/src/navigation-menu/NavigationMenuRoot.vue b/vue/primitives/src/navigation-menu/NavigationMenuRoot.vue
index 724ca1e..19cb04b 100644
--- a/vue/primitives/src/navigation-menu/NavigationMenuRoot.vue
+++ b/vue/primitives/src/navigation-menu/NavigationMenuRoot.vue
@@ -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(undefined);
const viewport = shallowRef(undefined);
const activeTrigger = shallowRef(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).
diff --git a/vue/primitives/src/navigation-menu/NavigationMenuSub.vue b/vue/primitives/src/navigation-menu/NavigationMenuSub.vue
index 0b59360..9b801be 100644
--- a/vue/primitives/src/navigation-menu/NavigationMenuSub.vue
+++ b/vue/primitives/src/navigation-menu/NavigationMenuSub.vue
@@ -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(undefined);
const viewport = shallowRef(undefined);
const activeTrigger = shallowRef(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');
diff --git a/vue/primitives/src/navigation-menu/NavigationMenuTrigger.vue b/vue/primitives/src/navigation-menu/NavigationMenuTrigger.vue
index bfaaaa1..2c33935 100644
--- a/vue/primitives/src/navigation-menu/NavigationMenuTrigger.vue
+++ b/vue/primitives/src/navigation-menu/NavigationMenuTrigger.vue
@@ -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();
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 | 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) {
-
-
+
+
+
-
-
+
+ > = [];
+
+afterEach(() => {
+ while (wrappers.length) wrappers.pop()!.unmount();
+ document.body.innerHTML = '';
+});
+
+function track>(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;
+}
+
+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(`[data-testid="trigger-${value}"]`)!;
+}
+
+function content(): HTMLElement | null {
+ return document.querySelector('[data-primitives-navigation-menu-content]');
+}
+
+function viewport(): HTMLElement | null {
+ return document.querySelector('[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');
+ });
+});
diff --git a/vue/primitives/src/navigation-menu/utils.ts b/vue/primitives/src/navigation-menu/utils.ts
index cbfe734..d8f371f 100644
--- a/vue/primitives/src/navigation-menu/utils.ts
+++ b/vue/primitives/src/navigation-menu/utils.ts
@@ -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. */
diff --git a/vue/primitives/src/tags-input/TagsInputInput.vue b/vue/primitives/src/tags-input/TagsInputInput.vue
index a968d18..f2480ed 100644
--- a/vue/primitives/src/tags-input/TagsInputInput.vue
+++ b/vue/primitives/src/tags-input/TagsInputInput.vue
@@ -49,12 +49,13 @@ function commitCurrent(target: HTMLInputElement): void {
if (ok) target.value = '';
}
-async function onEnter(event: KeyboardEvent): Promise {
+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);
}
diff --git a/vue/primitives/src/tags-input/TagsInputItem.vue b/vue/primitives/src/tags-input/TagsInputItem.vue
index 9e1c08c..61ea167 100644
--- a/vue/primitives/src/tags-input/TagsInputItem.vue
+++ b/vue/primitives/src/tags-input/TagsInputItem.vue
@@ -47,7 +47,7 @@ provideTagsInputItemContext({
{
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(['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();
+ });
});
diff --git a/vue/primitives/src/tags-input/demo.vue b/vue/primitives/src/tags-input/demo.vue
index 5f62ced..5fabf5e 100644
--- a/vue/primitives/src/tags-input/demo.vue
+++ b/vue/primitives/src/tags-input/demo.vue
@@ -29,52 +29,55 @@ function onInvalid() {
Type an address and press Enter, comma, or paste a list.
+
-
-
-
+
-
-
-
+
+
+
+
+
-
+
+