Merge pull request #143 from robonen/docs
feat(navigation-menu): enhance context handling and lifecycle management
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
function focusIndex(i: number): void {
|
||||
const el = items.value[i];
|
||||
if (el) {
|
||||
activeIndex.value = i;
|
||||
el.focus();
|
||||
// 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 = 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user