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
|
- name: Test
|
||||||
run: pnpm --filter "${{ matrix.package }}" --if-present run 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 '';
|
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[] {
|
function getExamples(tags: JSDocTag[]): string[] {
|
||||||
return tags
|
return tags
|
||||||
.filter(t => t.getTagName() === 'example')
|
.filter(t => t.getTagName() === 'example')
|
||||||
.map((t) => {
|
.map((t) => {
|
||||||
let text = t.getCommentText()?.trim() ?? '';
|
let text = rawExampleText(t).trim();
|
||||||
// A leading `<caption>…</caption>` (JSDoc example title) isn't valid code —
|
// A leading `<caption>…</caption>` (JSDoc example title) isn't valid code —
|
||||||
// turn it into a leading comment so the snippet stays clean & highlightable.
|
// turn it into a leading comment so the snippet stays clean & highlightable.
|
||||||
let caption = '';
|
let caption = '';
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface AspectRatioProps extends PrimitiveProps {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
import { Primitive } from '../primitive';
|
import { Primitive } from '../primitive';
|
||||||
import { useForwardExpose } from '@robonen/vue';
|
import { useForwardExpose } from '@robonen/vue';
|
||||||
|
|
||||||
@@ -24,11 +25,11 @@ const { forwardRef } = useForwardExpose();
|
|||||||
|
|
||||||
const { ratio = 1, as = 'div' } = defineProps<AspectRatioProps>();
|
const { ratio = 1, as = 'div' } = defineProps<AspectRatioProps>();
|
||||||
|
|
||||||
const wrapperStyle = {
|
const wrapperStyle = computed(() => ({
|
||||||
position: 'relative' as const,
|
position: 'relative' as const,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
paddingBottom: `${(1 / ratio) * 100}%`,
|
paddingBottom: `${(1 / ratio) * 100}%`,
|
||||||
};
|
}));
|
||||||
|
|
||||||
// Hoisted constant — the inner style never depends on props, so a single
|
// Hoisted constant — the inner style never depends on props, so a single
|
||||||
// module-level object is reused across all instances.
|
// module-level object is reused across all instances.
|
||||||
|
|||||||
@@ -15,6 +15,18 @@ describe('AspectRatio', () => {
|
|||||||
expect(outer.style.paddingBottom).toMatch(/^56\.25%$/);
|
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', () => {
|
it('places inner element absolutely covering the wrapper', () => {
|
||||||
const wrapper = mount(AspectRatio, { props: { ratio: 4 / 3 }, slots: { default: '<img />' } });
|
const wrapper = mount(AspectRatio, { props: { ratio: 4 / 3 }, slots: { default: '<img />' } });
|
||||||
const inner = wrapper.element.firstElementChild as HTMLElement;
|
const inner = wrapper.element.firstElementChild as HTMLElement;
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { useForwardExpose } from '@robonen/vue';
|
|||||||
import { computed, nextTick } from 'vue';
|
import { computed, nextTick } from 'vue';
|
||||||
import { Primitive } from '../primitive';
|
import { Primitive } from '../primitive';
|
||||||
import { useCalendarGridContext, useCalendarRootContext } from './context';
|
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>();
|
const { as = 'div', day, month } = defineProps<CalendarCellTriggerProps>();
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ defineSlots<{
|
|||||||
|
|
||||||
const ctx = useCalendarRootContext();
|
const ctx = useCalendarRootContext();
|
||||||
const gridCtx = useCalendarGridContext();
|
const gridCtx = useCalendarGridContext();
|
||||||
const { forwardRef, currentElement } = useForwardExpose();
|
const { forwardRef } = useForwardExpose();
|
||||||
|
|
||||||
const monthValue = computed(() => month ?? gridCtx.month.value);
|
const monthValue = computed(() => month ?? gridCtx.month.value);
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ function focusByDataValue(target: Date) {
|
|||||||
const parent = ctx.parentElement.value;
|
const parent = ctx.parentElement.value;
|
||||||
if (!parent) return false;
|
if (!parent) return false;
|
||||||
const el = parent.querySelector<HTMLElement>(
|
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) {
|
if (el) {
|
||||||
el.focus();
|
el.focus();
|
||||||
@@ -89,8 +89,8 @@ function focusByDataValue(target: Date) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shiftFocus(target: Date) {
|
function shiftFocus(target: Date) {
|
||||||
if (ctx.minValue.value && target < ctx.minValue.value) return;
|
if (ctx.minValue.value && isBefore(target, ctx.minValue.value)) return;
|
||||||
if (ctx.maxValue.value && target > ctx.maxValue.value) return;
|
if (ctx.maxValue.value && isAfter(target, ctx.maxValue.value)) return;
|
||||||
ctx.focusedDate.value = target;
|
ctx.focusedDate.value = target;
|
||||||
if (focusByDataValue(target)) return;
|
if (focusByDataValue(target)) return;
|
||||||
// Crossed visible range — page placeholder and retry.
|
// Crossed visible range — page placeholder and retry.
|
||||||
@@ -159,14 +159,12 @@ function handleFocus() {
|
|||||||
ctx.focusedDate.value = day;
|
ctx.focusedDate.value = day;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataValue = computed(() => day.toISOString().slice(0, 10));
|
const dataValue = computed(() => toIsoDate(day));
|
||||||
const tabindex = computed(() => {
|
const tabindex = computed(() => {
|
||||||
if (isFocusedDate.value) return 0;
|
if (isFocusedDate.value) return 0;
|
||||||
if (isOutsideView.value || isDisabled.value) return undefined;
|
if (isOutsideView.value || isDisabled.value) return undefined;
|
||||||
return -1;
|
return -1;
|
||||||
});
|
});
|
||||||
|
|
||||||
defineExpose({ currentElement });
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -80,8 +80,6 @@ import {
|
|||||||
toDateOnly,
|
toDateOnly,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
|
|
||||||
defineOptions({ inheritAttrs: false });
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
as = 'div',
|
as = 'div',
|
||||||
defaultValue,
|
defaultValue,
|
||||||
@@ -159,6 +157,7 @@ const grid = computed<CalendarMonth[]>(() => createMonths({
|
|||||||
date: placeholder.value,
|
date: placeholder.value,
|
||||||
numberOfMonths,
|
numberOfMonths,
|
||||||
weekStartsOn,
|
weekStartsOn,
|
||||||
|
fixedWeeks,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const weekDays = computed(() => getWeekdayLabels(weekStartsOn, locale, weekdayFormat));
|
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,
|
isDateUnavailable,
|
||||||
isSameDay,
|
isSameDay,
|
||||||
startOfWeek,
|
startOfWeek,
|
||||||
|
toIsoDate,
|
||||||
} from '../date-utils';
|
} from '../date-utils';
|
||||||
|
|
||||||
describe('date-utils', () => {
|
describe('date-utils', () => {
|
||||||
@@ -35,6 +36,13 @@ describe('date-utils', () => {
|
|||||||
expect(isSameDay(a, new Date(2024, 5, 2))).toBe(false);
|
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', () => {
|
it('isDateUnavailable honors min/max and predicate', () => {
|
||||||
const min = new Date(2024, 0, 5);
|
const min = new Date(2024, 0, 5);
|
||||||
const max = new Date(2024, 0, 25);
|
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);
|
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 {
|
export function isSameDay(a: Date, b: Date): boolean {
|
||||||
return a.getFullYear() === b.getFullYear()
|
return a.getFullYear() === b.getFullYear()
|
||||||
&& a.getMonth() === b.getMonth()
|
&& a.getMonth() === b.getMonth()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
formatDate,
|
formatDate,
|
||||||
formatWeekday,
|
formatWeekday,
|
||||||
getWeeks,
|
getWeeks,
|
||||||
|
isSameMonth,
|
||||||
startOfMonth,
|
startOfMonth,
|
||||||
startOfWeek,
|
startOfWeek,
|
||||||
} from './date-utils';
|
} from './date-utils';
|
||||||
@@ -13,7 +14,7 @@ export * from './date-utils';
|
|||||||
export interface CalendarMonth {
|
export interface CalendarMonth {
|
||||||
/** First day of this month (date-only). */
|
/** First day of this month (date-only). */
|
||||||
value: Date;
|
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[][];
|
weeks: Date[][];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,14 +22,22 @@ export interface CreateMonthsOptions {
|
|||||||
date: Date;
|
date: Date;
|
||||||
numberOfMonths: number;
|
numberOfMonths: number;
|
||||||
weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
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. */
|
/** Build N consecutive months starting from `date`'s month. */
|
||||||
export function createMonths(opts: CreateMonthsOptions): CalendarMonth[] {
|
export function createMonths(opts: CreateMonthsOptions): CalendarMonth[] {
|
||||||
|
const { fixedWeeks = true } = opts;
|
||||||
const months: CalendarMonth[] = [];
|
const months: CalendarMonth[] = [];
|
||||||
for (let i = 0; i < opts.numberOfMonths; i++) {
|
for (let i = 0; i < opts.numberOfMonths; i++) {
|
||||||
const m = startOfMonth(addMonths(opts.date, 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;
|
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.
|
* Creates a new collection state and provides it to descendants.
|
||||||
* Call this in the parent (e.g. `RovingFocusGroup`, `ListboxRoot`).
|
* 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
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
* const { getItems, CollectionSlot } = useCollectionProvider();
|
* 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>();
|
const ctx = createCollectionState<Value>();
|
||||||
CollectionCtx.provide(ctx as CollectionContext);
|
getCollectionContextFactory(key).provide(ctx as CollectionContext);
|
||||||
return ctx;
|
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`).
|
* Call this in children (e.g. `RovingFocusItem`, `ListboxItem`).
|
||||||
*
|
*
|
||||||
* @throws when used outside a provider.
|
* @throws when used outside a provider.
|
||||||
*/
|
*/
|
||||||
export function useCollectionInjector<Value = unknown>(): CollectionContext<Value> {
|
export function useCollectionInjector<Value = unknown>(
|
||||||
return CollectionCtx.inject() as CollectionContext<Value>;
|
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);
|
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 target = event.target as Element | null;
|
||||||
|
const parent = rootCtx.parentElement.value;
|
||||||
const input = rootCtx.inputElement.value;
|
const input = rootCtx.inputElement.value;
|
||||||
const trigger = rootCtx.triggerElement.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();
|
event.preventDefault();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDownOutside(event: any) {
|
||||||
emit('pointerDownOutside', event);
|
emit('pointerDownOutside', event);
|
||||||
if (!event.defaultPrevented) rootCtx.onOpenChange(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFocusOutside(event: any) {
|
function handleFocusOutside(event: any) {
|
||||||
@@ -92,6 +97,7 @@ function handleCloseAutoFocus(event: Event) {
|
|||||||
as="template"
|
as="template"
|
||||||
:disable-outside-pointer-events="props.disableOutsidePointerEvents ?? false"
|
:disable-outside-pointer-events="props.disableOutsidePointerEvents ?? false"
|
||||||
@escape-key-down="handleEscape"
|
@escape-key-down="handleEscape"
|
||||||
|
@interact-outside="handleInteractOutside"
|
||||||
@pointer-down-outside="handlePointerDownOutside"
|
@pointer-down-outside="handlePointerDownOutside"
|
||||||
@focus-outside="handleFocusOutside"
|
@focus-outside="handleFocusOutside"
|
||||||
@dismiss="rootCtx.onOpenChange(false)"
|
@dismiss="rootCtx.onOpenChange(false)"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, watch } from 'vue';
|
|||||||
import { useForwardExpose } from '@robonen/vue';
|
import { useForwardExpose } from '@robonen/vue';
|
||||||
import { Primitive } from '../primitive';
|
import { Primitive } from '../primitive';
|
||||||
import { useComboboxRootContext } from './context';
|
import { useComboboxRootContext } from './context';
|
||||||
import { OPEN_KEYS } from './utils';
|
import { INPUT_OPEN_KEYS } from './utils';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
as = 'input',
|
as = 'input',
|
||||||
@@ -132,7 +132,7 @@ function handleKeyDown(event: KeyboardEvent) {
|
|||||||
if (isDisabled.value) return;
|
if (isDisabled.value) return;
|
||||||
const { key } = event;
|
const { key } = event;
|
||||||
|
|
||||||
if (!rootCtx.open.value && OPEN_KEYS.includes(key)) {
|
if (!rootCtx.open.value && INPUT_OPEN_KEYS.includes(key)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
rootCtx.onOpenChange(true);
|
rootCtx.onOpenChange(true);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import { provideComboboxItemContext, useComboboxGroupContext, useComboboxRootCon
|
|||||||
|
|
||||||
const props = defineProps<ComboboxItemProps<T>>();
|
const props = defineProps<ComboboxItemProps<T>>();
|
||||||
|
|
||||||
const { forwardRef, currentElement } = useForwardExpose();
|
|
||||||
const rootCtx = useComboboxRootContext();
|
const rootCtx = useComboboxRootContext();
|
||||||
let groupCtx: { id: { value: string } } | null = null;
|
let groupCtx: { id: { value: string } } | null = null;
|
||||||
try {
|
try {
|
||||||
@@ -44,6 +43,12 @@ const isSelected = computed(() => rootCtx.isSelected(props.value));
|
|||||||
const isHighlighted = computed(() => rootCtx.selectedValueId.value === id.value);
|
const isHighlighted = computed(() => rootCtx.selectedValueId.value === id.value);
|
||||||
const isVisible = computed(() => rootCtx.filterState.value.items.has(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() {
|
function syncRegistration() {
|
||||||
rootCtx.onItemRegister(id.value, {
|
rootCtx.onItemRegister(id.value, {
|
||||||
value: props.value,
|
value: props.value,
|
||||||
@@ -98,8 +103,6 @@ provideComboboxItemContext({
|
|||||||
isSelected,
|
isSelected,
|
||||||
isDisabled,
|
isDisabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
defineExpose({ id, isVisible, isHighlighted });
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -79,31 +79,17 @@ const {
|
|||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
const direction = computed(() => dir ?? config.dir.value);
|
const direction = computed(() => dir ?? config.dir.value);
|
||||||
|
|
||||||
const localOpen = ref<boolean>(defaultOpen);
|
|
||||||
/** Controlled open state. Use `v-model:open`. */
|
/** Controlled open state. Use `v-model:open`. */
|
||||||
const open = defineModel<boolean>('open', {
|
const open = defineModel<boolean>('open', { default: false });
|
||||||
default: undefined,
|
if (defaultOpen && !open.value) open.value = true;
|
||||||
get: v => v ?? localOpen.value,
|
|
||||||
set: (v) => {
|
|
||||||
localOpen.value = v;
|
|
||||||
return v;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const initial = (modelValue ?? defaultValue) as T | T[] | undefined;
|
/** Controlled selected value. Use `v-model`. `undefined` from the parent means "no selection". */
|
||||||
const localValue = shallowRef<T | T[] | undefined>(
|
const value = defineModel<T | T[] | undefined>('modelValue');
|
||||||
multiple
|
if (modelValue === undefined && defaultValue !== undefined) {
|
||||||
? (Array.isArray(initial) ? initial.slice() : (initial === undefined ? [] : [initial]))
|
value.value = multiple
|
||||||
: (Array.isArray(initial) ? initial[0] : initial),
|
? (Array.isArray(defaultValue) ? defaultValue.slice() : [defaultValue]) as T[]
|
||||||
);
|
: (Array.isArray(defaultValue) ? defaultValue[0] : defaultValue) as T;
|
||||||
const value = defineModel<T | T[] | undefined>('modelValue', {
|
}
|
||||||
default: undefined,
|
|
||||||
get: v => v ?? localValue.value,
|
|
||||||
set: (v) => {
|
|
||||||
localValue.value = v;
|
|
||||||
return v;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const searchTerm = ref('');
|
const searchTerm = ref('');
|
||||||
const isUserInputted = ref(false);
|
const isUserInputted = ref(false);
|
||||||
@@ -228,8 +214,9 @@ function onValueChange(v: T) {
|
|||||||
function onOpenChange(next: boolean) {
|
function onOpenChange(next: boolean) {
|
||||||
open.value = next;
|
open.value = next;
|
||||||
if (next) {
|
if (next) {
|
||||||
isUserInputted.value = false;
|
// When the open was initiated by typing, ComboboxInput already set
|
||||||
searchTerm.value = '';
|
// searchTerm/isUserInputted — resetting here would wipe the first keystroke.
|
||||||
|
if (!isUserInputted.value) searchTerm.value = '';
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
inputElement.value?.focus();
|
inputElement.value?.focus();
|
||||||
highlightSelectedOrFirst();
|
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 type AcceptableValue = string | number | boolean | Record<string, unknown>;
|
||||||
|
|
||||||
export const OPEN_KEYS = ['Enter', ' ', 'ArrowDown', 'ArrowUp', 'PageUp', 'PageDown', 'Home', 'End'];
|
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 const SELECTION_KEYS = ['Enter', ' '];
|
||||||
|
|
||||||
export function clamp(value: number, min: number, max: number): number {
|
export function clamp(value: number, min: number, max: number): number {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { PrimitiveProps } from '../primitive';
|
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.
|
* native context menu and opening the menu anchored at the pointer position.
|
||||||
* Wrap whatever area should respond to a secondary click.
|
* Wrap whatever area should respond to a secondary click.
|
||||||
*/
|
*/
|
||||||
@@ -59,7 +59,8 @@ function handleContextMenu(event: MouseEvent) {
|
|||||||
|
|
||||||
function handlePointerDown(event: PointerEvent) {
|
function handlePointerDown(event: PointerEvent) {
|
||||||
if (disabled || event.button !== 0) return;
|
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();
|
clearLongPress();
|
||||||
longPressTimer = setTimeout(() => {
|
longPressTimer = setTimeout(() => {
|
||||||
point.value = { x: event.clientX, y: event.clientY };
|
point.value = { x: event.clientX, y: event.clientY };
|
||||||
@@ -67,6 +68,11 @@ function handlePointerDown(event: PointerEvent) {
|
|||||||
}, LONG_PRESS_DELAY);
|
}, 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() {
|
function handlePointerCancel() {
|
||||||
clearLongPress();
|
clearLongPress();
|
||||||
}
|
}
|
||||||
@@ -77,7 +83,7 @@ function handlePointerUp() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<MenuAnchor :reference="virtualEl">
|
<MenuAnchor as="template" :reference="virtualEl">
|
||||||
<Primitive
|
<Primitive
|
||||||
:ref="forwardRef"
|
:ref="forwardRef"
|
||||||
:as="as"
|
:as="as"
|
||||||
@@ -85,6 +91,7 @@ function handlePointerUp() {
|
|||||||
:data-disabled="disabled ? '' : undefined"
|
:data-disabled="disabled ? '' : undefined"
|
||||||
@contextmenu="handleContextMenu"
|
@contextmenu="handleContextMenu"
|
||||||
@pointerdown="handlePointerDown"
|
@pointerdown="handlePointerDown"
|
||||||
|
@pointermove="handlePointerMove"
|
||||||
@pointercancel="handlePointerCancel"
|
@pointercancel="handlePointerCancel"
|
||||||
@pointerup="handlePointerUp"
|
@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);
|
dismissableLayerStack.remove(layer);
|
||||||
});
|
});
|
||||||
|
|
||||||
function createInteractEvent(event: PointerEvent | MouseEvent | FocusEvent): { defaultPrevented: boolean } {
|
// `focusin` is non-cancelable (and synthetic pointer events may be too), so the
|
||||||
// Emit `interactOutside` first so consumers can cancel before the specific event fires.
|
// 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;
|
let prevented = false;
|
||||||
const original = event.preventDefault;
|
const original = event.preventDefault;
|
||||||
event.preventDefault = () => {
|
event.preventDefault = () => {
|
||||||
prevented = true;
|
prevented = true;
|
||||||
original.call(event);
|
original.call(event);
|
||||||
};
|
};
|
||||||
emit('interactOutside', event);
|
emitEvent(event);
|
||||||
event.preventDefault = original;
|
event.preventDefault = original;
|
||||||
return { defaultPrevented: prevented };
|
return prevented || event.defaultPrevented;
|
||||||
}
|
}
|
||||||
|
|
||||||
useEscapeKey((event) => {
|
useEscapeKey((event) => {
|
||||||
@@ -81,10 +86,10 @@ useEscapeKey((event) => {
|
|||||||
|
|
||||||
useClickOutside(nodeRef, (event) => {
|
useClickOutside(nodeRef, (event) => {
|
||||||
if (!dismissableLayerStack.isTopmost(layer)) return;
|
if (!dismissableLayerStack.isTopmost(layer)) return;
|
||||||
const interact = createInteractEvent(event);
|
// Emit `interactOutside` first so consumers can cancel before the specific event fires.
|
||||||
if (interact.defaultPrevented) return;
|
if (emitPreventable(event, e => emit('interactOutside', e))) return;
|
||||||
emit('pointerDownOutside', event);
|
if (emitPreventable(event, e => emit('pointerDownOutside', e))) return;
|
||||||
if (!event.defaultPrevented) emit('dismiss');
|
emit('dismiss');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Focus outside detection — fires when focus leaves this layer to an element
|
// 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 (el === target || el.contains(target)) return;
|
||||||
if (!dismissableLayerStack.isTopmost(layer)) return;
|
if (!dismissableLayerStack.isTopmost(layer)) return;
|
||||||
|
|
||||||
const interact = createInteractEvent(event);
|
if (emitPreventable(event, e => emit('interactOutside', e))) return;
|
||||||
if (interact.defaultPrevented) return;
|
if (emitPreventable(event, e => emit('focusOutside', e))) return;
|
||||||
|
emit('dismiss');
|
||||||
emit('focusOutside', event);
|
|
||||||
if (!event.defaultPrevented) emit('dismiss');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// When this layer disables outside pointer events, the body gets a data
|
// When this layer disables outside pointer events, the body gets a data
|
||||||
|
|||||||
@@ -72,6 +72,103 @@ describe('DismissableLayer', () => {
|
|||||||
w.unmount();
|
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 () => {
|
it('sets body pointer-events: none when disableOutsidePointerEvents is true', async () => {
|
||||||
const w = mount(DismissableLayer, {
|
const w = mount(DismissableLayer, {
|
||||||
attachTo: document.body,
|
attachTo: document.body,
|
||||||
|
|||||||
@@ -26,7 +26,15 @@ const ddCtx = useDropdownMenuRootContext();
|
|||||||
:aria-labelledby="ddCtx.triggerId.value"
|
:aria-labelledby="ddCtx.triggerId.value"
|
||||||
@close-auto-focus="emit('closeAutoFocus', $event)"
|
@close-auto-focus="emit('closeAutoFocus', $event)"
|
||||||
@escape-key-down="emit('escapeKeyDown', $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)"
|
@focus-outside="emit('focusOutside', $event)"
|
||||||
@interact-outside="emit('interactOutside', $event)"
|
@interact-outside="emit('interactOutside', $event)"
|
||||||
@dismiss="emit('dismiss')"
|
@dismiss="emit('dismiss')"
|
||||||
|
|||||||
@@ -36,10 +36,15 @@ onUnmounted(() => {
|
|||||||
function handlePointerDown(event: PointerEvent) {
|
function handlePointerDown(event: PointerEvent) {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
if (event.button !== 0 || event.ctrlKey) return;
|
if (event.button !== 0 || event.ctrlKey) return;
|
||||||
if (!menuCtx.open.value) {
|
// Toggle on the pre-interaction state: DropdownMenuContent prevents the
|
||||||
menuCtx.onOpenChange(true);
|
// dismissable layer from closing on trigger pointerdown, so this handler is
|
||||||
event.preventDefault();
|
// 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) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
@@ -52,7 +57,7 @@ function handleKeyDown(event: KeyboardEvent) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<MenuAnchor>
|
<MenuAnchor as="template">
|
||||||
<Primitive
|
<Primitive
|
||||||
:ref="forwardRef"
|
:ref="forwardRef"
|
||||||
:as="as"
|
: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"
|
:disabled="ctx.disabled.value || undefined"
|
||||||
:data-disabled="ctx.disabled.value ? '' : undefined"
|
:data-disabled="ctx.disabled.value ? '' : undefined"
|
||||||
:hidden="ctx.isEditing.value ? undefined : ''"
|
:hidden="ctx.isEditing.value ? undefined : ''"
|
||||||
|
:style="ctx.isEditing.value ? undefined : { display: 'none' }"
|
||||||
@click="ctx.cancel"
|
@click="ctx.cancel"
|
||||||
>
|
>
|
||||||
<slot>Cancel</slot>
|
<slot>Cancel</slot>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const { forwardRef } = useForwardExpose();
|
|||||||
:disabled="ctx.disabled.value || undefined"
|
:disabled="ctx.disabled.value || undefined"
|
||||||
:data-disabled="ctx.disabled.value ? '' : undefined"
|
:data-disabled="ctx.disabled.value ? '' : undefined"
|
||||||
:hidden="ctx.isEditing.value ? '' : undefined"
|
:hidden="ctx.isEditing.value ? '' : undefined"
|
||||||
|
:style="ctx.isEditing.value ? { display: 'none' } : undefined"
|
||||||
@click="ctx.edit"
|
@click="ctx.edit"
|
||||||
>
|
>
|
||||||
<slot>Edit</slot>
|
<slot>Edit</slot>
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ function onKeyDown(event: KeyboardEvent): void {
|
|||||||
all: 'unset',
|
all: 'unset',
|
||||||
gridArea: '1 / 1 / auto / auto',
|
gridArea: '1 / 1 / auto / auto',
|
||||||
visibility: !ctx.isEditing.value ? 'hidden' : undefined,
|
visibility: !ctx.isEditing.value ? 'hidden' : undefined,
|
||||||
} : undefined"
|
} : (!ctx.isEditing.value ? { display: 'none' } : undefined)"
|
||||||
aria-label="editable input"
|
aria-label="editable input"
|
||||||
@input="onInput"
|
@input="onInput"
|
||||||
@keydown="onKeyDown"
|
@keydown="onKeyDown"
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ function onDoubleClick(): void {
|
|||||||
visibility: ctx.isEditing.value ? 'hidden' : undefined,
|
visibility: ctx.isEditing.value ? 'hidden' : undefined,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
} : undefined"
|
} : (ctx.isEditing.value ? { display: 'none' } : undefined)"
|
||||||
@focusin="onFocus"
|
@focusin="onFocus"
|
||||||
@dblclick="onDoubleClick"
|
@dblclick="onDoubleClick"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export interface EditableRootEmits {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { Primitive } from '../primitive';
|
||||||
import { provideEditableContext } from './context';
|
import { provideEditableContext } from './context';
|
||||||
import { useForwardExpose } from '@robonen/vue';
|
import { useForwardExpose } from '@robonen/vue';
|
||||||
@@ -105,25 +105,39 @@ function edit(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cancel(): void {
|
function cancel(): void {
|
||||||
|
if (!isEditing.value) return;
|
||||||
isEditing.value = false;
|
isEditing.value = false;
|
||||||
inputValue.value = model.value;
|
inputValue.value = model.value;
|
||||||
emit('update:state', 'cancel');
|
emit('update:state', 'cancel');
|
||||||
}
|
}
|
||||||
|
|
||||||
function submit(): void {
|
function submit(): void {
|
||||||
|
if (!isEditing.value) return;
|
||||||
commitModel(inputValue.value);
|
commitModel(inputValue.value);
|
||||||
isEditing.value = false;
|
isEditing.value = false;
|
||||||
emit('update:state', 'submit');
|
emit('update:state', 'submit');
|
||||||
emit('submit', inputValue.value);
|
emit('submit', inputValue.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let blurTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
onBeforeUnmount(() => clearTimeout(blurTimer));
|
||||||
|
|
||||||
function onFocusOutCapture(event: FocusEvent): void {
|
function onFocusOutCapture(event: FocusEvent): void {
|
||||||
if (!isEditing.value) return;
|
if (!isEditing.value) return;
|
||||||
const root = currentElement.value;
|
const root = currentElement.value;
|
||||||
const next = event.relatedTarget as Node | null;
|
const next = event.relatedTarget as Node | null;
|
||||||
if (root && next && root.contains(next)) return;
|
if (root && next && root.contains(next)) return;
|
||||||
if (submitMode === 'blur' || submitMode === 'both') submit();
|
// Hiding the focused preview/trigger on entering edit mode fires a
|
||||||
else cancel();
|
// 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({
|
provideEditableContext({
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const { forwardRef } = useForwardExpose();
|
|||||||
:disabled="ctx.disabled.value || undefined"
|
:disabled="ctx.disabled.value || undefined"
|
||||||
:data-disabled="ctx.disabled.value ? '' : undefined"
|
:data-disabled="ctx.disabled.value ? '' : undefined"
|
||||||
:hidden="ctx.isEditing.value ? undefined : ''"
|
:hidden="ctx.isEditing.value ? undefined : ''"
|
||||||
|
:style="ctx.isEditing.value ? undefined : { display: 'none' }"
|
||||||
@click="ctx.submit"
|
@click="ctx.submit"
|
||||||
>
|
>
|
||||||
<slot>Submit</slot>
|
<slot>Submit</slot>
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ function press(el: Element, key: string) {
|
|||||||
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
|
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', () => {
|
describe('Editable', () => {
|
||||||
it('renders preview with default placeholder when empty', () => {
|
it('renders preview with default placeholder when empty', () => {
|
||||||
const w = createEditable({ placeholder: 'Click to edit' });
|
const w = createEditable({ placeholder: 'Click to edit' });
|
||||||
@@ -169,4 +174,101 @@ describe('Editable', () => {
|
|||||||
expect((w.find('input').element as HTMLInputElement).hidden).toBe(true);
|
expect((w.find('input').element as HTMLInputElement).hidden).toBe(true);
|
||||||
w.unmount();
|
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;
|
local.value = next;
|
||||||
emit('update:checked', next);
|
emit('update:checked', next);
|
||||||
|
|
||||||
const selectEvent = new CustomEvent(ITEM_SELECT, { bubbles: true, cancelable: true })
|
const target = event.currentTarget as HTMLElement;
|
||||||
;(event.currentTarget as HTMLElement).dispatchEvent(selectEvent);
|
const selectEvent = new CustomEvent(ITEM_SELECT, { bubbles: true, cancelable: true });
|
||||||
emit('select', event);
|
// 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();
|
if (!selectEvent.defaultPrevented) rootCtx.onClose();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import { PopperContent } from '../popper';
|
|||||||
import { RovingFocusGroup } from '../roving-focus';
|
import { RovingFocusGroup } from '../roving-focus';
|
||||||
import { useForwardExpose } from '@robonen/vue';
|
import { useForwardExpose } from '@robonen/vue';
|
||||||
import { provideMenuContentContext, useMenuContext, useMenuRootContext } from './context';
|
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 {
|
const {
|
||||||
loop = false,
|
loop = false,
|
||||||
@@ -100,9 +100,9 @@ provideMenuContentContext({
|
|||||||
|
|
||||||
function handleMountAutoFocus(event: Event) {
|
function handleMountAutoFocus(event: Event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (rootCtx.isUsingKeyboardRef.value) {
|
// Always focus the content so key events reach the menu even after a
|
||||||
contentElement.value?.focus({ preventScroll: true });
|
// pointer-open; entryFocus decides whether the first item gets focus.
|
||||||
}
|
contentElement.value?.focus({ preventScroll: true });
|
||||||
emit('openAutoFocus', event);
|
emit('openAutoFocus', event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +125,17 @@ function handleKeyDown(event: KeyboardEvent) {
|
|||||||
|
|
||||||
if (FIRST_LAST_KEYS.includes(event.key)) {
|
if (FIRST_LAST_KEYS.includes(event.key)) {
|
||||||
event.stopPropagation();
|
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();
|
const rootCtx = useMenuRootContext();
|
||||||
|
|
||||||
function handleSelect(event: Event) {
|
function handleSelect(event: Event) {
|
||||||
const selectEvent = new CustomEvent(ITEM_SELECT, { bubbles: true, cancelable: true })
|
const target = event.currentTarget as HTMLElement;
|
||||||
;(event.currentTarget as HTMLElement).dispatchEvent(selectEvent);
|
const selectEvent = new CustomEvent(ITEM_SELECT, { bubbles: true, cancelable: true });
|
||||||
emit('select', event);
|
// 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) {
|
if (!selectEvent.defaultPrevented) {
|
||||||
rootCtx.onClose();
|
rootCtx.onClose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,10 +75,17 @@ function handleKeyDown(event: KeyboardEvent) {
|
|||||||
el.click();
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RovingFocusItem :focusable="!disabled" :active="isHighlighted">
|
<RovingFocusItem as="template" :focusable="!disabled" :active="isHighlighted">
|
||||||
<Primitive
|
<Primitive
|
||||||
:ref="(el: unknown) => { itemRef = el as HTMLElement | null }"
|
:ref="(el: unknown) => { itemRef = el as HTMLElement | null }"
|
||||||
:as="as"
|
:as="as"
|
||||||
@@ -88,7 +95,6 @@ function handleKeyDown(event: KeyboardEvent) {
|
|||||||
:data-highlighted="isHighlighted ? '' : undefined"
|
:data-highlighted="isHighlighted ? '' : undefined"
|
||||||
:data-disabled="disabled ? '' : undefined"
|
:data-disabled="disabled ? '' : undefined"
|
||||||
:aria-disabled="disabled || undefined"
|
:aria-disabled="disabled || undefined"
|
||||||
:tabindex="isHighlighted ? 0 : -1"
|
|
||||||
@pointermove="handlePointerMove"
|
@pointermove="handlePointerMove"
|
||||||
@pointerleave="handlePointerLeave"
|
@pointerleave="handlePointerLeave"
|
||||||
@focus="handleFocus"
|
@focus="handleFocus"
|
||||||
|
|||||||
@@ -32,9 +32,11 @@ provideMenuItemIndicatorContext({ checkedState });
|
|||||||
|
|
||||||
function handleSelect(event: Event) {
|
function handleSelect(event: Event) {
|
||||||
radioCtx.onValueChange(value);
|
radioCtx.onValueChange(value);
|
||||||
const selectEvent = new CustomEvent(ITEM_SELECT, { bubbles: true, cancelable: true })
|
const target = event.currentTarget as HTMLElement;
|
||||||
;(event.currentTarget as HTMLElement).dispatchEvent(selectEvent);
|
const selectEvent = new CustomEvent(ITEM_SELECT, { bubbles: true, cancelable: true });
|
||||||
emit('select', event);
|
// 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();
|
if (!selectEvent.defaultPrevented) rootCtx.onClose();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ useHideOthers(contentRef);
|
|||||||
@pointer-down-outside="emit('pointerDownOutside', $event)"
|
@pointer-down-outside="emit('pointerDownOutside', $event)"
|
||||||
@focus-outside="emit('focusOutside', $event)"
|
@focus-outside="emit('focusOutside', $event)"
|
||||||
@interact-outside="emit('interactOutside', $event)"
|
@interact-outside="emit('interactOutside', $event)"
|
||||||
@dismiss="emit('dismiss')"
|
@dismiss="() => { menuCtx.onOpenChange(false); emit('dismiss') }"
|
||||||
@entry-focus="emit('entryFocus', $event)"
|
@entry-focus="emit('entryFocus', $event)"
|
||||||
@open-auto-focus="emit('openAutoFocus', $event)"
|
@open-auto-focus="emit('openAutoFocus', $event)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ watchEffect(() => menuCtx.onContentChange(contentRef.value));
|
|||||||
@pointer-down-outside="emit('pointerDownOutside', $event)"
|
@pointer-down-outside="emit('pointerDownOutside', $event)"
|
||||||
@focus-outside="emit('focusOutside', $event)"
|
@focus-outside="emit('focusOutside', $event)"
|
||||||
@interact-outside="emit('interactOutside', $event)"
|
@interact-outside="emit('interactOutside', $event)"
|
||||||
@dismiss="emit('dismiss')"
|
@dismiss="() => { menuCtx.onOpenChange(false); emit('dismiss') }"
|
||||||
@entry-focus="emit('entryFocus', $event)"
|
@entry-focus="emit('entryFocus', $event)"
|
||||||
@open-auto-focus="emit('openAutoFocus', $event)"
|
@open-auto-focus="emit('openAutoFocus', $event)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -65,10 +65,22 @@ function handleKeyDown(event: KeyboardEvent) {
|
|||||||
close();
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PopperAnchor>
|
<PopperAnchor as="template">
|
||||||
<MenuItemImpl
|
<MenuItemImpl
|
||||||
v-bind="props"
|
v-bind="props"
|
||||||
:id="subCtx.triggerId.value"
|
:id="subCtx.triggerId.value"
|
||||||
@@ -81,7 +93,7 @@ function handleKeyDown(event: KeyboardEvent) {
|
|||||||
@pointermove="handlePointerMove"
|
@pointermove="handlePointerMove"
|
||||||
@pointerleave="handlePointerLeave"
|
@pointerleave="handlePointerLeave"
|
||||||
@keydown="handleKeyDown"
|
@keydown="handleKeyDown"
|
||||||
@select.prevent
|
@select="handleSelect"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</MenuItemImpl>
|
</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 });
|
defineOptions({ inheritAttrs: false });
|
||||||
|
|
||||||
const { forceMount = false, ...rest } = defineProps<NavigationMenuContentProps>();
|
const { forceMount = false, ...rest } = defineProps<NavigationMenuContentProps>();
|
||||||
void rest;
|
|
||||||
|
|
||||||
const emit = defineEmits<NavigationMenuContentEmits>();
|
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() {
|
function handlePointerEnter() {
|
||||||
menuContext.onContentEnter(itemContext.value);
|
menuContext.onContentEnter(itemContext.value);
|
||||||
@@ -62,7 +73,7 @@ function handlePointerLeave() {
|
|||||||
<Teleport :to="menuContext.viewport.value ?? 'body'" :disabled="!menuContext.viewport.value">
|
<Teleport :to="menuContext.viewport.value ?? 'body'" :disabled="!menuContext.viewport.value">
|
||||||
<Presence :present="present" :force-mount="forceMount || !menuContext.unmountOnHide.value">
|
<Presence :present="present" :force-mount="forceMount || !menuContext.unmountOnHide.value">
|
||||||
<NavigationMenuContentImpl
|
<NavigationMenuContentImpl
|
||||||
v-bind="$attrs"
|
v-bind="{ ...rest, ...$attrs }"
|
||||||
@escape-key-down="emit('escapeKeyDown', $event)"
|
@escape-key-down="emit('escapeKeyDown', $event)"
|
||||||
@pointer-down-outside="emit('pointerDownOutside', $event)"
|
@pointer-down-outside="emit('pointerDownOutside', $event)"
|
||||||
@focus-outside="emit('focusOutside', $event)"
|
@focus-outside="emit('focusOutside', $event)"
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { COLLECTION_ITEM_ATTR, EVENT_ROOT_CONTENT_DISMISS, getOpenState } from '
|
|||||||
|
|
||||||
defineOptions({ inheritAttrs: false });
|
defineOptions({ inheritAttrs: false });
|
||||||
|
|
||||||
defineProps<NavigationMenuContentImplProps>();
|
const { as } = defineProps<NavigationMenuContentImplProps>();
|
||||||
|
|
||||||
const emit = defineEmits<NavigationMenuContentImplEmits>();
|
const emit = defineEmits<NavigationMenuContentImplEmits>();
|
||||||
|
|
||||||
@@ -133,6 +133,20 @@ function handlePointerDownOutside(ev: PointerEvent | MouseEvent) {
|
|||||||
if (isTrigger || isRootViewport || !menuContext.isRootMenu) ev.preventDefault();
|
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
|
// Listen for sibling/global EVENT_ROOT_CONTENT_DISMISS for root menus so links
|
||||||
// inside content can request the whole root close.
|
// inside content can request the whole root close.
|
||||||
watchEffect((onCleanup) => {
|
watchEffect((onCleanup) => {
|
||||||
@@ -155,6 +169,7 @@ watchEffect((onCleanup) => {
|
|||||||
<DismissableLayer
|
<DismissableLayer
|
||||||
:id="itemContext.contentId"
|
:id="itemContext.contentId"
|
||||||
:ref="forwardRef"
|
:ref="forwardRef"
|
||||||
|
:as="as"
|
||||||
:aria-labelledby="itemContext.triggerId"
|
:aria-labelledby="itemContext.triggerId"
|
||||||
:data-motion="motionAttribute"
|
:data-motion="motionAttribute"
|
||||||
:data-state="getOpenState(menuContext.modelValue.value, itemContext.value)"
|
:data-state="getOpenState(menuContext.modelValue.value, itemContext.value)"
|
||||||
@@ -167,7 +182,7 @@ watchEffect((onCleanup) => {
|
|||||||
@pointer-down-outside="handlePointerDownOutside"
|
@pointer-down-outside="handlePointerDownOutside"
|
||||||
@focus-outside="handleFocusOutside"
|
@focus-outside="handleFocusOutside"
|
||||||
@interact-outside="emit('interactOutside', $event)"
|
@interact-outside="emit('interactOutside', $event)"
|
||||||
@dismiss="emit('dismiss')"
|
@dismiss="handleDismiss"
|
||||||
@pointerenter="emit('pointerEnterContent')"
|
@pointerenter="emit('pointerEnterContent')"
|
||||||
@pointerleave="emit('pointerLeaveContent')"
|
@pointerleave="emit('pointerLeaveContent')"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ import { useCollectionProvider } from '../collection';
|
|||||||
import { useConfig } from '../config-provider';
|
import { useConfig } from '../config-provider';
|
||||||
import { Primitive } from '../primitive';
|
import { Primitive } from '../primitive';
|
||||||
import { provideNavigationMenuContext } from './context';
|
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 });
|
defineOptions({ inheritAttrs: false });
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ const indicatorTrack = shallowRef<HTMLElement | undefined>(undefined);
|
|||||||
const viewport = shallowRef<HTMLElement | undefined>(undefined);
|
const viewport = shallowRef<HTMLElement | undefined>(undefined);
|
||||||
const activeTrigger = 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
|
// 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).
|
// 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 { useCollectionProvider } from '../collection';
|
||||||
import { Primitive } from '../primitive';
|
import { Primitive } from '../primitive';
|
||||||
import { provideNavigationMenuContext, useNavigationMenuContext } from './context';
|
import { provideNavigationMenuContext, useNavigationMenuContext } from './context';
|
||||||
|
import { NAVIGATION_MENU_COLLECTION_KEY } from './utils';
|
||||||
|
|
||||||
defineOptions({ inheritAttrs: false });
|
defineOptions({ inheritAttrs: false });
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ const indicatorTrack = shallowRef<HTMLElement | undefined>(undefined);
|
|||||||
const viewport = shallowRef<HTMLElement | undefined>(undefined);
|
const viewport = shallowRef<HTMLElement | undefined>(undefined);
|
||||||
const activeTrigger = 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');
|
const baseId = useId(undefined, 'primitives-navigation-menu-sub');
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { Primitive } from '../primitive';
|
|||||||
import { RovingFocusItem } from '../roving-focus';
|
import { RovingFocusItem } from '../roving-focus';
|
||||||
import { VisuallyHidden } from '../visually-hidden';
|
import { VisuallyHidden } from '../visually-hidden';
|
||||||
import { useNavigationMenuContext, useNavigationMenuItemContext } from './context';
|
import { useNavigationMenuContext, useNavigationMenuItemContext } from './context';
|
||||||
import { getOpenState } from './utils';
|
import { NAVIGATION_MENU_COLLECTION_KEY, getOpenState } from './utils';
|
||||||
|
|
||||||
defineOptions({ inheritAttrs: false });
|
defineOptions({ inheritAttrs: false });
|
||||||
|
|
||||||
@@ -31,20 +31,12 @@ const { disabled = false } = defineProps<NavigationMenuTriggerProps>();
|
|||||||
const menuContext = useNavigationMenuContext();
|
const menuContext = useNavigationMenuContext();
|
||||||
const itemContext = useNavigationMenuItemContext();
|
const itemContext = useNavigationMenuItemContext();
|
||||||
|
|
||||||
const { CollectionItem } = useCollectionInjector<{ value: string }>();
|
const { CollectionItem } = useCollectionInjector<{ value: string }>(NAVIGATION_MENU_COLLECTION_KEY);
|
||||||
const { forwardRef, currentElement: triggerElement } = useForwardExpose();
|
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);
|
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);
|
const wasClickClose = ref(false);
|
||||||
|
|
||||||
@@ -69,7 +61,7 @@ function handlePointerMove(ev: PointerEvent) {
|
|||||||
if (ev.pointerType !== 'mouse') return;
|
if (ev.pointerType !== 'mouse') return;
|
||||||
if (disabled || wasClickClose.value || itemContext.wasEscapeCloseRef.value || hasPointerMoveOpened.value) return;
|
if (disabled || wasClickClose.value || itemContext.wasEscapeCloseRef.value || hasPointerMoveOpened.value) return;
|
||||||
menuContext.onTriggerEnter(itemContext.value);
|
menuContext.onTriggerEnter(itemContext.value);
|
||||||
markPointerMoveOpened();
|
hasPointerMoveOpened.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePointerLeave(ev: PointerEvent) {
|
function handlePointerLeave(ev: PointerEvent) {
|
||||||
@@ -83,11 +75,11 @@ function handlePointerLeave(ev: PointerEvent) {
|
|||||||
function handleClick(event: MouseEvent | PointerEvent) {
|
function handleClick(event: MouseEvent | PointerEvent) {
|
||||||
const isMouse = !('pointerType' in event) || (event as PointerEvent).pointerType === 'mouse';
|
const isMouse = !('pointerType' in event) || (event as PointerEvent).pointerType === 'mouse';
|
||||||
if (isMouse && menuContext.disableClickTrigger.value) return;
|
if (isMouse && menuContext.disableClickTrigger.value) return;
|
||||||
// If pointermove already opened the menu, ignore the resulting click.
|
// Capture before onItemSelect mutates modelValue — `open` is a computed over
|
||||||
if (hasPointerMoveOpened.value) return;
|
// it, so reading it afterwards would be inverted.
|
||||||
if (open.value) menuContext.onItemSelect('');
|
const wasOpen = open.value;
|
||||||
else menuContext.onItemSelect(itemContext.value);
|
menuContext.onItemSelect(wasOpen ? '' : itemContext.value);
|
||||||
wasClickClose.value = open.value;
|
wasClickClose.value = wasOpen;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(ev: KeyboardEvent) {
|
function handleKeydown(ev: KeyboardEvent) {
|
||||||
@@ -120,8 +112,11 @@ function handleVisuallyHiddenFocus(ev: FocusEvent) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CollectionItem :value="{ value: itemContext.value }">
|
<!-- CollectionItem must wrap the button itself (not RovingFocusItem, which
|
||||||
<RovingFocusItem :focusable="!disabled">
|
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
|
<Primitive
|
||||||
:id="itemContext.triggerId"
|
:id="itemContext.triggerId"
|
||||||
:ref="forwardRef"
|
:ref="forwardRef"
|
||||||
@@ -143,8 +138,8 @@ function handleVisuallyHiddenFocus(ev: FocusEvent) {
|
|||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</Primitive>
|
</Primitive>
|
||||||
</RovingFocusItem>
|
</CollectionItem>
|
||||||
</CollectionItem>
|
</RovingFocusItem>
|
||||||
|
|
||||||
<template v-if="open">
|
<template v-if="open">
|
||||||
<VisuallyHidden
|
<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. */
|
/** Selector identifying the link/item nodes for arrow navigation inside content. */
|
||||||
export const COLLECTION_ITEM_ATTR = 'data-primitives-collection-item';
|
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. */
|
/** Custom event dispatched by a `NavigationMenuLink` selection. */
|
||||||
export const LINK_SELECT_EVENT = 'navigationMenu.linkSelect';
|
export const LINK_SELECT_EVENT = 'navigationMenu.linkSelect';
|
||||||
/** Custom event bubbled to the root content when an item dismisses the menu. */
|
/** 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 = '';
|
if (ok) target.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onEnter(event: KeyboardEvent): Promise<void> {
|
function onEnter(event: KeyboardEvent): void {
|
||||||
if (isComposing.value) return;
|
if (isComposing.value) return;
|
||||||
await nextTick();
|
|
||||||
if (event.defaultPrevented) return;
|
if (event.defaultPrevented) return;
|
||||||
const target = event.target as HTMLInputElement;
|
const target = event.target as HTMLInputElement;
|
||||||
if (!target.value) return;
|
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();
|
event.preventDefault();
|
||||||
commitCurrent(target);
|
commitCurrent(target);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ provideTagsInputItemContext({
|
|||||||
<Primitive
|
<Primitive
|
||||||
:ref="forwardRef"
|
:ref="forwardRef"
|
||||||
:as="as"
|
:as="as"
|
||||||
:aria-labelledby="textId.value || undefined"
|
:aria-labelledby="textId || undefined"
|
||||||
:aria-current="isSelected ? 'true' : undefined"
|
:aria-current="isSelected ? 'true' : undefined"
|
||||||
:data-state="isSelected ? 'active' : 'inactive'"
|
:data-state="isSelected ? 'active' : 'inactive'"
|
||||||
:data-disabled="isDisabled ? '' : undefined"
|
:data-disabled="isDisabled ? '' : undefined"
|
||||||
|
|||||||
@@ -168,4 +168,73 @@ describe('TagsInput', () => {
|
|||||||
expect((input.element as HTMLInputElement).disabled).toBe(true);
|
expect((input.element as HTMLInputElement).disabled).toBe(true);
|
||||||
w.unmount();
|
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,52 +29,55 @@ function onInvalid() {
|
|||||||
Type an address and press Enter, comma, or paste a list.
|
Type an address and press Enter, comma, or paste a list.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Root wraps the footer too: TagsInputClear must be a descendant to inject the context. -->
|
||||||
<TagsInputRoot
|
<TagsInputRoot
|
||||||
v-model="recipients"
|
v-model="recipients"
|
||||||
add-on-paste
|
add-on-paste
|
||||||
add-on-blur
|
add-on-blur
|
||||||
:max="5"
|
:max="5"
|
||||||
delimiter=","
|
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"
|
@invalid="onInvalid"
|
||||||
>
|
>
|
||||||
<TagsInputItem
|
<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">
|
||||||
v-for="tag in recipients"
|
<TagsInputItem
|
||||||
:key="tag"
|
v-for="tag in recipients"
|
||||||
:value="tag"
|
:key="tag"
|
||||||
class="flex items-center gap-1 rounded-md bg-(--bg-subtle) py-0.5 pl-2 pr-1 text-sm text-(--fg) data-[state=active]:bg-(--accent) data-[state=active]:text-(--accent-fg)"
|
:value="tag"
|
||||||
>
|
class="flex items-center gap-1 rounded-md bg-(--bg-subtle) py-0.5 pl-2 pr-1 text-sm text-(--fg) data-[state=active]:bg-(--accent) data-[state=active]:text-(--accent-fg)"
|
||||||
<TagsInputItemText class="leading-none" />
|
|
||||||
<TagsInputItemDelete
|
|
||||||
class="grid h-4 w-4 place-items-center rounded text-(--fg-subtle) transition-colors hover:bg-(--bg-inset) hover:text-(--fg)"
|
|
||||||
aria-label="Remove"
|
|
||||||
>
|
>
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
|
<TagsInputItemText class="leading-none" />
|
||||||
<path d="M1 1l8 8M9 1l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
<TagsInputItemDelete
|
||||||
</svg>
|
class="grid h-4 w-4 place-items-center rounded text-(--fg-subtle) transition-colors hover:bg-(--bg-inset) hover:text-(--fg)"
|
||||||
</TagsInputItemDelete>
|
aria-label="Remove"
|
||||||
</TagsInputItem>
|
>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
|
||||||
|
<path d="M1 1l8 8M9 1l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</TagsInputItemDelete>
|
||||||
|
</TagsInputItem>
|
||||||
|
|
||||||
<TagsInputInput
|
<TagsInputInput
|
||||||
placeholder="name@company.com"
|
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)"
|
class="min-w-32 flex-1 bg-transparent px-1 py-0.5 text-sm text-(--fg) outline-none placeholder:text-(--fg-subtle)"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex items-center justify-between text-sm">
|
||||||
|
<p
|
||||||
|
class="font-medium transition-colors"
|
||||||
|
:class="wasInvalid ? 'text-red-600 dark:text-red-400' : 'text-(--fg-subtle)'"
|
||||||
|
>
|
||||||
|
<span v-if="wasInvalid">Duplicate or limit reached</span>
|
||||||
|
<span v-else>{{ recipients.length }} / 5 recipients</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<TagsInputClear
|
||||||
|
class="rounded-md px-2 py-1 text-(--accent) transition-colors hover:bg-(--bg-inset) disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</TagsInputClear>
|
||||||
|
</div>
|
||||||
</TagsInputRoot>
|
</TagsInputRoot>
|
||||||
|
|
||||||
<div class="mt-3 flex items-center justify-between text-sm">
|
|
||||||
<p
|
|
||||||
class="font-medium transition-colors"
|
|
||||||
:class="wasInvalid ? 'text-red-600 dark:text-red-400' : 'text-(--fg-subtle)'"
|
|
||||||
>
|
|
||||||
<span v-if="wasInvalid">Duplicate or limit reached</span>
|
|
||||||
<span v-else>{{ recipients.length }} / 5 recipients</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<TagsInputClear
|
|
||||||
class="rounded-md px-2 py-1 text-(--accent) transition-colors hover:bg-(--bg-inset) disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Clear all
|
|
||||||
</TagsInputClear>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -33,24 +33,33 @@ const { forwardRef } = useForwardExpose();
|
|||||||
|
|
||||||
// DOM-order items via Collection primitive. Survives `v-for` reorders and
|
// DOM-order items via Collection primitive. Survives `v-for` reorders and
|
||||||
// teleport/portal children, unlike a mount-order array.
|
// 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 { getItems, CollectionSlot } = useCollectionProvider();
|
||||||
const items = computed(() => getItems(true).map(i => i.ref));
|
const items = computed(() => getItems().map(i => i.ref));
|
||||||
|
|
||||||
const activeIndex = ref(0);
|
const activeIndex = ref(0);
|
||||||
|
|
||||||
|
// Read fresh rather than through `items`: `getItems` filters on live
|
||||||
|
// `data-disabled`, which the computed cannot track across runtime toggles.
|
||||||
|
function enabledItems(): HTMLElement[] {
|
||||||
|
return getItems().map(i => i.ref);
|
||||||
|
}
|
||||||
|
|
||||||
function focusIndex(i: number): void {
|
function focusIndex(i: number): void {
|
||||||
const el = items.value[i];
|
const el = enabledItems()[i];
|
||||||
if (el) {
|
if (!el) return;
|
||||||
activeIndex.value = i;
|
el.focus();
|
||||||
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 {
|
function onItemKeyDown(event: KeyboardEvent, el: HTMLElement): void {
|
||||||
const action = rovingKeyToAction(event, { orientation, dir, loop });
|
const action = rovingKeyToAction(event, { orientation, dir, loop });
|
||||||
if (!action) return;
|
if (!action) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const list = items.value;
|
const list = enabledItems();
|
||||||
const idx = list.indexOf(el);
|
const idx = list.indexOf(el);
|
||||||
if (action.absolute === 'home') return focusIndex(0);
|
if (action.absolute === 'home') return focusIndex(0);
|
||||||
if (action.absolute === 'end') return focusIndex(list.length - 1);
|
if (action.absolute === 'end') return focusIndex(list.length - 1);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface ToolbarSeparatorProps extends PrimitiveProps {
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Primitive } from '../primitive';
|
import { Primitive } from '../primitive';
|
||||||
|
import { computed } from 'vue';
|
||||||
import { useForwardExpose } from '@robonen/vue';
|
import { useForwardExpose } from '@robonen/vue';
|
||||||
import { useToolbarContext } from './context';
|
import { useToolbarContext } from './context';
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ const { as = 'span', orientation } = defineProps<ToolbarSeparatorProps>();
|
|||||||
const { forwardRef } = useForwardExpose();
|
const { forwardRef } = useForwardExpose();
|
||||||
const ctx = useToolbarContext();
|
const ctx = useToolbarContext();
|
||||||
// If no orientation passed, inherit from toolbar — but invert (horizontal toolbar needs vertical separator).
|
// 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
import { describe, expect, it } from 'vitest';
|
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';
|
import { ToolbarButton, ToolbarRoot, ToolbarSeparator } from '../index';
|
||||||
|
|
||||||
function mountToolbar(opts: { orientation?: 'horizontal' | 'vertical'; dir?: 'ltr' | 'rtl'; loop?: boolean } = {}) {
|
function mountToolbar(opts: { orientation?: 'horizontal' | 'vertical'; dir?: 'ltr' | 'rtl'; loop?: boolean } = {}) {
|
||||||
@@ -99,4 +99,118 @@ describe('Toolbar', () => {
|
|||||||
expect(document.activeElement).toBe(btns[0]);
|
expect(document.activeElement).toBe(btns[0]);
|
||||||
w.unmount();
|
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