feat(primitives): media-editor components, category reorg, perf + type cleanup

Reorganize components into category folders (forms/canvas/overlays/etc.); add the
media-editor headless family (timeline, curve-editor, waveform, crop, color
picker, etc.); apply perf fixes (O(1) collection lookups, plain-object drag
state, gesture-leak teardown, shallowRef color state, rect caching) and replace
source `any` with proper types.
This commit is contained in:
2026-06-15 16:54:29 +07:00
parent 661a55719e
commit eefd7abf83
1029 changed files with 65815 additions and 9449 deletions
@@ -0,0 +1,48 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A clear/reset affordance for the palette: on click it empties the search term,
* refocuses the input, and — when `resetValue` is set — also clears the committed
* `modelValue`. Renders a `<button>` by default. Pair it with `CommandInput`.
*/
export interface CommandCancelProps extends PrimitiveProps {
/** Also clear the committed `modelValue`, not just the search term. */
resetValue?: boolean;
/** Accessible label for the control. @default 'Clear search' */
label?: string;
}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { useCommandContext } from './context';
const {
as = 'button',
resetValue = false,
label = 'Clear search',
} = defineProps<CommandCancelProps>();
const { forwardRef } = useForwardExpose();
const ctx = useCommandContext();
function handleClick() {
ctx.clear(resetValue);
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
:aria-label="label"
tabindex="-1"
data-primitives-command-cancel
@click="handleClick"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,44 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Empty-state message shown when the search yields no matching items. By default
* it appears only while a search term is active; set `always` to also show it for
* an empty list with no query.
*/
export interface CommandEmptyProps extends PrimitiveProps {
/** Render even while there is no active search term. */
always?: boolean;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { useCommandContext } from './context';
const { as = 'div', always = false } = defineProps<CommandEmptyProps>();
const { forwardRef } = useForwardExpose();
const ctx = useCommandContext();
const shouldRender = computed(() => {
if (ctx.filteredItems.value.size !== 0) return false;
if (always) return true;
return ctx.searchTerm.value.length > 0;
});
</script>
<template>
<Primitive
v-if="shouldRender"
:ref="forwardRef"
:as="as"
role="presentation"
data-primitives-command-empty
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,78 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Labelled section that visually clusters related items under an optional
* heading. Hides itself automatically when every item it contains is filtered
* out (unless `forceMount`), so empty categories disappear during search.
*/
export interface CommandGroupProps extends PrimitiveProps {
/** Group heading text (rendered when the default slot doesn't override it). */
heading?: string;
/** Stable identifier for the group. Auto-generated when omitted. */
value?: string;
/** Render the group even when all of its items are filtered out. */
forceMount?: boolean;
}
</script>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, toRef } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useId } from '../../utilities/config-provider';
import { Primitive } from '../../internal/primitive';
import { provideCommandGroupContext, useCommandContext } from './context';
const {
as = 'div',
heading,
value,
forceMount = false,
} = defineProps<CommandGroupProps>();
const { forwardRef } = useForwardExpose();
const ctx = useCommandContext();
const id = useId(() => value, 'command-group');
const headingId = useId(undefined, 'command-group-heading');
const hasVisibleItem = computed(() => {
const set = ctx.allGroups.value.get(id.value);
if (!set || set.size === 0) return false;
for (const v of set) {
const info = ctx.allItems.value.get(v);
if (!info || info.disabled) continue;
if (ctx.filteredItems.value.has(v)) return true;
}
return false;
});
const isVisible = computed(() => forceMount || hasVisibleItem.value);
onMounted(() => ctx.registerGroup(id.value));
onBeforeUnmount(() => ctx.unregisterGroup(id.value));
provideCommandGroupContext({
id,
forceMount: toRef(() => forceMount),
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="presentation"
:data-primitives-state="isVisible ? 'visible' : 'hidden'"
:hidden="!isVisible || undefined"
data-primitives-command-group
>
<div v-if="heading" :id="headingId" data-primitives-command-group-heading>
{{ heading }}
</div>
<div role="group" :aria-labelledby="heading ? headingId : undefined">
<slot />
</div>
</Primitive>
</template>
@@ -0,0 +1,186 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Search box that drives the command palette: typing updates the root search
* term (and re-filters items), while Arrow/Home/End/Enter move the highlight and
* commit the selected item. Renders a combobox `<input>` wired up for assistive tech.
*/
export interface CommandInputProps extends PrimitiveProps {
/** Controlled value; falls back to root `searchTerm`. */
modelValue?: string;
/** Disable the input. */
disabled?: boolean;
/** Focus the input on mount. */
autoFocus?: boolean;
}
export interface CommandInputEmits {
'update:modelValue': [value: string];
'update:searchTerm': [value: string];
}
</script>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { useCommandContext } from './context';
const {
as = 'input',
modelValue,
disabled = false,
autoFocus = false,
} = defineProps<CommandInputProps>();
const emit = defineEmits<CommandInputEmits>();
const { forwardRef, currentElement } = useForwardExpose();
const ctx = useCommandContext();
const activeDescendant = computed(() => {
const v = ctx.selectedValue.value;
return v === undefined ? undefined : ctx.getItemId(v);
});
onMounted(() => {
const el = currentElement.value as HTMLInputElement | undefined;
if (!el) return;
ctx.setInputElement(el);
if (modelValue !== undefined && modelValue !== ctx.searchTerm.value) {
ctx.setSearchTerm(modelValue);
}
if (el.value !== ctx.searchTerm.value) el.value = ctx.searchTerm.value;
if (autoFocus) setTimeout(() => el.focus(), 0);
});
onBeforeUnmount(() => {
if (ctx.inputElement.value === currentElement.value) ctx.setInputElement(undefined);
});
watch(
() => modelValue,
(v) => {
if (v === undefined) return;
if (v !== ctx.searchTerm.value) ctx.setSearchTerm(v);
},
);
watch(
() => ctx.searchTerm.value,
(v) => {
const el = currentElement.value as HTMLInputElement | undefined;
if (el && el.value !== v) el.value = v;
},
);
function moveBy(delta: number) {
const items = ctx.getSelectableItems();
if (items.length === 0) return;
const cur = ctx.selectedValue.value;
const idx = cur === undefined ? -1 : items.indexOf(cur);
let next: number;
if (idx === -1) {
next = delta > 0 ? 0 : items.length - 1;
}
else {
next = idx + delta;
if (ctx.loop.value) {
next = (next + items.length) % items.length;
}
else {
if (next < 0) next = 0;
if (next > items.length - 1) next = items.length - 1;
}
}
ctx.setSelectedValue(items[next]);
scrollSelectedIntoView();
}
function moveTo(position: 'first' | 'last') {
const items = ctx.getSelectableItems();
if (items.length === 0) return;
ctx.setSelectedValue(position === 'first' ? items[0] : items[items.length - 1]);
scrollSelectedIntoView();
}
function scrollSelectedIntoView() {
const v = ctx.selectedValue.value;
const root = ctx.listElement.value;
if (v === undefined || !root) return;
const id = ctx.getItemId(v);
const el = root.querySelector<HTMLElement>(`#${CSS.escape(id)}`);
el?.scrollIntoView({ block: 'nearest' });
}
function handleInput(event: Event) {
const next = (event.target as HTMLInputElement).value;
ctx.setSearchTerm(next);
emit('update:modelValue', next);
emit('update:searchTerm', next);
}
function handleKeyDown(event: KeyboardEvent) {
if (disabled) return;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
moveBy(1);
break;
case 'ArrowUp':
event.preventDefault();
moveBy(-1);
break;
case 'Home':
event.preventDefault();
moveTo('first');
break;
case 'End':
event.preventDefault();
moveTo('last');
break;
case 'PageUp':
event.preventDefault();
moveTo('first');
break;
case 'PageDown':
event.preventDefault();
moveTo('last');
break;
case 'Enter':
// Don't commit while an IME candidate is being composed (e.g. CJK input);
// Enter confirms the candidate first.
if (event.isComposing || event.keyCode === 229) break;
if (ctx.selectedValue.value !== undefined) {
event.preventDefault();
ctx.commitSelected();
}
break;
}
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
type="text"
role="combobox"
autocomplete="off"
spellcheck="false"
aria-autocomplete="list"
:aria-expanded="true"
:aria-controls="ctx.listId.value"
:aria-activedescendant="activeDescendant"
:aria-disabled="disabled || undefined"
:disabled="disabled || undefined"
:data-disabled="disabled ? '' : undefined"
data-primitives-command-input
@input="handleInput"
@keydown="handleKeyDown"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,143 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A selectable option in the list. Registers itself with the root (so it can be
* filtered, highlighted, and selected), reflects highlight/selection/disabled
* state via data attributes, and emits `select` when chosen by click or Enter.
*/
export interface CommandItemProps extends PrimitiveProps {
/** Item value — the identity used by selection and `data-value`. */
value: string;
/**
* Plain-text representation the filter should match against. Use it when the
* `value` is an opaque identity key and the searchable label differs (e.g.
* non-text children). Falls back to `value` when omitted.
*/
textValue?: string;
/** Extra terms the default filter should match against. */
keywords?: string[];
/** Disable this item — it is skipped by keyboard nav and filtering. */
disabled?: boolean;
/** Render even when filtered out. */
forceMount?: boolean;
}
export interface CommandItemEmits {
select: [value: string];
}
</script>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { provideCommandItemContext, useCommandContext, useCommandGroupContext } from './context';
const {
as = 'div',
value,
textValue,
keywords,
disabled = false,
forceMount = false,
} = defineProps<CommandItemProps>();
const emit = defineEmits<CommandItemEmits>();
const { forwardRef } = useForwardExpose();
const ctx = useCommandContext();
let groupCtx: ReturnType<typeof useCommandGroupContext> | null = null;
try {
groupCtx = useCommandGroupContext();
}
catch {
groupCtx = null;
}
const itemId = computed(() => ctx.getItemId(value));
const isVisible = computed(() => forceMount || ctx.filteredItems.value.has(value));
const isHighlighted = computed(() => ctx.selectedValue.value === value);
const isSelected = computed(() => ctx.modelValue.value === value);
provideCommandItemContext({ isSelected });
function syncRegistration() {
ctx.registerItem({
value,
textValue: textValue ?? value,
keywords: keywords ?? [],
disabled,
onSelect: () => emit('select', value),
});
}
onMounted(() => {
syncRegistration();
if (groupCtx) ctx.registerGroupItem(groupCtx.id.value, value);
});
watch(
() => [value, textValue, disabled, (keywords ?? []).join('\u0001')] as const,
(_next, prev) => {
const [prevValue] = prev ?? [];
if (prevValue !== undefined && prevValue !== value) {
ctx.unregisterItem(prevValue);
if (groupCtx) ctx.unregisterGroupItem(groupCtx.id.value, prevValue);
syncRegistration();
if (groupCtx) ctx.registerGroupItem(groupCtx.id.value, value);
}
else {
syncRegistration();
}
},
);
onBeforeUnmount(() => {
ctx.unregisterItem(value);
if (groupCtx) ctx.unregisterGroupItem(groupCtx.id.value, value);
});
function handlePointerMove(event: PointerEvent) {
if (disabled) return;
// Only react to genuine mouse / pen movement; keyboard nav already manages highlight.
if (event.pointerType === 'touch') return;
if (ctx.selectedValue.value !== value) ctx.setSelectedValue(value);
}
function handleClick(event: MouseEvent) {
if (disabled) {
event.preventDefault();
return;
}
event.preventDefault();
ctx.setSelectedValue(value);
ctx.commitSelected();
}
</script>
<template>
<Primitive
v-show="isVisible"
:ref="forwardRef"
:id="itemId"
:as="as"
role="option"
:aria-selected="isSelected || undefined"
:aria-disabled="disabled || undefined"
:data-state="isHighlighted ? 'selected' : ''"
:data-highlighted="isHighlighted ? '' : undefined"
:data-selected="isSelected ? '' : undefined"
:data-disabled="disabled ? '' : undefined"
:data-primitives-state="isVisible ? 'visible' : 'hidden'"
:tabindex="-1"
data-primitives-command-item
:data-value="value"
@click="handleClick"
@pointermove="handlePointerMove"
>
<slot :highlighted="isHighlighted" :selected="isSelected" :disabled="disabled" />
</Primitive>
</template>
@@ -0,0 +1,37 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Renders its content only while the parent `CommandItem` matches the committed
* `modelValue` — use it to show a check (or any marker) on the selected row of a
* palette that keeps a persistent selection. Marked `aria-hidden` because the
* selection state is already conveyed by the item's `aria-selected`.
*/
export interface CommandItemIndicatorProps extends PrimitiveProps {
/** Render even when the parent item is not the committed selection. */
forceMount?: boolean;
}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { useCommandItemContext } from './context';
const { as = 'span', forceMount = false } = defineProps<CommandItemIndicatorProps>();
const { forwardRef } = useForwardExpose();
const itemCtx = useCommandItemContext();
</script>
<template>
<Primitive
v-if="forceMount || itemCtx.isSelected.value"
:ref="forwardRef"
:as="as"
aria-hidden="true"
data-primitives-command-item-indicator
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,97 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Scrollable listbox container that holds the items, groups, and empty/loading
* states. Tracks its content height in the `--primitives-command-list-height`
* CSS variable so you can animate the palette as results filter in and out.
*/
export interface CommandListProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { useCommandContext } from './context';
const { as = 'div' } = defineProps<CommandListProps>();
const { forwardRef, currentElement } = useForwardExpose();
const ctx = useCommandContext();
let resizeObserver: ResizeObserver | undefined;
let observedChild: Element | undefined;
function setHeight(height: number) {
const list = currentElement.value as HTMLElement | undefined;
if (!list) return;
list.style.setProperty('--primitives-command-list-height', `${height}px`);
}
function observeFirstChild() {
const list = currentElement.value as HTMLElement | undefined;
if (!list) return;
const child = list.firstElementChild ?? undefined;
if (child === observedChild) return;
if (resizeObserver && observedChild) resizeObserver.unobserve(observedChild);
observedChild = child;
if (!child) {
setHeight(0);
return;
}
resizeObserver?.observe(child);
setHeight((child as HTMLElement).offsetHeight);
}
onMounted(() => {
const list = currentElement.value as HTMLElement | undefined;
if (!list) return;
ctx.setListElement(list);
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const target = entry.target as HTMLElement;
setHeight(target.offsetHeight);
}
});
}
observeFirstChild();
// React to subtree changes (items added/removed/reordered).
const mo = new MutationObserver(observeFirstChild);
mo.observe(list, { childList: true });
onBeforeUnmount(() => {
mo.disconnect();
resizeObserver?.disconnect();
resizeObserver = undefined;
observedChild = undefined;
ctx.setListElement(undefined);
});
});
// Re-evaluate the observed child whenever the filter result changes (items hide/show).
watch(
() => ctx.filteredItems.value,
() => observeFirstChild(),
);
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:id="ctx.listId.value"
role="listbox"
:aria-labelledby="ctx.labelId.value"
data-primitives-command-list
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,45 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Progress indicator for asynchronous results — render it inside the list while
* fetching items so screen readers announce the loading state. Exposes an
* optional `progress` value as an accessible progressbar.
*/
export interface CommandLoadingProps extends PrimitiveProps {
/** Accessible label describing the loading state. */
label?: string;
/** Optional 0..100 progress value — published via `aria-valuenow`. */
progress?: number;
}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
const {
as = 'div',
label = 'Loading',
progress,
} = defineProps<CommandLoadingProps>();
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="progressbar"
:aria-valuetext="label"
:aria-valuenow="progress"
:aria-valuemin="progress === undefined ? undefined : 0"
:aria-valuemax="progress === undefined ? undefined : 100"
aria-live="polite"
data-primitives-command-loading
>
<slot :progress="progress" />
</Primitive>
</template>
@@ -0,0 +1,326 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
import type { Direction } from '../../utilities/config-provider';
import type { CommandFilterFunction } from './utils';
/**
* Root of a command palette / fuzzy-finder menu (cmdk-style): owns the search
* term, the registry of items and groups, scoring/filtering, and keyboard-driven
* highlight + selection. Compose it with `CommandInput`, `CommandList`,
* `CommandGroup`, `CommandItem`, `CommandEmpty`, `CommandLoading`, and
* `CommandSeparator`. Reach for it whenever you need a searchable, keyboard-first
* list of actions or options — a Spotlight-style launcher, an autocomplete menu,
* or a quick-switcher.
*/
export interface CommandRootProps extends PrimitiveProps {
/** Controlled selected value. Use `v-model`. */
modelValue?: string;
/** Uncontrolled initial selected value. */
defaultValue?: string;
/** Controlled search term. Use `v-model:searchTerm`. */
searchTerm?: string;
/** Uncontrolled initial search term. */
defaultSearchTerm?: string;
/** Custom scoring filter. Returns 0..1 (0 = hide). */
filter?: CommandFilterFunction;
/** Run the filter automatically. Set false to perform filtering yourself. @default true */
shouldFilter?: boolean;
/** Loop keyboard navigation at the ends of the list. @default false */
loop?: boolean;
/** Accessible label announced to assistive tech. */
label?: string;
/**
* Writing direction. When omitted, inherits from a `ConfigProvider`,
* falling back to `'ltr'`.
*/
dir?: Direction;
}
export interface CommandRootEmits {
'update:modelValue': [value: string | undefined];
'update:searchTerm': [value: string];
}
</script>
<script setup lang="ts">
import { computed, ref, shallowRef, toRef, triggerRef, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useDirection, useId } from '../../utilities/config-provider';
import { Primitive } from '../../internal/primitive';
import { VisuallyHidden } from '../../utilities/visually-hidden';
import { provideCommandContext } from './context';
import type { CommandItemInfo } from './context';
import { COMMAND_ITEM_ATTR, COMMAND_VALUE_ATTR, defaultFilter } from './utils';
defineOptions({ inheritAttrs: false });
const {
as = 'div',
defaultValue,
defaultSearchTerm = '',
filter,
shouldFilter = true,
loop = false,
label,
dir,
} = defineProps<CommandRootProps>();
const direction = useDirection(() => dir);
const localValue = ref<string | undefined>(defaultValue);
const value = defineModel<string | undefined>('modelValue', {
default: undefined,
get: v => v ?? localValue.value,
set: (v) => {
localValue.value = v;
return v;
},
});
const localSearch = ref<string>(defaultSearchTerm);
const search = defineModel<string>('searchTerm', {
default: undefined,
get: v => v ?? localSearch.value,
set: (v) => {
localSearch.value = v;
return v;
},
});
const selectedValue = ref<string | undefined>(undefined);
const listId = useId(undefined, 'command-list');
const labelId = useId(undefined, 'command-label');
const listElement = shallowRef<HTMLElement | undefined>(undefined);
const inputElement = shallowRef<HTMLElement | undefined>(undefined);
const allItems = shallowRef(new Map<string, CommandItemInfo>());
const allGroups = shallowRef(new Map<string, Set<string>>());
function registerItem(info: CommandItemInfo) {
allItems.value.set(info.value, info);
triggerRef(allItems);
}
function unregisterItem(value: string) {
if (allItems.value.delete(value)) triggerRef(allItems);
if (selectedValue.value === value) selectedValue.value = undefined;
}
function registerGroup(groupId: string) {
if (!allGroups.value.has(groupId)) {
allGroups.value.set(groupId, new Set());
triggerRef(allGroups);
}
}
function unregisterGroup(groupId: string) {
if (allGroups.value.delete(groupId)) triggerRef(allGroups);
}
function registerGroupItem(groupId: string, val: string) {
let set = allGroups.value.get(groupId);
if (!set) {
set = new Set();
allGroups.value.set(groupId, set);
}
set.add(val);
triggerRef(allGroups);
}
function unregisterGroupItem(groupId: string, val: string) {
const set = allGroups.value.get(groupId);
if (set?.delete(val)) triggerRef(allGroups);
}
const filterRef = toRef(() => filter);
const shouldFilterRef = toRef(() => shouldFilter);
const filteredItems = computed<Map<string, number>>(() => {
const out = new Map<string, number>();
const term = search.value;
const useFilter = shouldFilterRef.value && term.length > 0;
const fn = filterRef.value ?? defaultFilter;
for (const [val, info] of allItems.value) {
const score = useFilter ? fn(info.textValue, term, info.keywords) : 1;
if (score > 0) out.set(val, score);
}
return out;
});
function escapeAttr(v: string): string {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') return CSS.escape(v);
return v.replaceAll(/["\\]/g, '\\$&');
}
function getSelectableItems(): string[] {
const filtered = filteredItems.value;
const root = listElement.value;
const candidates: Array<{ value: string; score: number; idx: number }> = [];
if (root) {
const els = Array.from(root.querySelectorAll<HTMLElement>(`[${COMMAND_ITEM_ATTR}]`));
const indexOf = new Map<string, number>();
for (let i = 0; i < els.length; i++) {
const v = els[i]!.getAttribute(COMMAND_VALUE_ATTR);
if (v !== null) indexOf.set(v, i);
}
for (const [val, score] of filtered) {
const info = allItems.value.get(val);
if (!info || info.disabled) continue;
candidates.push({ value: val, score, idx: indexOf.get(val) ?? Number.MAX_SAFE_INTEGER });
}
}
else {
let i = 0;
for (const [val, score] of filtered) {
const info = allItems.value.get(val);
if (!info || info.disabled) continue;
candidates.push({ value: val, score, idx: i++ });
}
}
candidates.sort((a, b) => b.score - a.score || a.idx - b.idx);
return candidates.map(c => c.value);
}
function getItemId(val: string): string {
return `${listId.value}-item-${escapeAttr(val)}`;
}
function setModelValue(v: string | undefined) {
value.value = v;
}
function setSearchTerm(v: string) {
search.value = v;
}
function setSelectedValue(v: string | undefined) {
selectedValue.value = v;
}
function setListElement(el: HTMLElement | undefined) {
listElement.value = el;
}
function setInputElement(el: HTMLElement | undefined) {
inputElement.value = el;
}
function clear(resetValue = false) {
setSearchTerm('');
if (resetValue) setModelValue(undefined);
const el = inputElement.value as HTMLInputElement | undefined;
if (el) {
if (el.value !== '') el.value = '';
el.focus();
}
}
function commitSelected() {
const v = selectedValue.value;
if (v === undefined) return;
const info = allItems.value.get(v);
if (!info || info.disabled) return;
setModelValue(v);
info.onSelect?.();
}
// Auto-highlight the highest-scored visible item when items or search change.
watch(
[() => search.value, filteredItems, allItems],
() => {
const current = selectedValue.value;
if (current && filteredItems.value.has(current)) {
const info = allItems.value.get(current);
if (info && !info.disabled) return;
}
const items = getSelectableItems();
selectedValue.value = items[0];
},
{ flush: 'post' },
);
const announceCount = computed(() => {
const n = filteredItems.value.size;
return n === 1 ? '1 result available.' : `${n} results available.`;
});
provideCommandContext({
modelValue: value,
setModelValue,
searchTerm: search,
setSearchTerm,
selectedValue,
setSelectedValue,
shouldFilter: toRef(() => shouldFilter),
loop: toRef(() => loop),
filterFunction: filterRef,
dir: direction,
listId,
labelId,
getItemId,
allItems,
filteredItems,
registerItem,
unregisterItem,
allGroups,
registerGroup,
unregisterGroup,
registerGroupItem,
unregisterGroupItem,
listElement,
setListElement,
getSelectableItems,
commitSelected,
clear,
inputElement,
setInputElement,
});
defineExpose({
filteredItems,
getSelectableItems,
selectedValue,
setSelectedValue,
commitSelected,
clear,
});
// `useForwardExpose` runs AFTER `defineExpose` so the composable absorbs and
// merges the prior expose bindings (plus props + `$el`) instead of
// `defineExpose`'s `expose()` clobbering them and warning
// "expose() should be called only once per setup()".
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="application"
:aria-label="label"
:aria-labelledby="label ? undefined : labelId"
:dir="direction"
data-primitives-command-root
v-bind="$attrs"
>
<VisuallyHidden :id="labelId" aria-hidden="true">
{{ label ?? 'Command palette' }}
</VisuallyHidden>
<VisuallyHidden role="status" aria-live="polite">
{{ announceCount }}
</VisuallyHidden>
<slot
:search-term="search"
:selected-value="selectedValue"
:model-value="value"
:filtered-count="filteredItems.size"
/>
</Primitive>
</template>
@@ -0,0 +1,41 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Visual divider between groups or items. Hidden automatically while a search
* term is active (since filtering collapses the list) unless `alwaysRender` is set.
*/
export interface CommandSeparatorProps extends PrimitiveProps {
/** Render the separator even while the search term is active. */
alwaysRender?: boolean;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { useCommandContext } from './context';
const { as = 'div', alwaysRender = false } = defineProps<CommandSeparatorProps>();
const { forwardRef } = useForwardExpose();
const ctx = useCommandContext();
const isVisible = computed(() => alwaysRender || ctx.searchTerm.value.length === 0);
</script>
<template>
<Primitive
v-if="isVisible"
:ref="forwardRef"
:as="as"
role="separator"
aria-orientation="horizontal"
aria-hidden="true"
data-primitives-command-separator
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,617 @@
import { defineComponent, h, nextTick, ref } from 'vue';
import { describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
import {
CommandCancel,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandItemIndicator,
CommandList,
CommandRoot,
CommandSeparator,
} from '../index';
interface Opt {
value: string;
label?: string;
keywords?: string[];
disabled?: boolean;
textValue?: string;
}
function createCommand(
options: Opt[],
rootProps: Record<string, unknown> = {},
inputProps: Record<string, unknown> = {},
) {
return mount(
defineComponent({
setup() {
return () =>
h(CommandRoot, { label: 'Test palette', ...rootProps }, {
default: () => [
h(CommandInput, { ...inputProps }),
h(CommandList, null, {
default: () => [
h(CommandEmpty, null, { default: () => 'No results' }),
...options.map(o =>
h(
CommandItem,
{
value: o.value,
keywords: o.keywords,
disabled: o.disabled,
textValue: o.textValue,
},
{
default: () => [
o.label ?? o.value,
h(CommandItemIndicator, null, { default: () => 'X' }),
],
},
),
),
],
}),
],
});
},
}),
{ attachTo: document.body },
);
}
function press(el: Element, key: string, opts: Record<string, unknown> = {}) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...opts }));
}
function setInput(el: HTMLInputElement, value: string) {
el.value = value;
el.dispatchEvent(new Event('input', { bubbles: true }));
}
const sample: Opt[] = [
{ value: 'apple', keywords: ['fruit'] },
{ value: 'banana', keywords: ['fruit', 'yellow'] },
{ value: 'cherry', keywords: ['fruit', 'red'] },
];
describe('Command — ARIA skeleton', () => {
it('renders combobox input, listbox and options with correct roles', async () => {
const w = createCommand(sample);
await nextTick();
expect(w.find('[role="combobox"]').exists()).toBe(true);
expect(w.find('[role="listbox"]').exists()).toBe(true);
expect(w.findAll('[role="option"]')).toHaveLength(3);
const input = w.find('[role="combobox"]');
expect(input.attributes('aria-controls')).toBe(w.find('[role="listbox"]').attributes('id'));
w.unmount();
});
it('exposes a polite live region announcing result count', async () => {
const w = createCommand(sample);
await nextTick();
const status = w.find('[role="status"]');
expect(status.exists()).toBe(true);
expect(status.text()).toContain('3 results available.');
w.unmount();
});
it('drives aria-activedescendant from the highlighted item', async () => {
const w = createCommand(sample);
await nextTick();
const input = w.find('[role="combobox"]');
const active = input.attributes('aria-activedescendant');
expect(active).toBeTruthy();
const highlighted = w.find('[data-highlighted]');
expect(highlighted.attributes('id')).toBe(active);
w.unmount();
});
});
describe('Command — aria-selected semantics', () => {
it('sets aria-selected from committed modelValue, not the highlight', async () => {
const w = createCommand(sample, { modelValue: 'banana' });
await nextTick();
const options = w.findAll('[role="option"]');
const selected = options.filter(o => o.attributes('aria-selected') === 'true');
expect(selected).toHaveLength(1);
expect(selected[0]!.attributes('data-value')).toBe('banana');
w.unmount();
});
it('highlight is conveyed via data-highlighted, separate from aria-selected', async () => {
const w = createCommand(sample);
await nextTick();
// First item auto-highlighted, but nothing committed → no aria-selected.
expect(w.findAll('[aria-selected="true"]')).toHaveLength(0);
expect(w.findAll('[data-highlighted]')).toHaveLength(1);
w.unmount();
});
it('renders ItemIndicator only on the committed item', async () => {
const w = createCommand(sample, { modelValue: 'cherry' });
await nextTick();
const indicators = w.findAll('[data-primitives-command-item-indicator]');
expect(indicators).toHaveLength(1);
const cherryRow = w.findAll('[role="option"]').find(o => o.attributes('data-value') === 'cherry')!;
expect(cherryRow.find('[data-primitives-command-item-indicator]').exists()).toBe(true);
w.unmount();
});
});
describe('Command — filtering', () => {
it('filters by substring of value', async () => {
const w = createCommand(sample);
await nextTick();
const input = w.find('[role="combobox"]').element as HTMLInputElement;
setInput(input, 'ban');
await nextTick();
const visible = w.findAll('[role="option"]').filter(o => (o.element as HTMLElement).style.display !== 'none');
expect(visible.map(o => o.attributes('data-value'))).toEqual(['banana']);
w.unmount();
});
it('matches keywords', async () => {
const w = createCommand(sample);
await nextTick();
const input = w.find('[role="combobox"]').element as HTMLInputElement;
setInput(input, 'yellow');
await nextTick();
const visible = w.findAll('[role="option"]').filter(o => (o.element as HTMLElement).style.display !== 'none');
expect(visible.map(o => o.attributes('data-value'))).toEqual(['banana']);
w.unmount();
});
it('filters by textValue when the value is an opaque key', async () => {
const w = createCommand([
{ value: 'id-1', textValue: 'Zebra' },
{ value: 'id-2', textValue: 'Lion' },
]);
await nextTick();
const input = w.find('[role="combobox"]').element as HTMLInputElement;
setInput(input, 'zeb');
await nextTick();
const visible = w.findAll('[role="option"]').filter(o => (o.element as HTMLElement).style.display !== 'none');
expect(visible.map(o => o.attributes('data-value'))).toEqual(['id-1']);
w.unmount();
});
it('shows the empty state when nothing matches', async () => {
const w = createCommand(sample);
await nextTick();
const input = w.find('[role="combobox"]').element as HTMLInputElement;
setInput(input, 'zzzz');
await nextTick();
expect(w.find('[data-primitives-command-empty]').exists()).toBe(true);
w.unmount();
});
it('respects shouldFilter=false (no filtering)', async () => {
const w = createCommand(sample, { shouldFilter: false });
await nextTick();
const input = w.find('[role="combobox"]').element as HTMLInputElement;
setInput(input, 'zzzz');
await nextTick();
const visible = w.findAll('[role="option"]').filter(o => (o.element as HTMLElement).style.display !== 'none');
expect(visible).toHaveLength(3);
w.unmount();
});
});
describe('Command — keyboard navigation', () => {
it('ArrowDown / ArrowUp move the highlight', async () => {
const w = createCommand(sample);
await nextTick();
const input = w.find('[role="combobox"]');
press(input.element, 'ArrowDown');
await nextTick();
expect(w.find('[data-highlighted]').attributes('data-value')).toBe('banana');
press(input.element, 'ArrowUp');
await nextTick();
expect(w.find('[data-highlighted]').attributes('data-value')).toBe('apple');
w.unmount();
});
it('Home / End jump to first / last', async () => {
const w = createCommand(sample);
await nextTick();
const input = w.find('[role="combobox"]');
press(input.element, 'End');
await nextTick();
expect(w.find('[data-highlighted]').attributes('data-value')).toBe('cherry');
press(input.element, 'Home');
await nextTick();
expect(w.find('[data-highlighted]').attributes('data-value')).toBe('apple');
w.unmount();
});
it('PageDown / PageUp jump to last / first', async () => {
const w = createCommand(sample);
await nextTick();
const input = w.find('[role="combobox"]');
press(input.element, 'PageDown');
await nextTick();
expect(w.find('[data-highlighted]').attributes('data-value')).toBe('cherry');
press(input.element, 'PageUp');
await nextTick();
expect(w.find('[data-highlighted]').attributes('data-value')).toBe('apple');
w.unmount();
});
it('clamps at the ends by default and wraps with loop', async () => {
const clamp = createCommand(sample);
await nextTick();
let input = clamp.find('[role="combobox"]');
press(input.element, 'ArrowUp');
await nextTick();
expect(clamp.find('[data-highlighted]').attributes('data-value')).toBe('apple');
clamp.unmount();
const loop = createCommand(sample, { loop: true });
await nextTick();
input = loop.find('[role="combobox"]');
press(input.element, 'ArrowUp');
await nextTick();
expect(loop.find('[data-highlighted]').attributes('data-value')).toBe('cherry');
loop.unmount();
});
it('Enter commits the highlighted item and updates modelValue', async () => {
const model = ref<string | undefined>(undefined);
const w = mount(
defineComponent({
setup() {
return () =>
h(CommandRoot, { modelValue: model.value, 'onUpdate:modelValue': (v: string | undefined) => (model.value = v) }, {
default: () => [
h(CommandInput),
h(CommandList, null, {
default: () => sample.map(o => h(CommandItem, { value: o.value }, { default: () => o.value })),
}),
],
});
},
}),
{ attachTo: document.body },
);
await nextTick();
const input = w.find('[role="combobox"]');
press(input.element, 'ArrowDown');
await nextTick();
press(input.element, 'Enter');
await nextTick();
expect(model.value).toBe('banana');
w.unmount();
});
it('Enter does NOT commit while IME composition is active', async () => {
const model = ref<string | undefined>(undefined);
const w = mount(
defineComponent({
setup() {
return () =>
h(CommandRoot, { modelValue: model.value, 'onUpdate:modelValue': (v: string | undefined) => (model.value = v) }, {
default: () => [
h(CommandInput),
h(CommandList, null, {
default: () => sample.map(o => h(CommandItem, { value: o.value }, { default: () => o.value })),
}),
],
});
},
}),
{ attachTo: document.body },
);
await nextTick();
const input = w.find('[role="combobox"]');
press(input.element, 'Enter', { isComposing: true });
await nextTick();
expect(model.value).toBeUndefined();
// Without composition it commits.
press(input.element, 'Enter');
await nextTick();
expect(model.value).toBe('apple');
w.unmount();
});
});
describe('Command — selection & emit', () => {
it('click commits and emits select on the item', async () => {
const selected: string[] = [];
const w = mount(
defineComponent({
setup() {
return () =>
h(CommandRoot, null, {
default: () => [
h(CommandInput),
h(CommandList, null, {
default: () =>
sample.map(o =>
h(CommandItem, { value: o.value, onSelect: (v: string) => selected.push(v) }, { default: () => o.value }),
),
}),
],
});
},
}),
{ attachTo: document.body },
);
await nextTick();
const banana = w.findAll('[role="option"]').find(o => o.attributes('data-value') === 'banana')!;
await banana.trigger('click');
await nextTick();
expect(selected).toEqual(['banana']);
w.unmount();
});
it('disabled item blocks click selection and is skipped by keyboard', async () => {
const selected: string[] = [];
const opts: Opt[] = [
{ value: 'a' },
{ value: 'b', disabled: true },
{ value: 'c' },
];
const w = mount(
defineComponent({
setup() {
return () =>
h(CommandRoot, null, {
default: () => [
h(CommandInput),
h(CommandList, null, {
default: () =>
opts.map(o =>
h(
CommandItem,
{ value: o.value, disabled: o.disabled, onSelect: (v: string) => selected.push(v) },
{ default: () => o.value },
),
),
}),
],
});
},
}),
{ attachTo: document.body },
);
await nextTick();
const disabled = w.findAll('[role="option"]').find(o => o.attributes('data-value') === 'b')!;
expect(disabled.attributes('aria-disabled')).toBe('true');
await disabled.trigger('click');
await nextTick();
expect(selected).toEqual([]);
const input = w.find('[role="combobox"]');
press(input.element, 'ArrowDown');
await nextTick();
// skips disabled 'b' → goes to 'c'
expect(w.find('[data-highlighted]').attributes('data-value')).toBe('c');
w.unmount();
});
});
describe('Command — uncontrolled & controlled search', () => {
it('uncontrolled defaultSearchTerm seeds the search', async () => {
const w = createCommand(sample, { defaultSearchTerm: 'cher' });
await nextTick();
const visible = w.findAll('[role="option"]').filter(o => (o.element as HTMLElement).style.display !== 'none');
expect(visible.map(o => o.attributes('data-value'))).toEqual(['cherry']);
w.unmount();
});
it('controlled searchTerm v-model reflects into the list', async () => {
const term = ref('app');
const w = mount(
defineComponent({
setup() {
return () =>
h(CommandRoot, { searchTerm: term.value, 'onUpdate:searchTerm': (v: string) => (term.value = v) }, {
default: () => [
h(CommandInput),
h(CommandList, null, {
default: () => sample.map(o => h(CommandItem, { value: o.value }, { default: () => o.value })),
}),
],
});
},
}),
{ attachTo: document.body },
);
await nextTick();
let visible = w.findAll('[role="option"]').filter(o => (o.element as HTMLElement).style.display !== 'none');
expect(visible.map(o => o.attributes('data-value'))).toEqual(['apple']);
term.value = 'ban';
await nextTick();
visible = w.findAll('[role="option"]').filter(o => (o.element as HTMLElement).style.display !== 'none');
expect(visible.map(o => o.attributes('data-value'))).toEqual(['banana']);
w.unmount();
});
});
describe('Command — Cancel / clear', () => {
it('clears the search term and refocuses the input on click', async () => {
const w = mount(
defineComponent({
setup() {
return () =>
h(CommandRoot, { defaultSearchTerm: 'banana' }, {
default: () => [
h(CommandInput),
h(CommandCancel, null, { default: () => 'Clear' }),
h(CommandList, null, {
default: () => sample.map(o => h(CommandItem, { value: o.value }, { default: () => o.value })),
}),
],
});
},
}),
{ attachTo: document.body },
);
await nextTick();
const input = w.find('[role="combobox"]').element as HTMLInputElement;
expect(input.value).toBe('banana');
await w.find('[data-primitives-command-cancel]').trigger('click');
await nextTick();
expect(input.value).toBe('');
expect(document.activeElement).toBe(input);
w.unmount();
});
it('resetValue also clears the committed modelValue', async () => {
const model = ref<string | undefined>('apple');
const w = mount(
defineComponent({
setup() {
return () =>
h(CommandRoot, { modelValue: model.value, 'onUpdate:modelValue': (v: string | undefined) => (model.value = v) }, {
default: () => [
h(CommandInput),
h(CommandCancel, { resetValue: true }, { default: () => 'Clear' }),
h(CommandList, null, {
default: () => sample.map(o => h(CommandItem, { value: o.value }, { default: () => o.value })),
}),
],
});
},
}),
{ attachTo: document.body },
);
await nextTick();
await w.find('[data-primitives-command-cancel]').trigger('click');
await nextTick();
expect(model.value).toBeUndefined();
w.unmount();
});
it('renders a button with type=button and an accessible label', async () => {
const w = mount(
defineComponent({
setup() {
return () =>
h(CommandRoot, null, {
default: () => [h(CommandInput), h(CommandCancel, null, { default: () => 'Clear' })],
});
},
}),
{ attachTo: document.body },
);
await nextTick();
const btn = w.find('[data-primitives-command-cancel]');
expect(btn.attributes('type')).toBe('button');
expect(btn.attributes('aria-label')).toBe('Clear search');
w.unmount();
});
});
describe('Command — dir / RTL', () => {
it('reflects dir on the root', async () => {
const w = createCommand(sample, { dir: 'rtl' });
await nextTick();
expect(w.find('[data-primitives-command-root]').attributes('dir')).toBe('rtl');
w.unmount();
});
it('defaults to ltr', async () => {
const w = createCommand(sample);
await nextTick();
expect(w.find('[data-primitives-command-root]').attributes('dir')).toBe('ltr');
w.unmount();
});
});
describe('Command — separator', () => {
it('hides the separator while a search term is active', async () => {
const w = mount(
defineComponent({
setup() {
return () =>
h(CommandRoot, null, {
default: () => [
h(CommandInput),
h(CommandList, null, {
default: () => [
h(CommandItem, { value: 'a' }, { default: () => 'a' }),
h(CommandSeparator),
h(CommandItem, { value: 'b' }, { default: () => 'b' }),
],
}),
],
});
},
}),
{ attachTo: document.body },
);
await nextTick();
expect(w.find('[data-primitives-command-separator]').exists()).toBe(true);
const input = w.find('[role="combobox"]').element as HTMLInputElement;
setInput(input, 'a');
await nextTick();
expect(w.find('[data-primitives-command-separator]').exists()).toBe(false);
w.unmount();
});
});
describe('Command — groups', () => {
it('hides a group when all its items are filtered out', async () => {
const w = mount(
defineComponent({
setup() {
return () =>
h(CommandRoot, null, {
default: () => [
h(CommandInput),
h(CommandList, null, {
default: () => [
h(CommandGroup, { heading: 'Fruit', value: 'fruit' }, {
default: () => [
h(CommandItem, { value: 'apple' }, { default: () => 'apple' }),
],
}),
h(CommandGroup, { heading: 'Veg', value: 'veg' }, {
default: () => [
h(CommandItem, { value: 'carrot' }, { default: () => 'carrot' }),
],
}),
],
}),
],
});
},
}),
{ attachTo: document.body },
);
await nextTick();
const input = w.find('[role="combobox"]').element as HTMLInputElement;
setInput(input, 'apple');
await nextTick();
const groups = w.findAll('[data-primitives-command-group]');
const fruit = groups.find(g => g.text().includes('Fruit'))!;
const veg = groups.find(g => g.text().includes('Veg'))!;
expect(fruit.attributes('hidden')).toBeUndefined();
expect(veg.attributes('hidden')).toBe('');
w.unmount();
});
});
describe('Command — edge cases', () => {
it('handles an empty item list', async () => {
const w = createCommand([]);
await nextTick();
expect(w.findAll('[role="option"]')).toHaveLength(0);
const status = w.find('[role="status"]');
expect(status.text()).toContain('0 results available.');
w.unmount();
});
it('singular vs plural in the live region', async () => {
const w = createCommand([{ value: 'only' }]);
await nextTick();
expect(w.find('[role="status"]').text()).toContain('1 result available.');
w.unmount();
});
});
@@ -0,0 +1,90 @@
import type { ComputedRef, Ref, ShallowRef } from 'vue';
import type { CommandFilterFunction } from './utils';
import { useContextFactory } from '@robonen/vue';
export interface CommandItemInfo {
value: string;
/** Display/searchable text. Filtering targets this instead of the identity `value`. */
textValue: string;
keywords: string[];
disabled: boolean;
onSelect?: () => void;
}
export interface CommandContext {
/** Committed selected value (v-model). */
modelValue: Ref<string | undefined>;
setModelValue: (value: string | undefined) => void;
/** Current search term (v-model:searchTerm). */
searchTerm: Ref<string>;
setSearchTerm: (value: string) => void;
/** Currently highlighted item value (keyboard / pointer focus). */
selectedValue: Ref<string | undefined>;
setSelectedValue: (value: string | undefined) => void;
/** Behavior flags. */
shouldFilter: Ref<boolean>;
loop: Ref<boolean>;
filterFunction: Ref<CommandFilterFunction | undefined>;
/** Effective reading direction (per-root override or `ConfigProvider`). */
dir: Ref<'ltr' | 'rtl'>;
/** A11y identifiers. */
listId: Ref<string>;
labelId: Ref<string>;
getItemId: (value: string) => string;
/** Registries. */
allItems: ShallowRef<Map<string, CommandItemInfo>>;
filteredItems: ComputedRef<Map<string, number>>;
registerItem: (info: CommandItemInfo) => void;
unregisterItem: (value: string) => void;
allGroups: ShallowRef<Map<string, Set<string>>>;
registerGroup: (groupId: string) => void;
unregisterGroup: (groupId: string) => void;
registerGroupItem: (groupId: string, value: string) => void;
unregisterGroupItem: (groupId: string, value: string) => void;
/** DOM. */
listElement: ShallowRef<HTMLElement | undefined>;
setListElement: (el: HTMLElement | undefined) => void;
/** Returns selectable item values sorted by score desc, then DOM order. */
getSelectableItems: () => string[];
/** Commits the currently-highlighted item: updates modelValue + fires its select callback. */
commitSelected: () => void;
/** Clears the search term and, when `resetValue`, the committed selection too. */
clear: (resetValue?: boolean) => void;
/** Registered input element (so a clear/cancel affordance can refocus it). */
inputElement: ShallowRef<HTMLElement | undefined>;
setInputElement: (el: HTMLElement | undefined) => void;
}
export interface CommandGroupContext {
id: Ref<string>;
forceMount: Ref<boolean>;
}
export interface CommandItemContext {
/** Whether this item matches the committed `modelValue`. */
isSelected: Ref<boolean>;
}
export const {
inject: useCommandContext,
provide: provideCommandContext,
} = useContextFactory<CommandContext>('Command');
export const {
inject: useCommandGroupContext,
provide: provideCommandGroupContext,
} = useContextFactory<CommandGroupContext>('CommandGroup');
export const {
inject: useCommandItemContext,
provide: provideCommandItemContext,
} = useContextFactory<CommandItemContext>('CommandItem');
+162
View File
@@ -0,0 +1,162 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandRoot,
CommandSeparator,
} from '@robonen/primitives';
interface Action {
value: string;
label: string;
/** Inline-SVG path data (24×24 viewBox, stroked with `currentColor`). */
icon: string;
keywords?: string[];
shortcut?: string;
}
const navigation: Action[] = [
{ value: 'home', label: 'Go to Dashboard', icon: 'M3 3h8v8H3z M13 3h8v8h-8z M13 13h8v8h-8z M3 13h8v8H3z', keywords: ['overview', 'start'] },
{ value: 'projects', label: 'Open Projects', icon: 'M3 7a1 1 0 0 1 1-1h5l2 2h9a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z', keywords: ['repos', 'work'] },
{ value: 'settings', label: 'Open Settings', icon: 'M4 21v-7 M4 10V3 M12 21v-9 M12 8V3 M20 21v-5 M20 12V3 M2 14h4 M10 8h4 M18 16h4', keywords: ['preferences', 'config'], shortcut: ',' },
];
const actions: Action[] = [
{ value: 'new-file', label: 'Create new file', icon: 'M13 3H6a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V9z M13 3v6h6 M12 12v6 M9 15h6', keywords: ['add'], shortcut: 'N' },
{ value: 'invite', label: 'Invite teammate', icon: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2 M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z M19 8v6 M22 11h-6', keywords: ['member', 'share'] },
{ value: 'theme', label: 'Toggle dark mode', icon: 'M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z', keywords: ['appearance', 'light'] },
{ value: 'archive', label: 'Archive workspace', icon: 'M4 8h16v11a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z M3 4h18v4H3z M10 12h4', keywords: ['delete'] },
];
const selected = ref<string>();
const lastRun = ref<string>();
function run(value: string) {
lastRun.value = value;
}
</script>
<template>
<div class="flex flex-col items-center gap-4 p-6 bg-bg-inset text-fg">
<CommandRoot
v-model="selected"
label="Command palette"
loop
class="demo-card w-full max-w-100 overflow-hidden shadow-lg"
>
<template #default="{ filteredCount }">
<!-- Search -->
<div class="flex items-center gap-2.5 border-b border-border px-3.5">
<svg
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"
stroke-linecap="round" stroke-linejoin="round"
class="size-4 shrink-0 text-fg-subtle" aria-hidden="true"
>
<path d="M11 19a8 8 0 1 1 0-16 8 8 0 0 1 0 16z M21 21l-4.3-4.3" />
</svg>
<CommandInput
auto-focus
placeholder="Type a command or search…"
class="w-full bg-transparent py-3 text-sm text-fg outline-none placeholder:text-fg-subtle"
/>
<span class="shrink-0 rounded bg-bg-subtle px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-fg-subtle">{{ filteredCount }}</span>
</div>
<!-- Results -->
<CommandList class="max-h-72 overflow-y-auto p-2">
<CommandEmpty class="px-3 py-10 text-center text-sm text-fg-muted">
No results found.
</CommandEmpty>
<CommandGroup
heading="Navigation"
class="[&_[data-primitives-command-group-heading]]:px-2 [&_[data-primitives-command-group-heading]]:pb-1 [&_[data-primitives-command-group-heading]]:pt-2 [&_[data-primitives-command-group-heading]]:text-xs [&_[data-primitives-command-group-heading]]:font-medium [&_[data-primitives-command-group-heading]]:text-fg-subtle"
>
<template #default>
<CommandItem
v-for="item in navigation"
:key="item.value"
:value="item.value"
:text-value="item.label"
:keywords="item.keywords"
class="group flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-sm data-[state=selected]:bg-accent data-[state=selected]:text-accent-fg"
@select="run"
>
<template #default="{ selected: isSelected }">
<svg
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"
stroke-linecap="round" stroke-linejoin="round"
class="size-4 shrink-0 text-fg-muted group-data-[state=selected]:text-accent-fg" aria-hidden="true"
>
<path :d="item.icon" />
</svg>
<span class="flex-1">{{ item.label }}</span>
<kbd
v-if="item.shortcut"
class="rounded border border-border bg-bg-subtle px-1.5 text-xs text-fg-muted group-data-[state=selected]:border-transparent group-data-[state=selected]:bg-transparent group-data-[state=selected]:text-accent-fg"
>{{ item.shortcut }}</kbd>
<svg
v-if="isSelected"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25"
stroke-linecap="round" stroke-linejoin="round"
class="size-4 shrink-0 text-emerald-500 group-data-[state=selected]:text-accent-fg dark:text-emerald-400" aria-hidden="true"
>
<path d="M20 6 9 17l-5-5" />
</svg>
</template>
</CommandItem>
</template>
</CommandGroup>
<CommandSeparator class="my-2 h-px bg-border" />
<CommandGroup
heading="Actions"
class="[&_[data-primitives-command-group-heading]]:px-2 [&_[data-primitives-command-group-heading]]:pb-1 [&_[data-primitives-command-group-heading]]:pt-2 [&_[data-primitives-command-group-heading]]:text-xs [&_[data-primitives-command-group-heading]]:font-medium [&_[data-primitives-command-group-heading]]:text-fg-subtle"
>
<template #default>
<CommandItem
v-for="item in actions"
:key="item.value"
:value="item.value"
:text-value="item.label"
:keywords="item.keywords"
:disabled="item.value === 'archive'"
class="group flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-sm data-[state=selected]:bg-accent data-[state=selected]:text-accent-fg data-[disabled]:cursor-not-allowed data-[disabled]:opacity-40"
@select="run"
>
<svg
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"
stroke-linecap="round" stroke-linejoin="round"
class="size-4 shrink-0 text-fg-muted group-data-[state=selected]:text-accent-fg" aria-hidden="true"
>
<path :d="item.icon" />
</svg>
<span class="flex-1">{{ item.label }}</span>
<span v-if="item.value === 'archive'" class="rounded bg-bg-subtle px-1.5 py-0.5 text-[11px] font-medium text-fg-subtle">soon</span>
<kbd
v-else-if="item.shortcut"
class="rounded border border-border bg-bg-subtle px-1.5 text-xs text-fg-muted group-data-[state=selected]:border-transparent group-data-[state=selected]:bg-transparent group-data-[state=selected]:text-accent-fg"
>{{ item.shortcut }}</kbd>
</CommandItem>
</template>
</CommandGroup>
</CommandList>
</template>
</CommandRoot>
<p class="text-sm text-fg-muted">
<template v-if="lastRun">
Ran: <code class="rounded bg-bg-subtle px-1.5 py-0.5 font-medium text-fg">{{ lastRun }}</code>
</template>
<template v-else>
Use to navigate, Enter to run.
</template>
</p>
</div>
</template>
+36
View File
@@ -0,0 +1,36 @@
export { default as CommandCancel } from './CommandCancel.vue';
export { default as CommandEmpty } from './CommandEmpty.vue';
export { default as CommandGroup } from './CommandGroup.vue';
export { default as CommandInput } from './CommandInput.vue';
export { default as CommandItem } from './CommandItem.vue';
export { default as CommandItemIndicator } from './CommandItemIndicator.vue';
export { default as CommandList } from './CommandList.vue';
export { default as CommandLoading } from './CommandLoading.vue';
export { default as CommandRoot } from './CommandRoot.vue';
export { default as CommandSeparator } from './CommandSeparator.vue';
export {
useCommandContext,
useCommandGroupContext,
useCommandItemContext,
} from './context';
export type {
CommandContext,
CommandGroupContext,
CommandItemContext,
CommandItemInfo,
} from './context';
export type { CommandFilterFunction } from './utils';
export type { CommandCancelProps } from './CommandCancel.vue';
export type { CommandEmptyProps } from './CommandEmpty.vue';
export type { CommandGroupProps } from './CommandGroup.vue';
export type { CommandInputEmits, CommandInputProps } from './CommandInput.vue';
export type { CommandItemEmits, CommandItemProps } from './CommandItem.vue';
export type { CommandItemIndicatorProps } from './CommandItemIndicator.vue';
export type { CommandListProps } from './CommandList.vue';
export type { CommandLoadingProps } from './CommandLoading.vue';
export type { CommandRootEmits, CommandRootProps } from './CommandRoot.vue';
export type { CommandSeparatorProps } from './CommandSeparator.vue';
+34
View File
@@ -0,0 +1,34 @@
export type CommandFilterFunction = (
value: string,
search: string,
keywords?: string[],
) => number;
export const COMMAND_ITEM_ATTR = 'data-primitives-command-item';
export const COMMAND_VALUE_ATTR = 'data-value';
/**
* Default scoring filter.
*
* - Empty search → score 1 (item visible).
* - Case-insensitive substring match across `value` + `keywords` → 1.
* - In-order subsequence (loose fuzzy) match → 0.5.
* - Otherwise → 0 (hide).
*/
export const defaultFilter: CommandFilterFunction = (value, search, keywords) => {
if (!search) return 1;
const needle = search.toLowerCase();
const haystackParts = keywords && keywords.length > 0 ? [value, ...keywords] : [value];
const haystack = haystackParts.join(' ').toLowerCase();
if (haystack.includes(needle)) return 1;
let i = 0;
for (let h = 0; h < haystack.length && i < needle.length; h++) {
if (haystack[h] === needle[i]) i++;
}
if (i === needle.length) return 0.5;
return 0;
};
@@ -0,0 +1,19 @@
<script lang="ts">
import type { MenuArrowProps } from '../menu';
/**
* An optional arrow that visually points from the content back toward its
* anchor. Render it inside the content.
*/
export interface ContextMenuArrowProps extends MenuArrowProps {}
</script>
<script setup lang="ts">
import { MenuArrow } from '../menu';
const props = defineProps<ContextMenuArrowProps>();
</script>
<template>
<MenuArrow v-bind="props"><slot /></MenuArrow>
</template>
@@ -0,0 +1,26 @@
<script lang="ts">
import type { MenuCheckboxItemEmits, MenuCheckboxItemProps } from '../menu';
/**
* An item that toggles an on/off (or indeterminate) state, exposing
* `aria-checked` for assistive tech. Bind `v-model:checked` and pair it with a
* `ContextMenuItemIndicator` to render the checkmark.
*/
export interface ContextMenuCheckboxItemProps extends MenuCheckboxItemProps {}
export type ContextMenuCheckboxItemEmits = MenuCheckboxItemEmits;
</script>
<script setup lang="ts">
import { MenuCheckboxItem } from '../menu';
const props = defineProps<ContextMenuCheckboxItemProps>();
const emit = defineEmits<ContextMenuCheckboxItemEmits>();
</script>
<template>
<MenuCheckboxItem
v-bind="props"
@select="emit('select', $event)"
@update:checked="emit('update:checked', $event)"
><slot /></MenuCheckboxItem>
</template>
@@ -0,0 +1,94 @@
<script lang="ts">
import type { MenuContentEmits, MenuContentProps } from '../menu';
/**
* The floating surface that holds the menu items, positioned at the pointer
* where the menu was invoked. Handles focus management, typeahead, and
* dismissal on outside click or Escape; render it inside a portal.
*
* Cursor-anchored positioning (`side`/`align`) is fixed by this part; pass
* collision/sizing props as needed.
*/
export interface ContextMenuContentProps extends MenuContentProps {}
export type ContextMenuContentEmits = MenuContentEmits;
</script>
<script setup lang="ts">
import { shallowRef } from 'vue';
import { MenuContent } from '../menu';
import { useContextMenuRootContext } from './context';
const {
sideOffset = 2,
alignOffset = 0,
avoidCollisions = true,
collisionPadding = 0,
sticky = 'partial',
hideWhenDetached = false,
...rest
} = defineProps<ContextMenuContentProps>();
const emit = defineEmits<ContextMenuContentEmits>();
const rootCtx = useContextMenuRootContext();
// In non-modal mode, an outside interaction should dismiss the menu but must
// not yank focus back to the trigger afterwards.
const hasInteractedOutside = shallowRef(false);
function handleInteractOutside(event: PointerEvent | MouseEvent | FocusEvent) {
// Right-clicking the trigger while the menu is open would otherwise dismiss
// (via pointerdown-outside) and immediately reopen (via contextmenu), causing
// a flicker and lost focus. Suppress the dismiss in that case.
const trigger = rootCtx.triggerElement.value;
const target = event.target as Node | null;
if (
'button' in event
&& event.button === 2
&& trigger !== undefined
&& target !== null
&& (trigger === target || trigger.contains(target))
) {
event.preventDefault();
return;
}
if (!event.defaultPrevented && !rootCtx.modal.value) hasInteractedOutside.value = true;
}
function handleCloseAutoFocus(event: Event) {
if (!event.defaultPrevented && hasInteractedOutside.value) event.preventDefault();
hasInteractedOutside.value = false;
emit('closeAutoFocus', event);
}
</script>
<template>
<MenuContent
v-bind="rest"
side="right"
:side-offset="sideOffset"
align="start"
:align-offset="alignOffset"
:avoid-collisions="avoidCollisions"
:collision-padding="collisionPadding"
:sticky="sticky"
:hide-when-detached="hideWhenDetached"
update-position-strategy="optimized"
:style="{
'--primitives-context-menu-content-transform-origin': 'var(--popper-transform-origin)',
'--primitives-context-menu-content-available-width': 'var(--popper-available-width)',
'--primitives-context-menu-content-available-height': 'var(--popper-available-height)',
'--primitives-context-menu-trigger-width': 'var(--popper-anchor-width)',
'--primitives-context-menu-trigger-height': 'var(--popper-anchor-height)',
}"
@close-auto-focus="handleCloseAutoFocus"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="(event) => { handleInteractOutside(event); emit('interactOutside', event); }"
@dismiss="emit('dismiss')"
@entry-focus="emit('entryFocus', $event)"
@open-auto-focus="emit('openAutoFocus', $event)"
>
<slot />
</MenuContent>
</template>
@@ -0,0 +1,19 @@
<script lang="ts">
import type { MenuGroupProps } from '../menu';
/**
* Groups a set of related items under one accessible `role="group"`, optionally
* labelled by a `ContextMenuLabel`. Use it to organize the menu into sections.
*/
export interface ContextMenuGroupProps extends MenuGroupProps {}
</script>
<script setup lang="ts">
import { MenuGroup } from '../menu';
const props = defineProps<ContextMenuGroupProps>();
</script>
<template>
<MenuGroup v-bind="props"><slot /></MenuGroup>
</template>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { MenuItemEmits, MenuItemProps } from '../menu';
/**
* A single actionable command in the menu. Emits `select` on click or Enter
* and closes the menu by default; can be `disabled` for unavailable actions.
*/
export interface ContextMenuItemProps extends MenuItemProps {}
export type ContextMenuItemEmits = MenuItemEmits;
</script>
<script setup lang="ts">
import { MenuItem } from '../menu';
const props = defineProps<ContextMenuItemProps>();
const emit = defineEmits<ContextMenuItemEmits>();
</script>
<template>
<MenuItem v-bind="props" @select="emit('select', $event)"><slot /></MenuItem>
</template>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { MenuItemIndicatorProps } from '../menu';
/**
* Renders its content only when the parent checkbox or radio item is checked.
* Place it inside a `ContextMenuCheckboxItem` or `ContextMenuRadioItem` to show
* the check or dot.
*/
export interface ContextMenuItemIndicatorProps extends MenuItemIndicatorProps {}
</script>
<script setup lang="ts">
import { MenuItemIndicator } from '../menu';
const props = defineProps<ContextMenuItemIndicatorProps>();
</script>
<template>
<MenuItemIndicator v-bind="props"><slot /></MenuItemIndicator>
</template>
@@ -0,0 +1,19 @@
<script lang="ts">
import type { MenuLabelProps } from '../menu';
/**
* A non-interactive caption for a group of items. Skipped by keyboard
* navigation; use it to title a section within the menu.
*/
export interface ContextMenuLabelProps extends MenuLabelProps {}
</script>
<script setup lang="ts">
import { MenuLabel } from '../menu';
const props = defineProps<ContextMenuLabelProps>();
</script>
<template>
<MenuLabel v-bind="props"><slot /></MenuLabel>
</template>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { MenuPortalProps } from '../menu';
/**
* Teleports the menu content into `document.body` (or a custom target) so it
* escapes parent overflow and stacking contexts. Place it between the trigger
* and the content.
*/
export interface ContextMenuPortalProps extends MenuPortalProps {}
</script>
<script setup lang="ts">
import { MenuPortal } from '../menu';
const props = defineProps<ContextMenuPortalProps>();
</script>
<template>
<MenuPortal v-bind="props"><slot /></MenuPortal>
</template>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { MenuRadioGroupEmits, MenuRadioGroupProps } from '../menu';
/**
* A group of mutually exclusive `ContextMenuRadioItem`s sharing a single
* selected value. Bind `v-model` to track the active choice.
*/
export interface ContextMenuRadioGroupProps extends MenuRadioGroupProps {}
export type ContextMenuRadioGroupEmits = MenuRadioGroupEmits;
</script>
<script setup lang="ts">
import { MenuRadioGroup } from '../menu';
const props = defineProps<ContextMenuRadioGroupProps>();
const emit = defineEmits<ContextMenuRadioGroupEmits>();
</script>
<template>
<MenuRadioGroup v-bind="props" @update:model-value="emit('update:modelValue', $event)"><slot /></MenuRadioGroup>
</template>
@@ -0,0 +1,22 @@
<script lang="ts">
import type { MenuRadioItemEmits, MenuRadioItemProps } from '../menu';
/**
* One option within a `ContextMenuRadioGroup`, identified by its `value`.
* Selecting it sets the group's value; pair it with a `ContextMenuItemIndicator`
* to render the selected dot.
*/
export interface ContextMenuRadioItemProps extends MenuRadioItemProps {}
export type ContextMenuRadioItemEmits = MenuRadioItemEmits;
</script>
<script setup lang="ts">
import { MenuRadioItem } from '../menu';
const props = defineProps<ContextMenuRadioItemProps>();
const emit = defineEmits<ContextMenuRadioItemEmits>();
</script>
<template>
<MenuRadioItem v-bind="props" @select="emit('select', $event)"><slot /></MenuRadioItem>
</template>
@@ -0,0 +1,61 @@
<script lang="ts">
import type { Direction } from '../../utilities/config-provider';
/**
* A menu that opens at the pointer on right-click (or a long-press on touch),
* replacing the platform's native context menu with your own styled actions.
* Built on top of Menu, so it inherits keyboard navigation, typeahead, nested
* submenus, and checkbox/radio items.
*
* Use it for contextual actions tied to a region or element — cut/copy/paste,
* row actions in a table, canvas tools — when there is no persistent button to
* click. The root owns open state and provides context to every part; listen
* to `update:open` to react when the menu opens or closes.
*/
export interface ContextMenuRootProps {
dir?: Direction;
modal?: boolean;
/**
* The duration in milliseconds from when a touch/pen press starts until the
* menu opens (long-press). Right-click opens immediately regardless.
* @default 700
*/
pressOpenDelay?: number;
}
</script>
<script setup lang="ts">
import { shallowRef, toRef } from 'vue';
import { useConfig } from '../../utilities/config-provider';
import { MenuRoot } from '../menu';
import { provideContextMenuRootContext } from './context';
const { dir: dirProp, modal = true, pressOpenDelay = 700 } = defineProps<ContextMenuRootProps>();
defineSlots<{ default?: (props: { open: boolean }) => unknown }>();
const open = defineModel<boolean>('open', { default: false });
const config = useConfig();
const triggerElement = shallowRef<HTMLElement>();
const dir = toRef(() => dirProp ?? config.dir.value);
provideContextMenuRootContext({
open,
onOpenChange: (v) => { open.value = v; },
modal: toRef(() => modal),
dir,
triggerElement,
pressOpenDelay: toRef(() => pressOpenDelay),
});
</script>
<template>
<MenuRoot
v-model:open="open"
:dir="dir"
:modal="modal"
>
<slot :open="open" />
</MenuRoot>
</template>
@@ -0,0 +1,19 @@
<script lang="ts">
import type { MenuSeparatorProps } from '../menu';
/**
* A visual divider between groups of items, exposed as `role="separator"` and
* skipped by keyboard navigation.
*/
export interface ContextMenuSeparatorProps extends MenuSeparatorProps {}
</script>
<script setup lang="ts">
import { MenuSeparator } from '../menu';
const props = defineProps<ContextMenuSeparatorProps>();
</script>
<template>
<MenuSeparator v-bind="props"><slot /></MenuSeparator>
</template>
@@ -0,0 +1,58 @@
<script lang="ts">
import type { MenuSubEmits, MenuSubProps } from '../menu';
/**
* Wraps a nested submenu, pairing a `ContextMenuSubTrigger` with its
* `ContextMenuSubContent` and owning that submenu's open state. Bind
* `v-model:open` to control it, or leave it unbound and pass `defaultOpen` to
* use it uncontrolled. Listen to `update:open` to track expansion; the default
* slot also exposes the current `open` value.
*/
export interface ContextMenuSubProps extends MenuSubProps {
/** The open state of the submenu when initially rendered. Use when you do not need to control its open state. */
defaultOpen?: boolean;
}
export type ContextMenuSubEmits = MenuSubEmits;
</script>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { MenuSub } from '../menu';
// `open: undefined` opts out of Vue's boolean-prop coercion (an absent boolean
// prop would otherwise become `false`), so we can tell "uncontrolled" (use
// `defaultOpen`) apart from an explicit `:open="false"` (controlled).
// `open: undefined` is load-bearing: it keeps an absent boolean prop `undefined`
// instead of letting Vue coerce it to `false`, so the `open ?? defaultOpen`
// uncontrolled seed works. Reactive props destructure can't preserve this, so
// this stays as `withDefaults` + `props`.
const props = withDefaults(defineProps<ContextMenuSubProps>(), {
open: undefined,
defaultOpen: false,
});
const emit = defineEmits<ContextMenuSubEmits>();
defineSlots<{ default?: (props: { open: boolean }) => unknown }>();
// Controlled when the `open` prop is supplied; otherwise the local ref (seeded
// by `defaultOpen`) drives it.
const local = ref(props.open ?? props.defaultOpen);
watch(() => props.open, (value) => {
if (value !== undefined) local.value = value;
});
function setOpen(value: boolean) {
local.value = value;
emit('update:open', value);
}
</script>
<template>
<MenuSub
:open="local"
@update:open="setOpen"
>
<slot :open="local" />
</MenuSub>
</template>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { MenuSubContentEmits, MenuSubContentProps } from '../menu';
/**
* The floating panel of a nested submenu, positioned alongside its
* `ContextMenuSubTrigger`. Render it inside a `ContextMenuSub`.
*/
export interface ContextMenuSubContentProps extends MenuSubContentProps {}
export type ContextMenuSubContentEmits = MenuSubContentEmits;
</script>
<script setup lang="ts">
import { MenuSubContent } from '../menu';
const props = defineProps<ContextMenuSubContentProps>();
const emit = defineEmits<ContextMenuSubContentEmits>();
</script>
<template>
<MenuSubContent
v-bind="props"
@close-auto-focus="emit('closeAutoFocus', $event)"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
@dismiss="emit('dismiss')"
@entry-focus="emit('entryFocus', $event)"
@open-auto-focus="emit('openAutoFocus', $event)"
><slot /></MenuSubContent>
</template>
@@ -0,0 +1,19 @@
<script lang="ts">
import type { MenuSubTriggerProps } from '../menu';
/**
* The item that opens a nested submenu on hover or arrow-key, anchoring its
* `ContextMenuSubContent`. Place it as the first child of a `ContextMenuSub`.
*/
export interface ContextMenuSubTriggerProps extends MenuSubTriggerProps {}
</script>
<script setup lang="ts">
import { MenuSubTrigger } from '../menu';
const props = defineProps<ContextMenuSubTriggerProps>();
</script>
<template>
<MenuSubTrigger v-bind="props"><slot /></MenuSubTrigger>
</template>
@@ -0,0 +1,127 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The region that captures right-click (and touch/pen long-press), preventing the
* native context menu and opening the menu anchored at the pointer position.
* Wrap whatever area should respond to a secondary click.
*/
export interface ContextMenuTriggerProps extends PrimitiveProps {
disabled?: boolean;
}
// Fully static; hoisted so the trigger does not reallocate it on every render.
const TRIGGER_STYLE = {
WebkitTouchCallout: 'none',
pointerEvents: 'auto',
} as const;
</script>
<script setup lang="ts">
import { nextTick, onMounted, onScopeDispose, shallowRef } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { MenuAnchor, useMenuContext } from '../menu';
import { Primitive } from '../../internal/primitive';
import { useContextMenuRootContext } from './context';
const { disabled = false, as = 'span' } = defineProps<ContextMenuTriggerProps>();
const menuCtx = useMenuContext();
const ctxMenuCtx = useContextMenuRootContext();
const { forwardRef, currentElement } = useForwardExpose();
const point = shallowRef({ x: 0, y: 0 });
// Reused scratch rect: getBoundingClientRect is polled by floating-ui on
// scroll/resize, so we mutate a single object in place (stable hidden class,
// zero per-call allocation) instead of returning a fresh literal each time.
const scratchRect = {
x: 0,
y: 0,
width: 0,
height: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
toJSON: () => {},
};
const virtualEl = {
getBoundingClientRect: () => {
const { x, y } = point.value;
scratchRect.x = x;
scratchRect.y = y;
scratchRect.top = y;
scratchRect.right = x;
scratchRect.bottom = y;
scratchRect.left = x;
return scratchRect;
},
};
let longPressTimer: ReturnType<typeof setTimeout> | undefined;
function clearLongPress() {
clearTimeout(longPressTimer);
}
onScopeDispose(clearLongPress);
// Long-press applies to touch AND pen; mouse uses the native contextmenu event.
function isTouchOrPen(event: PointerEvent): boolean {
return event.pointerType !== 'mouse';
}
function handleOpen(event: MouseEvent | PointerEvent) {
point.value = { x: event.clientX, y: event.clientY };
ctxMenuCtx.onOpenChange(true);
}
async function handleContextMenu(event: MouseEvent) {
if (disabled) return;
// Wait a microtask so a nested ContextMenuTrigger (whose own handler runs
// first as the event bubbles inward-out) can call `preventDefault()` and
// suppress this outer one. Also lets a consumer cancel the open by calling
// `preventDefault()` on the contextmenu event.
await nextTick();
if (event.defaultPrevented) return;
clearLongPress();
handleOpen(event);
event.preventDefault();
}
function handlePointerDown(event: PointerEvent) {
if (disabled || event.button !== 0 || !isTouchOrPen(event)) return;
// Clear here in case there are multiple touch points.
clearLongPress();
longPressTimer = setTimeout(handleOpen, ctxMenuCtx.pressOpenDelay.value, event);
}
function handlePointerEvent(event: PointerEvent) {
// A drag/scroll/lift gesture must cancel the pending long-press open.
if (isTouchOrPen(event)) clearLongPress();
}
onMounted(() => {
if (currentElement.value) ctxMenuCtx.triggerElement.value = currentElement.value;
});
</script>
<template>
<MenuAnchor as="template" :reference="virtualEl">
<Primitive
:ref="forwardRef"
:as="as"
:data-state="menuCtx.open.value ? 'open' : 'closed'"
:data-disabled="disabled ? '' : undefined"
:style="TRIGGER_STYLE"
@contextmenu="handleContextMenu"
@pointerdown="handlePointerDown"
@pointermove="handlePointerEvent"
@pointercancel="handlePointerEvent"
@pointerup="handlePointerEvent"
>
<slot />
</Primitive>
</MenuAnchor>
</template>
@@ -0,0 +1,335 @@
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,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
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>;
rootProps?: Record<string, unknown>;
onUpdateOpen?: (v: boolean) => void;
} = {}) {
const Harness = defineComponent({
setup() {
return () =>
h(
ContextMenuRoot,
{ ...options.rootProps, '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');
});
it('honours a custom pressOpenDelay on the root', async () => {
vi.useFakeTimers();
mountContextMenu({ rootProps: { pressOpenDelay: 300 } });
const trigger = getTrigger();
pointerDown(trigger, 'touch');
vi.advanceTimersByTime(299);
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('closed');
vi.advanceTimersByTime(1);
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('open');
});
});
describe('context-menu — contextmenu open semantics', () => {
it('does not open when the contextmenu event was already defaultPrevented (nested / consumer suppression)', async () => {
const onUpdateOpen = vi.fn();
mountContextMenu({ onUpdateOpen });
const trigger = getTrigger();
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 });
event.preventDefault();
trigger.dispatchEvent(event);
await nextTick();
await nextTick();
expect(onUpdateOpen).not.toHaveBeenCalled();
expect(trigger.getAttribute('data-state')).toBe('closed');
});
it('does not open when the trigger is disabled', async () => {
const onUpdateOpen = vi.fn();
mountContextMenu({ triggerAttrs: { disabled: true }, onUpdateOpen });
const trigger = getTrigger();
dispatchContextMenu(trigger);
await nextTick();
await nextTick();
expect(onUpdateOpen).not.toHaveBeenCalled();
expect(trigger.getAttribute('data-state')).toBe('closed');
expect(trigger.getAttribute('data-disabled')).toBe('');
});
});
describe('context-menu — trigger defensive styles', () => {
it('applies pointerEvents:auto so the trigger always receives the secondary click', () => {
mountContextMenu();
const trigger = getTrigger();
expect(trigger.style.pointerEvents).toBe('auto');
});
});
describe('context-menu — right-click-on-trigger guard', () => {
it('does not dismiss the open menu when the trigger is right-clicked again', async () => {
const onUpdateOpen = vi.fn();
mountContextMenu({ onUpdateOpen });
const trigger = getTrigger();
dispatchContextMenu(trigger);
await nextTick();
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('open');
onUpdateOpen.mockClear();
// A right-click (button=2) pointerdown lands "outside" the content but on
// the trigger; the guard must preventDefault so the layer does not dismiss.
trigger.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true,
cancelable: true,
button: 2,
clientX: 100,
clientY: 80,
}));
await nextTick();
await nextTick();
expect(onUpdateOpen).not.toHaveBeenCalledWith(false);
expect(trigger.getAttribute('data-state')).toBe('open');
});
});
describe('context-menu — content positioning', () => {
it('anchors the content to the cursor side/align and exposes trigger-size CSS vars', async () => {
mountContextMenu();
const trigger = getTrigger();
dispatchContextMenu(trigger);
await nextTick();
await nextTick();
const content = document.querySelector('[role="menu"]') as HTMLElement;
expect(content).toBeTruthy();
expect(content.getAttribute('data-side')).toBe('right');
expect(content.getAttribute('data-align')).toBe('start');
expect(content.style.getPropertyValue('--primitives-context-menu-trigger-width')).toBe('var(--popper-anchor-width)');
expect(content.style.getPropertyValue('--primitives-context-menu-trigger-height')).toBe('var(--popper-anchor-height)');
});
});
describe('context-menu — submenu uncontrolled', () => {
function mountWithSub(subProps: Record<string, unknown> = {}) {
const Harness = defineComponent({
setup() {
return () =>
h(ContextMenuRoot, { open: true }, {
default: () => [
h(ContextMenuTrigger, { 'data-testid': 'trigger' }, { default: () => 'Right-click me' }),
h(ContextMenuPortal, null, {
default: () =>
h(ContextMenuContent, null, {
default: () =>
h(ContextMenuSub, subProps, {
default: () => [
h(ContextMenuSubTrigger, { 'data-testid': 'sub-trigger' }, { default: () => 'More' }),
h(ContextMenuPortal, null, {
default: () =>
h(ContextMenuSubContent, null, {
default: () => h(ContextMenuItem, { 'data-testid': 'sub-item' }, { default: () => 'Sub item' }),
}),
}),
],
}),
}),
}),
],
});
},
});
return track(mount(Harness, { attachTo: document.body }));
}
it('renders the submenu open from the start when defaultOpen is set (uncontrolled)', async () => {
mountWithSub({ defaultOpen: true });
await nextTick();
await nextTick();
const subTrigger = document.querySelector('[data-testid="sub-trigger"]') as HTMLElement;
expect(subTrigger).toBeTruthy();
expect(subTrigger.getAttribute('data-state')).toBe('open');
expect(document.querySelector('[data-testid="sub-item"]')).toBeTruthy();
});
it('keeps the submenu closed by default when defaultOpen is omitted', async () => {
mountWithSub();
await nextTick();
await nextTick();
const subTrigger = document.querySelector('[data-testid="sub-trigger"]') as HTMLElement;
expect(subTrigger).toBeTruthy();
expect(subTrigger.getAttribute('data-state')).toBe('closed');
expect(document.querySelector('[data-testid="sub-item"]')).toBeFalsy();
});
});
@@ -0,0 +1,25 @@
import type { Ref } from 'vue';
import type { Direction } from '../../utilities/config-provider';
import { useContextFactory } from '@robonen/vue';
export interface ContextMenuRootContext {
open: Ref<boolean>;
onOpenChange: (open: boolean) => void;
modal: Ref<boolean>;
/** Resolved reading direction (prop on root falls back to the global ConfigProvider dir). */
dir: Ref<Direction>;
/**
* The trigger element captured once mounted. Used by the content's
* interact-outside guard so a right-click on the trigger while the menu is
* open does not dismiss-then-reopen (flicker / lost focus).
*/
triggerElement: Ref<HTMLElement | undefined>;
/** Delay in ms from a touch/pen press until the menu opens (long-press). */
pressOpenDelay: Ref<number>;
}
export const {
inject: useContextMenuRootContext,
provide: provideContextMenuRootContext,
} = useContextFactory<ContextMenuRootContext>('ContextMenuRootContext');
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuItem,
ContextMenuItemIndicator,
ContextMenuLabel,
ContextMenuPortal,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuRoot,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from '@robonen/primitives';
const showGrid = ref(true);
const showRulers = ref(false);
const zoom = ref('100');
const lastAction = ref('Right-click the canvas to open the menu.');
function run(action: string) {
lastAction.value = action;
}
const itemClass = 'flex items-center justify-between gap-6 rounded px-2 py-1.5 text-sm outline-none cursor-default select-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-fg data-[disabled]:opacity-40 data-[disabled]:pointer-events-none';
const contentClass = 'min-w-52 rounded-lg border border-border bg-bg-elevated p-1 text-fg shadow-lg shadow-black/10';
</script>
<template>
<div class="flex flex-col items-center gap-3">
<ContextMenuRoot>
<ContextMenuTrigger
as="div"
class="flex h-44 w-80 items-center justify-center rounded-xl border border-dashed border-border bg-bg-subtle text-sm text-fg-muted select-none"
>
Right-click anywhere in this area
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuContent :class="contentClass">
<ContextMenuItem
:class="itemClass"
@select="run('Cut')"
>
Cut
<span class="text-xs text-fg-subtle">X</span>
</ContextMenuItem>
<ContextMenuItem
:class="itemClass"
@select="run('Copy')"
>
Copy
<span class="text-xs text-fg-subtle">C</span>
</ContextMenuItem>
<ContextMenuItem
:class="itemClass"
disabled
>
Paste
<span class="text-xs text-fg-subtle">V</span>
</ContextMenuItem>
<ContextMenuSeparator class="my-1 h-px bg-border" />
<ContextMenuLabel class="px-2 py-1 text-xs font-medium text-fg-subtle">
View
</ContextMenuLabel>
<ContextMenuCheckboxItem
v-model:checked="showGrid"
:class="itemClass"
>
<span class="flex items-center gap-2">
<span class="flex size-4 items-center justify-center">
<ContextMenuItemIndicator>
<svg
class="size-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</ContextMenuItemIndicator>
</span>
Show grid
</span>
</ContextMenuCheckboxItem>
<ContextMenuCheckboxItem
v-model:checked="showRulers"
:class="itemClass"
>
<span class="flex items-center gap-2">
<span class="flex size-4 items-center justify-center">
<ContextMenuItemIndicator>
<svg
class="size-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</ContextMenuItemIndicator>
</span>
Show rulers
</span>
</ContextMenuCheckboxItem>
<ContextMenuSub>
<ContextMenuSubTrigger
:class="itemClass"
class="data-[state=open]:bg-bg-subtle"
>
Zoom
<svg
class="size-4 text-fg-subtle"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</ContextMenuSubTrigger>
<ContextMenuPortal>
<ContextMenuSubContent
:class="contentClass"
class="min-w-32"
>
<ContextMenuRadioGroup v-model="zoom">
<ContextMenuRadioItem
v-for="level in ['50', '100', '200']"
:key="level"
:value="level"
:class="itemClass"
>
<span class="flex items-center gap-2">
<span class="flex size-4 items-center justify-center">
<ContextMenuItemIndicator>
<span class="size-1.5 rounded-full bg-current" />
</ContextMenuItemIndicator>
</span>
{{ level }}%
</span>
</ContextMenuRadioItem>
</ContextMenuRadioGroup>
</ContextMenuSubContent>
</ContextMenuPortal>
</ContextMenuSub>
<ContextMenuSeparator class="my-1 h-px bg-border" />
<ContextMenuItem
:class="itemClass"
class="text-red-600 data-[highlighted]:bg-red-600 data-[highlighted]:text-white dark:text-red-400"
@select="run('Delete')"
>
Delete
<span class="text-xs opacity-70"></span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenuPortal>
</ContextMenuRoot>
<p class="text-xs text-fg-muted">
{{ lastAction }} · grid {{ showGrid ? 'on' : 'off' }} · zoom {{ zoom }}%
</p>
</div>
</template>
@@ -0,0 +1,17 @@
export { useContextMenuRootContext } from './context';
export { default as ContextMenuArrow, type ContextMenuArrowProps } from './ContextMenuArrow.vue';
export { default as ContextMenuCheckboxItem, type ContextMenuCheckboxItemEmits, type ContextMenuCheckboxItemProps } from './ContextMenuCheckboxItem.vue';
export { default as ContextMenuContent, type ContextMenuContentEmits, type ContextMenuContentProps } from './ContextMenuContent.vue';
export { default as ContextMenuGroup, type ContextMenuGroupProps } from './ContextMenuGroup.vue';
export { default as ContextMenuItem, type ContextMenuItemEmits, type ContextMenuItemProps } from './ContextMenuItem.vue';
export { default as ContextMenuItemIndicator, type ContextMenuItemIndicatorProps } from './ContextMenuItemIndicator.vue';
export { default as ContextMenuLabel, type ContextMenuLabelProps } from './ContextMenuLabel.vue';
export { default as ContextMenuPortal, type ContextMenuPortalProps } from './ContextMenuPortal.vue';
export { default as ContextMenuRadioGroup, type ContextMenuRadioGroupEmits, type ContextMenuRadioGroupProps } from './ContextMenuRadioGroup.vue';
export { default as ContextMenuRadioItem, type ContextMenuRadioItemEmits, type ContextMenuRadioItemProps } from './ContextMenuRadioItem.vue';
export { default as ContextMenuRoot, type ContextMenuRootProps } from './ContextMenuRoot.vue';
export { default as ContextMenuSeparator, type ContextMenuSeparatorProps } from './ContextMenuSeparator.vue';
export { default as ContextMenuSub, type ContextMenuSubEmits, type ContextMenuSubProps } from './ContextMenuSub.vue';
export { default as ContextMenuSubContent, type ContextMenuSubContentEmits, type ContextMenuSubContentProps } from './ContextMenuSubContent.vue';
export { default as ContextMenuSubTrigger, type ContextMenuSubTriggerProps } from './ContextMenuSubTrigger.vue';
export { default as ContextMenuTrigger, type ContextMenuTriggerProps } from './ContextMenuTrigger.vue';
@@ -0,0 +1,21 @@
<script lang="ts">
import type { MenuArrowProps } from '../menu';
/**
* An optional arrow that points from the content back toward the trigger.
* Render it inside the content; it tracks the trigger as the menu repositions.
*/
export interface DropdownMenuArrowProps extends MenuArrowProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { MenuArrow } from '../menu';
const props = defineProps<DropdownMenuArrowProps>();
useForwardExpose();
</script>
<template>
<MenuArrow v-bind="props"><slot /></MenuArrow>
</template>
@@ -0,0 +1,28 @@
<script lang="ts">
import type { MenuCheckboxItemEmits, MenuCheckboxItemProps } from '../menu';
/**
* A menu item that toggles a boolean (or indeterminate) state. Bind
* `v-model:checked` to track the value; pair it with DropdownMenuItemIndicator
* to render a check mark when active.
*/
export interface DropdownMenuCheckboxItemProps extends MenuCheckboxItemProps {}
export type DropdownMenuCheckboxItemEmits = MenuCheckboxItemEmits;
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { MenuCheckboxItem } from '../menu';
const props = defineProps<DropdownMenuCheckboxItemProps>();
const emit = defineEmits<DropdownMenuCheckboxItemEmits>();
useForwardExpose();
</script>
<template>
<MenuCheckboxItem
v-bind="props"
@select="emit('select', $event)"
@update:checked="emit('update:checked', $event)"
><slot /></MenuCheckboxItem>
</template>
@@ -0,0 +1,89 @@
<script lang="ts">
import type { MenuContentEmits, MenuContentProps } from '../menu';
/**
* The floating surface that holds the menu items, positioned relative to the
* trigger. Handles focus management, typeahead, and dismissal on outside click
* or Escape; render it inside a portal so it escapes overflow clipping.
*/
export interface DropdownMenuContentProps extends MenuContentProps {}
export type DropdownMenuContentEmits = MenuContentEmits;
</script>
<script setup lang="ts">
import type { CSSProperties } from 'vue';
import { ref } from 'vue';
import { MenuContent } from '../menu';
import { useDropdownMenuRootContext } from './context';
const props = defineProps<DropdownMenuContentProps>();
const emit = defineEmits<DropdownMenuContentEmits>();
const ddCtx = useDropdownMenuRootContext();
// Tracks whether the menu closed because of an outside / right-click / non-modal
// interaction. When it did, focus must stay where the user pointed instead of
// snapping back to the trigger (which would be jarring and steal the caret).
const hasInteractedOutside = ref(false);
// Map the Popper-computed measurements onto dropdown-scoped CSS custom
// properties so consumers can size/animate the menu relative to its trigger.
const contentStyle: CSSProperties = {
'--primitives-dropdown-menu-content-transform-origin': 'var(--popper-transform-origin)',
'--primitives-dropdown-menu-content-available-width': 'var(--popper-available-width)',
'--primitives-dropdown-menu-content-available-height': 'var(--popper-available-height)',
'--primitives-dropdown-menu-trigger-width': 'var(--popper-anchor-width)',
'--primitives-dropdown-menu-trigger-height': 'var(--popper-anchor-height)',
} as CSSProperties;
function handleCloseAutoFocus(event: Event) {
// Let the consumer opt out of the managed focus return entirely.
emit('closeAutoFocus', event);
if (event.defaultPrevented) return;
if (!hasInteractedOutside.value) {
ddCtx.triggerRef.value?.focus({ preventScroll: true });
}
hasInteractedOutside.value = false;
// Either we refocused the trigger ourselves or the user is interacting
// elsewhere — in both cases the underlying scope's default restore must not run.
event.preventDefault();
}
function handleInteractOutside(event: PointerEvent | MouseEvent | FocusEvent) {
if (!event.defaultPrevented) {
const originalEvent = event as Partial<MouseEvent>;
const ctrlLeftClick = originalEvent.button === 0 && originalEvent.ctrlKey === true;
const isRightClick = originalEvent.button === 2 || ctrlLeftClick;
if (!ddCtx.modal.value || isRightClick) hasInteractedOutside.value = true;
}
emit('interactOutside', event);
}
</script>
<template>
<MenuContent
v-bind="props"
:id="ddCtx.contentId.value"
:aria-labelledby="ddCtx.triggerId.value"
:style="contentStyle"
@close-auto-focus="handleCloseAutoFocus"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="(event: PointerEvent | MouseEvent) => {
const target = event.target as Node
// The trigger owns pointerdown toggling — letting the layer also dismiss
// here would close the menu before the trigger handler runs and make its
// toggle reopen it.
const isTriggerPointerDown = ddCtx.triggerRef.value?.contains(target)
if (isTriggerPointerDown) event.preventDefault()
emit('pointerDownOutside', event)
}"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="handleInteractOutside"
@dismiss="emit('dismiss')"
@entry-focus="emit('entryFocus', $event)"
@open-auto-focus="emit('openAutoFocus', $event)"
>
<slot />
</MenuContent>
</template>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { MenuGroupProps } from '../menu';
/**
* Groups related items so assistive tech announces them together. Pair it with
* a DropdownMenuLabel to give the group an accessible name.
*/
export interface DropdownMenuGroupProps extends MenuGroupProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { MenuGroup } from '../menu';
const props = defineProps<DropdownMenuGroupProps>();
useForwardExpose();
</script>
<template>
<MenuGroup v-bind="props"><slot /></MenuGroup>
</template>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { MenuItemEmits, MenuItemProps } from '../menu';
/**
* A single actionable row in the menu. Emits `select` on click or
* Enter/Space and closes the menu afterwards unless the event is prevented.
*/
export interface DropdownMenuItemProps extends MenuItemProps {}
export type DropdownMenuItemEmits = MenuItemEmits;
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { MenuItem } from '../menu';
const props = defineProps<DropdownMenuItemProps>();
const emit = defineEmits<DropdownMenuItemEmits>();
useForwardExpose();
</script>
<template>
<MenuItem v-bind="props" @select="emit('select', $event)"><slot /></MenuItem>
</template>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { MenuItemIndicatorProps } from '../menu';
/**
* Renders its content only when the enclosing checkbox or radio item is
* checked. Put a check mark or dot inside it as the selection marker.
*/
export interface DropdownMenuItemIndicatorProps extends MenuItemIndicatorProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { MenuItemIndicator } from '../menu';
const props = defineProps<DropdownMenuItemIndicatorProps>();
useForwardExpose();
</script>
<template>
<MenuItemIndicator v-bind="props"><slot /></MenuItemIndicator>
</template>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { MenuLabelProps } from '../menu';
/**
* A non-interactive heading that titles a section of the menu. It is skipped
* by keyboard navigation and is not selectable.
*/
export interface DropdownMenuLabelProps extends MenuLabelProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { MenuLabel } from '../menu';
const props = defineProps<DropdownMenuLabelProps>();
useForwardExpose();
</script>
<template>
<MenuLabel v-bind="props"><slot /></MenuLabel>
</template>
@@ -0,0 +1,19 @@
<script lang="ts">
import type { MenuPortalProps } from '../menu';
/**
* Teleports the menu content into the document body (or a chosen target) so it
* renders above other content and escapes any `overflow: hidden` ancestor.
*/
export interface DropdownMenuPortalProps extends MenuPortalProps {}
</script>
<script setup lang="ts">
import { MenuPortal } from '../menu';
const props = defineProps<DropdownMenuPortalProps>();
</script>
<template>
<MenuPortal v-bind="props"><slot /></MenuPortal>
</template>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { MenuRadioGroupEmits, MenuRadioGroupProps } from '../menu';
/**
* Groups DropdownMenuRadioItems into a single-choice set. Bind `v-model` to
* track the selected item's value across the group.
*/
export interface DropdownMenuRadioGroupProps extends MenuRadioGroupProps {}
export type DropdownMenuRadioGroupEmits = MenuRadioGroupEmits;
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { MenuRadioGroup } from '../menu';
const props = defineProps<DropdownMenuRadioGroupProps>();
const emit = defineEmits<DropdownMenuRadioGroupEmits>();
useForwardExpose();
</script>
<template>
<MenuRadioGroup v-bind="props" @update:model-value="emit('update:modelValue', $event)"><slot /></MenuRadioGroup>
</template>
@@ -0,0 +1,24 @@
<script lang="ts">
import type { MenuRadioItemEmits, MenuRadioItemProps } from '../menu';
/**
* One option within a DropdownMenuRadioGroup. Selecting it sets the group's
* value to this item's `value`; pair it with DropdownMenuItemIndicator to show
* which option is active.
*/
export interface DropdownMenuRadioItemProps extends MenuRadioItemProps {}
export type DropdownMenuRadioItemEmits = MenuRadioItemEmits;
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { MenuRadioItem } from '../menu';
const props = defineProps<DropdownMenuRadioItemProps>();
const emit = defineEmits<DropdownMenuRadioItemEmits>();
useForwardExpose();
</script>
<template>
<MenuRadioItem v-bind="props" @select="emit('select', $event)"><slot /></MenuRadioItem>
</template>
@@ -0,0 +1,75 @@
<script lang="ts">
import type { Direction } from '../../utilities/config-provider';
/**
* A button-triggered menu of actions, opened on click and built on top of Menu,
* so it inherits keyboard navigation, typeahead, nested submenus, and
* checkbox/radio items. Unlike a context menu, it is anchored to a persistent
* trigger button rather than the pointer.
*
* Use it for action menus on a toolbar, an avatar, or a "more" button — settings,
* row actions, account menus, and the like. The root owns open state and provides
* context to every part; bind `v-model:open` (or listen to `update:open`) to
* control or observe whether the menu is open.
*/
export interface DropdownMenuRootProps {
defaultOpen?: boolean;
dir?: Direction;
modal?: boolean;
}
</script>
<script setup lang="ts">
import { ref, shallowRef, toRef } from 'vue';
import { useDirection, useId } from '../../utilities/config-provider';
import { MenuRoot } from '../menu';
import { provideDropdownMenuRootContext } from './context';
const {
defaultOpen = false,
dir: dirProp,
modal = true,
} = defineProps<DropdownMenuRootProps>();
const localOpen = ref<boolean>(defaultOpen);
const open = defineModel<boolean>('open', {
default: undefined,
get: v => v ?? localOpen.value,
set: (v) => {
localOpen.value = v;
return v;
},
});
defineSlots<{ default?: (props: { open: boolean }) => unknown }>();
const dir = useDirection(() => dirProp);
const triggerRef = shallowRef<HTMLElement | null>(null);
const triggerId = useId(undefined, 'dropdown-trigger');
const contentId = useId(undefined, 'dropdown-content');
provideDropdownMenuRootContext({
triggerId,
contentId,
triggerRef,
onTriggerChange: (el) => {
triggerRef.value = el;
},
open,
onOpenChange: (v) => { open.value = v; },
onOpenToggle: () => { open.value = !open.value; },
modal: toRef(() => modal),
dir,
});
</script>
<template>
<MenuRoot
v-model:open="open"
:dir="dir"
:modal="modal"
>
<slot :open="open" />
</MenuRoot>
</template>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { MenuSeparatorProps } from '../menu';
/**
* A horizontal divider used to visually separate groups of items. Decorative
* and skipped by keyboard navigation.
*/
export interface DropdownMenuSeparatorProps extends MenuSeparatorProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { MenuSeparator } from '../menu';
const props = defineProps<DropdownMenuSeparatorProps>();
useForwardExpose();
</script>
<template>
<MenuSeparator v-bind="props"><slot /></MenuSeparator>
</template>
@@ -0,0 +1,41 @@
<script lang="ts">
import type { MenuSubEmits, MenuSubProps } from '../menu';
/**
* Wraps a nested submenu, pairing a DropdownMenuSubTrigger with its
* DropdownMenuSubContent. Owns the submenu's open state; bind `v-model:open`
* to control or observe it, or leave it unbound and set `defaultOpen` to run
* uncontrolled. The default slot also exposes the current `open` value.
*/
export interface DropdownMenuSubProps extends MenuSubProps {
/** The submenu's open state when first rendered, for uncontrolled usage. */
defaultOpen?: boolean;
}
export type DropdownMenuSubEmits = MenuSubEmits;
</script>
<script setup lang="ts">
import { ref } from 'vue';
import { MenuSub } from '../menu';
const { defaultOpen = false } = defineProps<DropdownMenuSubProps>();
defineSlots<{ default?: (props: { open: boolean }) => unknown }>();
const localOpen = ref<boolean>(defaultOpen);
const open = defineModel<boolean>('open', {
default: undefined,
get: v => v ?? localOpen.value,
set: (v) => {
localOpen.value = v;
return v;
},
});
</script>
<template>
<MenuSub v-model:open="open">
<slot :open="open" />
</MenuSub>
</template>
@@ -0,0 +1,44 @@
<script lang="ts">
import type { MenuSubContentEmits, MenuSubContentProps } from '../menu';
/**
* The floating surface for a submenu's items, positioned alongside its
* DropdownMenuSubTrigger. Place it inside a DropdownMenuSub.
*/
export interface DropdownMenuSubContentProps extends MenuSubContentProps {}
export type DropdownMenuSubContentEmits = MenuSubContentEmits;
</script>
<script setup lang="ts">
import type { CSSProperties } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { MenuSubContent } from '../menu';
const props = defineProps<DropdownMenuSubContentProps>();
const emit = defineEmits<DropdownMenuSubContentEmits>();
useForwardExpose();
const contentStyle: CSSProperties = {
'--primitives-dropdown-menu-content-transform-origin': 'var(--popper-transform-origin)',
'--primitives-dropdown-menu-content-available-width': 'var(--popper-available-width)',
'--primitives-dropdown-menu-content-available-height': 'var(--popper-available-height)',
'--primitives-dropdown-menu-trigger-width': 'var(--popper-anchor-width)',
'--primitives-dropdown-menu-trigger-height': 'var(--popper-anchor-height)',
} as CSSProperties;
</script>
<template>
<MenuSubContent
v-bind="props"
:style="contentStyle"
@close-auto-focus="emit('closeAutoFocus', $event)"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
@dismiss="emit('dismiss')"
@entry-focus="emit('entryFocus', $event)"
@open-auto-focus="emit('openAutoFocus', $event)"
><slot /></MenuSubContent>
</template>
@@ -0,0 +1,22 @@
<script lang="ts">
import type { MenuSubTriggerProps } from '../menu';
/**
* The item that opens its submenu on hover or ArrowRight and closes it on
* ArrowLeft. Renders like a regular item but opens DropdownMenuSubContent
* instead of emitting `select`.
*/
export interface DropdownMenuSubTriggerProps extends MenuSubTriggerProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { MenuSubTrigger } from '../menu';
const props = defineProps<DropdownMenuSubTriggerProps>();
useForwardExpose();
</script>
<template>
<MenuSubTrigger v-bind="props"><slot /></MenuSubTrigger>
</template>
@@ -0,0 +1,78 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The button that toggles the menu open on click, Enter, Space, or the arrow
* keys, and serves as the anchor the content is positioned against. Renders a
* `<button>` by default and wires up the `aria-haspopup`/`aria-expanded`
* accessibility attributes.
*/
export interface DropdownMenuTriggerProps extends PrimitiveProps {
disabled?: boolean;
}
</script>
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { MenuAnchor, useMenuContext } from '../menu';
import { Primitive } from '../../internal/primitive';
import { useDropdownMenuRootContext } from './context';
const { disabled = false, as = 'button' } = defineProps<DropdownMenuTriggerProps>();
const menuCtx = useMenuContext();
const ddCtx = useDropdownMenuRootContext();
const { forwardRef, currentElement } = useForwardExpose();
onMounted(() => {
ddCtx.onTriggerChange(currentElement.value ?? null);
});
onUnmounted(() => {
ddCtx.onTriggerChange(null);
});
function handlePointerDown(event: PointerEvent) {
if (disabled) return;
if (event.button !== 0 || event.ctrlKey) return;
// Toggle on the pre-interaction state: DropdownMenuContent prevents the
// dismissable layer from closing on trigger pointerdown, so this handler is
// the single owner of the open state for trigger interactions (otherwise
// dismiss-then-toggle would immediately reopen the menu).
const wasOpen = menuCtx.open.value;
menuCtx.onOpenChange(!wasOpen);
// Prevent trigger focusing when opening so the content can take focus
// without competition.
if (!wasOpen) event.preventDefault();
}
function handleKeyDown(event: KeyboardEvent) {
if (disabled) return;
if (['Enter', ' ', 'ArrowDown', 'ArrowUp'].includes(event.key)) {
event.preventDefault();
menuCtx.onOpenChange(true);
}
}
</script>
<template>
<MenuAnchor as="template">
<Primitive
:ref="forwardRef"
:as="as"
:id="ddCtx.triggerId.value"
:type="as === 'button' ? 'button' : undefined"
aria-haspopup="menu"
:aria-expanded="menuCtx.open.value"
:aria-controls="menuCtx.open.value ? ddCtx.contentId.value : undefined"
:data-state="menuCtx.open.value ? 'open' : 'closed'"
:data-disabled="disabled ? '' : undefined"
:disabled="as === 'button' ? disabled : undefined"
@pointerdown="handlePointerDown"
@keydown="handleKeyDown"
>
<slot />
</Primitive>
</MenuAnchor>
</template>
@@ -0,0 +1,324 @@
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 {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
useDropdownMenuRootContext,
} 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');
});
});
describe('dropdownMenu — trigger forms safety + conditional aria-controls', () => {
it('renders type="button" on the default button trigger to avoid form submission', () => {
mountMenu();
expect(trigger().getAttribute('type')).toBe('button');
});
it('only sets aria-controls while the menu is open (WAI-ARIA menu-button pattern)', async () => {
mountMenu({ modal: false });
expect(trigger().getAttribute('aria-controls')).toBeNull();
expect(trigger().getAttribute('aria-expanded')).toBe('false');
pointerDown(trigger());
await flush();
const controls = trigger().getAttribute('aria-controls');
expect(controls).toBeTruthy();
// aria-controls must point at the content element id when open.
expect(menu()!.getAttribute('id')).toBe(controls);
pointerDown(trigger());
await flush();
expect(trigger().getAttribute('aria-controls')).toBeNull();
});
it('omits type on a non-button trigger element', () => {
const Harness = defineComponent({
setup() {
return () => h(DropdownMenuRoot, { modal: false }, {
default: () => [
h(DropdownMenuTrigger, { as: 'a', 'data-testid': 'trigger' }, { default: () => 'Open' }),
h(DropdownMenuPortal, null, {
default: () => h(DropdownMenuContent, null, { default: () => h(DropdownMenuItem, null, { default: () => 'One' }) }),
}),
],
});
},
});
track(mount(Harness, { attachTo: document.body }));
expect(trigger().tagName).toBe('A');
expect(trigger().getAttribute('type')).toBeNull();
});
});
describe('dropdownMenu — content sizing custom properties', () => {
it('exposes dropdown-scoped CSS variables mapped from the popper anchor measurements', async () => {
mountMenu({ modal: false });
pointerDown(trigger());
await flush();
const style = menu()!.getAttribute('style') ?? '';
expect(style).toContain('--primitives-dropdown-menu-trigger-width: var(--popper-anchor-width)');
expect(style).toContain('--primitives-dropdown-menu-trigger-height: var(--popper-anchor-height)');
expect(style).toContain('--primitives-dropdown-menu-content-transform-origin: var(--popper-transform-origin)');
});
});
describe('dropdownMenu — focus return to trigger on close', () => {
it('returns focus to the trigger when closed via Escape', async () => {
mountMenu({ modal: false });
pointerDown(trigger());
await flush();
expect(menu()).toBeTruthy();
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
await flush();
await flush();
expect(menu()).toBeNull();
expect(document.activeElement).toBe(trigger());
});
it('does not steal focus back to the trigger when the user interacts outside (non-modal)', async () => {
mountMenu({ modal: false });
pointerDown(trigger());
await flush();
expect(menu()).toBeTruthy();
const outside = document.createElement('button');
outside.id = 'outside';
document.body.appendChild(outside);
// A non-modal outside pointerdown dismisses the menu; focus must stay where
// the user pointed rather than snapping back to the trigger.
outside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, composed: true, button: 0, pointerId: 1, pointerType: 'mouse' }));
await flush();
await flush();
expect(menu()).toBeNull();
expect(document.activeElement).not.toBe(trigger());
outside.remove();
});
});
describe('dropdownMenu — Sub controlled / uncontrolled', () => {
function mountWithSub(opts: { defaultOpen?: boolean } = {}) {
const seenOpen = ref<boolean | undefined>(undefined);
const Harness = defineComponent({
setup() {
return () => h(DropdownMenuRoot, { modal: false, defaultOpen: true }, {
default: () => h(DropdownMenuPortal, null, {
default: () => h(DropdownMenuContent, null, {
default: () => h(
DropdownMenuSub,
{ defaultOpen: opts.defaultOpen },
{
default: (slotProps: { open: boolean }) => {
seenOpen.value = slotProps.open;
return [
h(DropdownMenuSubTrigger, { class: 'sub-trigger' }, { default: () => 'More' }),
h(DropdownMenuSubContent, null, {
default: () => h(DropdownMenuItem, { class: 'sub-item' }, { default: () => 'Sub One' }),
}),
];
},
},
),
}),
}),
});
},
});
track(mount(Harness, { attachTo: document.body }));
return { seenOpen };
}
it('exposes the current open value through the default slot', async () => {
const { seenOpen } = mountWithSub({ defaultOpen: false });
await flush();
expect(seenOpen.value).toBe(false);
});
it('honours defaultOpen for uncontrolled submenus (open slot reflects it)', async () => {
const { seenOpen } = mountWithSub({ defaultOpen: true });
await flush();
expect(seenOpen.value).toBe(true);
expect(document.querySelector('.sub-item')).toBeTruthy();
});
});
describe('dropdownMenu — enriched root context', () => {
it('exposes open / onOpenToggle / modal / dir for composition', async () => {
let ctx!: ReturnType<typeof useDropdownMenuRootContext>;
const Consumer = defineComponent({
setup() {
ctx = useDropdownMenuRootContext();
return () => null;
},
});
const Harness = defineComponent({
setup() {
return () => h(DropdownMenuRoot, { modal: false, dir: 'rtl' }, {
default: () => [
h(DropdownMenuTrigger, { 'data-testid': 'trigger' }, { default: () => 'Open' }),
h(Consumer),
],
});
},
});
track(mount(Harness, { attachTo: document.body }));
await flush();
expect(ctx.open.value).toBe(false);
expect(ctx.modal.value).toBe(false);
expect(ctx.dir.value).toBe('rtl');
ctx.onOpenToggle();
await flush();
expect(ctx.open.value).toBe(true);
ctx.onOpenChange(false);
await flush();
expect(ctx.open.value).toBe(false);
});
});
@@ -0,0 +1,26 @@
import type { ComputedRef, Ref, ShallowRef } from 'vue';
import type { Direction } from '../../utilities/config-provider';
import { useContextFactory } from '@robonen/vue';
export interface DropdownMenuRootContext {
triggerId: ComputedRef<string>;
triggerRef: ShallowRef<HTMLElement | null>;
contentId: ComputedRef<string>;
onTriggerChange: (el: HTMLElement | null) => void;
/** Reactive open state, mirrored from the underlying menu, for composition. */
open: Ref<boolean>;
/** Sets the open state explicitly. */
onOpenChange: (open: boolean) => void;
/** Flips the open state. */
onOpenToggle: () => void;
/** Whether the menu blocks interaction with the rest of the page while open. */
modal: Ref<boolean>;
/** Resolved reading direction (`'ltr' | 'rtl'`). */
dir: Ref<Direction>;
}
export const {
inject: useDropdownMenuRootContext,
provide: provideDropdownMenuRootContext,
} = useContextFactory<DropdownMenuRootContext>('DropdownMenuRootContext');
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuItemIndicator,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuRoot,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@robonen/primitives';
const open = ref(false);
const showStatusBar = ref(true);
const showActivityBar = ref(false);
const theme = ref('system');
const lastAction = ref('');
const itemClass = 'group flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm text-fg outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-fg data-[disabled]:pointer-events-none data-[disabled]:opacity-50';
const checkItemClass = `${itemClass} pl-7 relative`;
function run(action: string) {
lastAction.value = action;
}
</script>
<template>
<div class="flex flex-col items-start gap-3 text-fg">
<DropdownMenuRoot v-model:open="open">
<DropdownMenuTrigger
class="group inline-flex items-center gap-2 rounded-md border border-border bg-bg px-3 py-1.5 text-sm font-medium text-fg transition-colors hover:bg-bg-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:bg-bg-subtle"
>
Options
<span
class="i-carbon-chevron-down text-fg-muted transition-transform duration-150 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
:side-offset="6"
align="start"
class="z-50 min-w-56 rounded-lg border border-border bg-bg-elevated p-1 shadow-lg"
>
<DropdownMenuLabel class="px-2 py-1.5 text-xs font-medium text-fg-subtle">
My account
</DropdownMenuLabel>
<DropdownMenuItem :class="itemClass" @select="run('Profile')">
<span class="i-carbon-user" aria-hidden="true" />
Profile
</DropdownMenuItem>
<DropdownMenuItem :class="itemClass" @select="run('Settings')">
<span class="i-carbon-settings" aria-hidden="true" />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator class="my-1 h-px bg-border" />
<DropdownMenuLabel class="px-2 py-1.5 text-xs font-medium text-fg-subtle">
View
</DropdownMenuLabel>
<DropdownMenuCheckboxItem v-model:checked="showStatusBar" :class="checkItemClass">
<DropdownMenuItemIndicator class="absolute left-1.5 inline-flex">
<span class="i-carbon-checkmark text-accent" aria-hidden="true" />
</DropdownMenuItemIndicator>
Status bar
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem v-model:checked="showActivityBar" :class="checkItemClass">
<DropdownMenuItemIndicator class="absolute left-1.5 inline-flex">
<span class="i-carbon-checkmark text-accent" aria-hidden="true" />
</DropdownMenuItemIndicator>
Activity bar
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator class="my-1 h-px bg-border" />
<DropdownMenuLabel class="px-2 py-1.5 text-xs font-medium text-fg-subtle">
Theme
</DropdownMenuLabel>
<DropdownMenuRadioGroup v-model="theme">
<DropdownMenuRadioItem value="light" :class="checkItemClass">
<DropdownMenuItemIndicator class="absolute left-1.5 inline-flex">
<span class="i-carbon-dot-mark text-accent" aria-hidden="true" />
</DropdownMenuItemIndicator>
Light
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark" :class="checkItemClass">
<DropdownMenuItemIndicator class="absolute left-1.5 inline-flex">
<span class="i-carbon-dot-mark text-accent" aria-hidden="true" />
</DropdownMenuItemIndicator>
Dark
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="system" :class="checkItemClass">
<DropdownMenuItemIndicator class="absolute left-1.5 inline-flex">
<span class="i-carbon-dot-mark text-accent" aria-hidden="true" />
</DropdownMenuItemIndicator>
System
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator class="my-1 h-px bg-border" />
<DropdownMenuItem
class="group flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm text-red-600 outline-none data-[highlighted]:bg-red-600 data-[highlighted]:text-white dark:text-red-400"
@select="run('Sign out')"
>
<span class="i-carbon-logout" aria-hidden="true" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
<p class="text-xs text-fg-muted">
Theme: <span class="font-medium text-fg">{{ theme }}</span>
&middot; Status bar: <span class="font-medium text-fg">{{ showStatusBar ? 'on' : 'off' }}</span>
&middot; Activity bar: <span class="font-medium text-fg">{{ showActivityBar ? 'on' : 'off' }}</span>
<template v-if="lastAction">
&middot; Last action: <span class="font-medium text-fg">{{ lastAction }}</span>
</template>
</p>
</div>
</template>
@@ -0,0 +1,17 @@
export { useDropdownMenuRootContext } from './context';
export { default as DropdownMenuArrow, type DropdownMenuArrowProps } from './DropdownMenuArrow.vue';
export { default as DropdownMenuCheckboxItem, type DropdownMenuCheckboxItemEmits, type DropdownMenuCheckboxItemProps } from './DropdownMenuCheckboxItem.vue';
export { default as DropdownMenuContent, type DropdownMenuContentEmits, type DropdownMenuContentProps } from './DropdownMenuContent.vue';
export { default as DropdownMenuGroup, type DropdownMenuGroupProps } from './DropdownMenuGroup.vue';
export { default as DropdownMenuItem, type DropdownMenuItemEmits, type DropdownMenuItemProps } from './DropdownMenuItem.vue';
export { default as DropdownMenuItemIndicator, type DropdownMenuItemIndicatorProps } from './DropdownMenuItemIndicator.vue';
export { default as DropdownMenuLabel, type DropdownMenuLabelProps } from './DropdownMenuLabel.vue';
export { default as DropdownMenuPortal, type DropdownMenuPortalProps } from './DropdownMenuPortal.vue';
export { default as DropdownMenuRadioGroup, type DropdownMenuRadioGroupEmits, type DropdownMenuRadioGroupProps } from './DropdownMenuRadioGroup.vue';
export { default as DropdownMenuRadioItem, type DropdownMenuRadioItemEmits, type DropdownMenuRadioItemProps } from './DropdownMenuRadioItem.vue';
export { default as DropdownMenuRoot, type DropdownMenuRootProps } from './DropdownMenuRoot.vue';
export { default as DropdownMenuSeparator, type DropdownMenuSeparatorProps } from './DropdownMenuSeparator.vue';
export { default as DropdownMenuSub, type DropdownMenuSubEmits, type DropdownMenuSubProps } from './DropdownMenuSub.vue';
export { default as DropdownMenuSubContent, type DropdownMenuSubContentEmits, type DropdownMenuSubContentProps } from './DropdownMenuSubContent.vue';
export { default as DropdownMenuSubTrigger, type DropdownMenuSubTriggerProps } from './DropdownMenuSubTrigger.vue';
export { default as DropdownMenuTrigger, type DropdownMenuTriggerProps } from './DropdownMenuTrigger.vue';
@@ -0,0 +1,23 @@
<script lang="ts">
import type { PopperAnchorProps } from '../../overlays/popper';
/**
* An optional positioning anchor for the menu content. Render it around the
* element the menu should attach to when the open trigger is not itself the
* anchor (for example, anchoring a dropdown to a virtual element or the
* pointer). When omitted, content is positioned relative to its trigger.
*/
export interface MenuAnchorProps extends PopperAnchorProps {}
</script>
<script setup lang="ts">
import { PopperAnchor } from '../../overlays/popper';
const props = defineProps<MenuAnchorProps>();
</script>
<template>
<PopperAnchor v-bind="props">
<slot />
</PopperAnchor>
</template>
@@ -0,0 +1,22 @@
<script lang="ts">
import type { PopperArrowProps } from '../../overlays/popper';
/**
* An optional arrow that renders inside the menu content and points back toward
* the anchor, visually tying the floating menu to its trigger. Place it as a
* child of the content.
*/
export interface MenuArrowProps extends PopperArrowProps {}
</script>
<script setup lang="ts">
import { PopperArrow } from '../../overlays/popper';
const props = defineProps<MenuArrowProps>();
</script>
<template>
<PopperArrow v-bind="props">
<slot />
</PopperArrow>
</template>
@@ -0,0 +1,68 @@
<script lang="ts">
import type { MenuItemImplEmits, MenuItemImplProps } from './MenuItemImpl.vue';
import type { CheckedState } from './types';
/**
* A menu item that toggles a boolean (or indeterminate) state when selected,
* rendering with `role="menuitemcheckbox"`. Pair it with MenuItemIndicator to
* show a check mark. Bind `v-model:checked` to control its state, or leave it
* uncontrolled with `defaultChecked`.
*/
export interface MenuCheckboxItemProps extends MenuItemImplProps {
/** The controlled checked state. Use together with `update:checked`; may be `'indeterminate'`. */
checked?: CheckedState;
/** The checked state when uncontrolled. */
defaultChecked?: CheckedState;
}
export interface MenuCheckboxItemEmits extends MenuItemImplEmits {
'update:checked': [value: CheckedState];
}
</script>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { provideMenuItemIndicatorContext, useMenuRootContext } from './context';
import MenuItemImpl from './MenuItemImpl.vue';
import { ITEM_SELECT, getCheckedState, isIndeterminate } from './utils';
const {
checked: checkedProp,
defaultChecked = false,
...itemProps
} = defineProps<MenuCheckboxItemProps>();
const emit = defineEmits<MenuCheckboxItemEmits>();
defineSlots<{ default?: (props: { checked: CheckedState }) => unknown }>();
const rootCtx = useMenuRootContext();
const local = ref<CheckedState>(defaultChecked);
const checkedState = computed<CheckedState>(() => checkedProp !== undefined ? checkedProp : local.value);
provideMenuItemIndicatorContext({ checkedState });
function handleSelect(event: Event) {
const next: CheckedState = isIndeterminate(checkedState.value) ? true : !checkedState.value;
local.value = next;
emit('update:checked', next);
const target = event.currentTarget as HTMLElement;
const selectEvent = new CustomEvent(ITEM_SELECT, { bubbles: true, cancelable: true });
// Emit the cancelable ITEM_SELECT event so `@select` preventDefault works.
target.addEventListener(ITEM_SELECT, e => emit('select', e), { once: true });
target.dispatchEvent(selectEvent);
if (!selectEvent.defaultPrevented) rootCtx.onClose();
}
</script>
<template>
<MenuItemImpl
v-bind="itemProps"
role="menuitemcheckbox"
:aria-checked="isIndeterminate(checkedState) ? 'mixed' : checkedState"
:data-state="getCheckedState(checkedState)"
@select="handleSelect"
>
<slot :checked="checkedState" />
</MenuItemImpl>
</template>
@@ -0,0 +1,64 @@
<script lang="ts">
import type { MenuContentImplEmits, MenuContentImplProps } from './MenuContentImpl.vue';
/**
* The popup surface that holds the menu items. It mounts only while the menu is
* open (gated by Presence), positions itself via Popper, and switches between
* modal and non-modal behaviour based on the root's `modal` prop. Place items,
* groups, labels, separators, and submenus inside it.
*
* Set `forceMount` to keep it in the DOM when open state is driven externally
* (for example, to run exit animations).
*/
export interface MenuContentProps extends MenuContentImplProps {
/** Force mounting the content even when closed, e.g. to control presence with an external animation library. */
forceMount?: boolean;
}
export type MenuContentEmits = MenuContentImplEmits;
</script>
<script setup lang="ts">
import { Presence } from '../../utilities/presence';
import { useMenuContext, useMenuRootContext } from './context';
import MenuRootContentModal from './MenuRootContentModal.vue';
import MenuRootContentNonModal from './MenuRootContentNonModal.vue';
const { forceMount = false, ...contentProps } = defineProps<MenuContentProps>();
const emit = defineEmits<MenuContentEmits>();
const menuCtx = useMenuContext();
const rootCtx = useMenuRootContext();
</script>
<template>
<Presence :present="forceMount || menuCtx.open.value">
<MenuRootContentModal
v-if="rootCtx.modal.value"
v-bind="contentProps"
@close-auto-focus="emit('closeAutoFocus', $event)"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
@dismiss="emit('dismiss')"
@entry-focus="emit('entryFocus', $event)"
@open-auto-focus="emit('openAutoFocus', $event)"
>
<slot />
</MenuRootContentModal>
<MenuRootContentNonModal
v-else
v-bind="contentProps"
@close-auto-focus="emit('closeAutoFocus', $event)"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
@dismiss="emit('dismiss')"
@entry-focus="emit('entryFocus', $event)"
@open-auto-focus="emit('openAutoFocus', $event)"
>
<slot />
</MenuRootContentNonModal>
</Presence>
</template>
@@ -0,0 +1,238 @@
<script lang="ts">
import type { PopperContentProps } from '../../overlays/popper';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Internal shared implementation behind MenuContent and MenuSubContent. It
* composes Popper positioning, FocusScope, DismissableLayer, and a vertical
* RovingFocusGroup, and adds typeahead search and pointer grace-area handling.
* Not meant to be used directly — render MenuContent (or MenuSubContent) instead.
*/
export interface MenuContentImplProps extends PrimitiveProps, Pick<PopperContentProps,
| 'side' | 'sideOffset' | 'sideFlip' | 'align' | 'alignOffset' | 'alignFlip'
| 'avoidCollisions' | 'collisionBoundary' | 'collisionPadding' | 'arrowPadding'
| 'sticky' | 'hideWhenDetached' | 'positionStrategy' | 'updatePositionStrategy'
| 'reference' | 'prioritizePosition'
> {
/** Whether keyboard focus should wrap from the last item back to the first (and vice versa). */
loop?: boolean;
/** Whether to trap focus inside the content while open (used for modal menus). */
trapFocus?: boolean;
/** Whether to block pointer events on everything outside the content (used for modal menus). */
disableOutsidePointerEvents?: boolean;
}
export interface MenuContentImplEmits {
closeAutoFocus: [event: Event];
escapeKeyDown: [event: KeyboardEvent];
pointerDownOutside: [event: PointerEvent | MouseEvent];
focusOutside: [event: FocusEvent];
interactOutside: [event: PointerEvent | MouseEvent | FocusEvent];
dismiss: [];
entryFocus: [event: Event];
openAutoFocus: [event: Event];
}
// Static CSS-variable bridge from Popper's exported vars to the menu-content
// namespace. Hoisted to module scope so a stable reference is bound every
// render (avoids per-render object allocation; the style values never change).
const CONTENT_STYLE = {
'--primitives-menu-content-transform-origin': 'var(--popper-transform-origin)',
'--primitives-menu-content-available-width': 'var(--popper-available-width)',
'--primitives-menu-content-available-height': 'var(--popper-available-height)',
} as const;
</script>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { DismissableLayer } from '../../utilities/dismissable-layer';
import { FocusScope } from '../../utilities/focus-scope';
import { PopperContent } from '../../overlays/popper';
import { RovingFocusGroup } from '../../utilities/roving-focus';
import { refAutoReset, useForwardExpose } from '@robonen/vue';
import { provideMenuContentContext, provideMenuItemSelectContext, useMenuContext, useMenuRootContext } from './context';
import type { GraceIntent, Side } from './utils';
import { FIRST_LAST_KEYS, LAST_KEYS, focusFirst, getNextMatch, getOpenState, isMousePointer, isPointerInGraceArea } from './utils';
const {
loop = false,
trapFocus = false,
disableOutsidePointerEvents = false,
side = 'bottom',
sideOffset = 0,
align = 'start',
as = 'div',
...popperProps
} = defineProps<MenuContentImplProps>();
const emit = defineEmits<MenuContentImplEmits>();
const menuCtx = useMenuContext();
const rootCtx = useMenuRootContext();
const { forwardRef, currentElement: contentElement } = useForwardExpose();
// Typeahead buffer that auto-clears 1s after the last keystroke — each write
// restarts the idle timer (and it tears down on scope dispose). Mirrors the
// Menubar/Select typeahead.
const searchRef = refAutoReset('', 1000);
const pointerGraceTimerRef = ref<number>(0);
const pointerGraceIntentRef = ref<GraceIntent | null>(null);
// Track which way the pointer is travelling so the grace area only keeps the
// submenu open when the cursor is heading *toward* it (a downward/sideways
// drift back over the parent items must still close it).
const pointerDirRef = ref<Side>('right');
const lastPointerXRef = ref(0);
function isPointerMovingToSubmenu(event: PointerEvent): boolean {
const isMovingTowards = pointerDirRef.value === pointerGraceIntentRef.value?.side;
return isMovingTowards && isPointerInGraceArea(event, pointerGraceIntentRef.value?.area);
}
function handlePointerMove(event: PointerEvent) {
if (!isMousePointer(event)) return;
const target = event.target as HTMLElement;
const pointerXHasChanged = lastPointerXRef.value !== event.clientX;
// Safari always reports `movementX === 0`, so compare clientX ourselves.
if ((event.currentTarget as HTMLElement)?.contains(target) && pointerXHasChanged) {
pointerDirRef.value = event.clientX > lastPointerXRef.value ? 'right' : 'left';
lastPointerXRef.value = event.clientX;
}
}
provideMenuContentContext({
onItemEnter: (event) => {
return isPointerMovingToSubmenu(event);
},
onItemLeave: (event) => {
if (isPointerMovingToSubmenu(event)) return;
contentElement.value?.focus({ preventScroll: true });
},
onTriggerLeave: (event) => {
return isPointerMovingToSubmenu(event);
},
searchRef,
pointerGraceTimerRef,
onPointerGraceIntentChange: (intent) => {
pointerGraceIntentRef.value = intent;
},
});
// Exposed to selectable items so Space can extend type-ahead instead of
// activating the focused item while the user is mid-search.
const isTypingAhead = computed(() => searchRef.value !== '');
provideMenuItemSelectContext({ isTypingAhead });
function handleMountAutoFocus(event: Event) {
event.preventDefault();
// Always focus the content so key events reach the menu even after a
// pointer-open; entryFocus decides whether the first item gets focus.
contentElement.value?.focus({ preventScroll: true });
emit('openAutoFocus', event);
}
function handleKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented) return;
const target = event.target as HTMLElement;
// Submenu key events bubble up through portals; only act on keys that
// originate inside *this* content, not a nested submenu's.
const isKeyDownInside = target.closest('[data-primitives-menu-content]') === event.currentTarget;
// Don't hijack typing inside an embedded input/textarea (e.g. a filter field).
const isKeyDownInTextField = ['input', 'textarea'].includes(target.tagName.toLowerCase());
// Menus must not be exited via Tab — keep focus inside (ARIA menu pattern).
if (isKeyDownInside && event.key === 'Tab') event.preventDefault();
const isCharKey = event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey;
// Space drives selection (handled by the item), never typeahead.
if (isCharKey && event.key !== ' ' && isKeyDownInside && !isKeyDownInTextField) {
searchRef.value += event.key;
const content = contentElement.value;
if (!content) return;
const items = Array.from(
content.querySelectorAll<HTMLElement>('[data-primitives-menu-item]:not([data-disabled])'),
);
const currentItem = content.querySelector<HTMLElement>('[data-primitives-menu-item][data-highlighted]');
const match = getNextMatch(items, searchRef.value, currentItem);
if (match) match.focus({ preventScroll: true });
event.stopPropagation();
}
if (FIRST_LAST_KEYS.includes(event.key)) {
event.stopPropagation();
// While the content itself is focused (e.g. right after a pointer-open),
// arrow/Home/End must move focus into the items.
const content = contentElement.value;
if (content && event.target === content) {
event.preventDefault();
const items = Array.from(
content.querySelectorAll<HTMLElement>('[data-primitives-menu-item]:not([data-disabled])'),
);
if (LAST_KEYS.includes(event.key)) items.reverse();
focusFirst(items);
}
}
}
function handleBlur(event: FocusEvent) {
const content = contentElement.value;
if (!content) return;
if (!content.contains(event.relatedTarget as Node)) {
searchRef.value = '';
}
}
</script>
<template>
<FocusScope
as="template"
:trapped="trapFocus"
:loop="loop"
@mount-auto-focus="handleMountAutoFocus"
@unmount-auto-focus="emit('closeAutoFocus', $event)"
>
<DismissableLayer
as="template"
:disable-outside-pointer-events="disableOutsidePointerEvents"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
@dismiss="emit('dismiss')"
>
<RovingFocusGroup
as="template"
orientation="vertical"
:dir="rootCtx.dir.value"
:loop="loop"
@entry-focus="(event: Event) => {
emit('entryFocus', event)
if (!rootCtx.isUsingKeyboardRef.value) event.preventDefault()
}"
>
<PopperContent
:ref="forwardRef"
:as="as"
role="menu"
aria-orientation="vertical"
data-primitives-menu-content=""
:data-state="getOpenState(menuCtx.open.value)"
:dir="rootCtx.dir.value"
:side="side"
:side-offset="sideOffset"
:align="align"
:style="CONTENT_STYLE"
v-bind="popperProps"
@keydown="handleKeyDown"
@blur="handleBlur"
@pointermove="handlePointerMove"
>
<slot />
</PopperContent>
</RovingFocusGroup>
</DismissableLayer>
</FocusScope>
</template>
@@ -0,0 +1,25 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Groups a set of related menu items under `role="group"` for accessibility.
* Combine it with a MenuLabel to give the group an accessible name.
*/
export interface MenuGroupProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { useId } from '../../utilities/config-provider';
import { Primitive } from '../../internal/primitive';
import { provideMenuGroupContext } from './context';
const { as = 'div' } = defineProps<MenuGroupProps>();
const id = useId(undefined, 'menu-group');
provideMenuGroupContext({ id: id.value });
</script>
<template>
<Primitive :as="as" role="group" :aria-labelledby="id">
<slot />
</Primitive>
</template>
@@ -0,0 +1,64 @@
<script lang="ts">
import type { MenuItemImplEmits, MenuItemImplProps } from './MenuItemImpl.vue';
/**
* A single actionable menu item that emits `select` and closes the menu when
* activated by click, Enter, or Space. Use it for ordinary commands; call
* `event.preventDefault()` in `select` to keep the menu open after selection.
*/
export interface MenuItemProps extends MenuItemImplProps {}
export type MenuItemEmits = MenuItemImplEmits;
</script>
<script setup lang="ts">
import { ref } from 'vue';
import { useMenuRootContext } from './context';
import MenuItemImpl from './MenuItemImpl.vue';
import { ITEM_SELECT } from './utils';
const props = defineProps<MenuItemProps>();
const emit = defineEmits<MenuItemEmits>();
const rootCtx = useMenuRootContext();
// Tracks whether the pointer went down on *this* item. If it went down
// elsewhere and is released over this item (drag-select), we synthesise a click
// so the item still activates — this also avoids Firefox getting stuck in text
// selection when the menu closes.
const isPointerDownRef = ref(false);
function handleSelect(event: Event) {
const target = event.currentTarget as HTMLElement;
const selectEvent = new CustomEvent(ITEM_SELECT, { bubbles: true, cancelable: true });
// The consumer must receive the cancelable ITEM_SELECT event (not the click)
// so `event.preventDefault()` in `@select` actually keeps the menu open.
target.addEventListener(ITEM_SELECT, e => emit('select', e), { once: true });
target.dispatchEvent(selectEvent);
if (!selectEvent.defaultPrevented) {
rootCtx.onClose();
}
}
function handlePointerDown() {
isPointerDownRef.value = true;
}
function handlePointerUp(event: PointerEvent) {
if (event.defaultPrevented) return;
// Pointer started on another item then released here: activate via click.
if (!isPointerDownRef.value) (event.currentTarget as HTMLElement)?.click();
isPointerDownRef.value = false;
}
</script>
<template>
<MenuItemImpl
v-bind="props"
@select="handleSelect"
@pointerdown="handlePointerDown"
@pointerup="handlePointerUp"
>
<slot />
</MenuItemImpl>
</template>
@@ -0,0 +1,118 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Internal base for every selectable menu item (MenuItem, MenuCheckboxItem,
* MenuRadioItem, MenuSubTrigger). It wires up roving-focus, pointer
* highlighting, disabled state, and typeahead `textValue`, and emits `select`.
* Not used directly — render one of the public item parts instead.
*/
export interface MenuItemImplProps extends PrimitiveProps {
/** Whether the item is disabled, removing it from focus and selection. */
disabled?: boolean;
/** Optional text used to match the item during typeahead; defaults to the item's trimmed text content. */
textValue?: string;
}
export interface MenuItemImplEmits {
select: [event: Event];
}
</script>
<script setup lang="ts">
import { computed, ref, shallowRef } from 'vue';
import { RovingFocusItem } from '../../utilities/roving-focus';
import { Primitive } from '../../internal/primitive';
import { useMenuContentContext, useMenuItemSelectContext } from './context';
const {
disabled = false,
textValue: textValueProp,
as = 'div',
} = defineProps<MenuItemImplProps>();
const emit = defineEmits<MenuItemImplEmits>();
const contentCtx = useMenuContentContext();
// Optional: present inside a menu content, absent if an item is rendered
// standalone in tests. Falls back to a never-typing stub.
const selectCtx = useMenuItemSelectContext({ isTypingAhead: shallowRef(false) });
const itemRef = shallowRef<HTMLElement | null>(null);
// Stable ref setter: a fresh inline arrow per render would force Vue to tear
// down (call with null) and re-bind the ref on every item patch.
const setItemRef = (el: unknown) => {
itemRef.value = el as HTMLElement | null;
};
const isHighlighted = ref(false);
const textValue = computed(() => textValueProp ?? itemRef.value?.textContent?.trim() ?? '');
function handlePointerMove(event: PointerEvent) {
if (event.pointerType === 'touch') return;
if (disabled) return;
if (contentCtx.onItemEnter(event)) return;
const item = event.currentTarget as HTMLElement;
item.focus({ preventScroll: true });
}
function handlePointerLeave(event: PointerEvent) {
if (event.pointerType === 'touch') return;
if (disabled) return;
contentCtx.onItemLeave(event);
}
function handleFocus() {
isHighlighted.value = true;
}
function handleBlur() {
isHighlighted.value = false;
}
function handleClick(event: MouseEvent) {
if (disabled) return;
emit('select', event);
}
function handleKeyDown(event: KeyboardEvent) {
if (disabled) return;
// While typing ahead, Space extends the search rather than selecting.
if (selectCtx.isTypingAhead.value && event.key === ' ') return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
const el = event.currentTarget as HTMLElement;
el.click();
}
}
// RovingFocusItem renders as="template" so its tab stop (tabindex, focus and
// keydown handlers, collection registration) merges onto the menu-item element
// itself — a real wrapper element would split focus handling across two nodes.
// NB: the template must stay single-root with no top-level comments; consumers
// resolve this component's element via `$el`/functional refs, and a dev-mode
// fragment root would point them at the fragment anchor instead.
</script>
<template>
<RovingFocusItem as="template" :focusable="!disabled" :active="isHighlighted">
<Primitive
:ref="setItemRef"
:as="as"
role="menuitem"
data-primitives-menu-item=""
:data-primitive-menu-item-text-value="textValue"
:data-highlighted="isHighlighted ? '' : undefined"
:data-disabled="disabled ? '' : undefined"
:aria-disabled="disabled || undefined"
@pointermove="handlePointerMove"
@pointerleave="handlePointerLeave"
@focus="handleFocus"
@blur="handleBlur"
@click="handleClick"
@keydown="handleKeyDown"
>
<slot />
</Primitive>
</RovingFocusItem>
</template>
@@ -0,0 +1,37 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Renders only while its parent MenuCheckboxItem or MenuRadioItem is checked (or
* indeterminate), giving you a place to show a check or dot icon. Must be nested
* inside a checkbox or radio item, which provides its state via context.
*/
export interface MenuItemIndicatorProps extends PrimitiveProps {
/** Force mounting the indicator even when unchecked, e.g. to control presence with an external animation library. */
forceMount?: boolean;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { Presence } from '../../utilities/presence';
import { Primitive } from '../../internal/primitive';
import { useMenuItemIndicatorContext } from './context';
import { getCheckedState, isIndeterminate } from './utils';
const { as = 'span', forceMount = false } = defineProps<MenuItemIndicatorProps>();
const ctx = useMenuItemIndicatorContext();
const isPresent = computed(() => ctx.checkedState.value === true || isIndeterminate(ctx.checkedState.value));
</script>
<template>
<Presence :present="forceMount || isPresent">
<Primitive
:as="as"
:data-state="getCheckedState(ctx.checkedState.value)"
>
<slot />
</Primitive>
</Presence>
</template>
@@ -0,0 +1,27 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A non-interactive heading for a section of the menu. It is skipped by keyboard
* navigation and typeahead, so use it to title a group of items rather than as a
* selectable entry.
*/
export interface MenuLabelProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { useMenuGroupContext } from './context';
const { as = 'div' } = defineProps<MenuLabelProps>();
// When rendered inside a MenuGroup, adopt the group's id so the group can point
// `aria-labelledby` at this label. Falls back to no id when used standalone.
const groupCtx = useMenuGroupContext({ id: '' });
</script>
<template>
<Primitive :as="as" :id="groupCtx.id || undefined">
<slot />
</Primitive>
</template>
@@ -0,0 +1,22 @@
<script lang="ts">
import type { PortalProps } from '../../utilities/teleport';
/**
* Teleports the menu content into a different part of the DOM (the document body
* by default) so it escapes ancestor `overflow` and stacking-context clipping.
* Wrap the content in this part to render it as a top-level overlay.
*/
export interface MenuPortalProps extends PortalProps {}
</script>
<script setup lang="ts">
import { Portal } from '../../utilities/teleport';
const { to, defer, disabled } = defineProps<MenuPortalProps>();
</script>
<template>
<Portal :to="to" :defer="defer" :disabled="disabled">
<slot />
</Portal>
</template>
@@ -0,0 +1,49 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
import type { AcceptableValue } from './utils';
/**
* Wraps a set of MenuRadioItems so that only one can be selected at a time,
* managing the shared selected value. Bind `v-model` to control the selection,
* or supply `defaultValue` to leave it uncontrolled. Values may be any
* serialisable type (string / number / boolean / object), not just strings.
* Renders through MenuGroup so a nested MenuLabel labels the group.
*/
export interface MenuRadioGroupProps extends PrimitiveProps {
/** The controlled selected value. Use together with `update:modelValue`. */
modelValue?: AcceptableValue;
/** The selected value when uncontrolled. */
defaultValue?: AcceptableValue;
}
export interface MenuRadioGroupEmits {
'update:modelValue': [value: AcceptableValue];
}
</script>
<script setup lang="ts">
import { computed, shallowRef } from 'vue';
import MenuGroup from './MenuGroup.vue';
import { provideMenuRadioGroupContext } from './context';
const { modelValue, defaultValue, as = 'div' } = defineProps<MenuRadioGroupProps>();
const emit = defineEmits<MenuRadioGroupEmits>();
defineSlots<{ default?: (props: { modelValue: AcceptableValue | undefined }) => unknown }>();
const local = shallowRef(defaultValue);
const value = computed(() => modelValue !== undefined ? modelValue : local.value);
provideMenuRadioGroupContext({
modelValue: value,
onValueChange: (v) => {
local.value = v;
emit('update:modelValue', v);
},
});
</script>
<template>
<MenuGroup :as="as">
<slot :model-value="value" />
</MenuGroup>
</template>
@@ -0,0 +1,58 @@
<script lang="ts">
import type { MenuItemImplEmits, MenuItemImplProps } from './MenuItemImpl.vue';
import type { AcceptableValue } from './utils';
/**
* A mutually-exclusive menu item rendered with `role="menuitemradio"`. Selecting
* it sets the enclosing MenuRadioGroup's value to this item's `value`. Pair it
* with MenuItemIndicator to show which option is active. `value` may be any
* serialisable type, compared structurally to the group's selected value. Must
* be used inside a MenuRadioGroup.
*/
export interface MenuRadioItemProps extends MenuItemImplProps {
/** The unique value this item represents within its MenuRadioGroup. */
value: AcceptableValue;
}
export type MenuRadioItemEmits = MenuItemImplEmits;
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { compare } from '../../internal/utils/compare-values';
import { provideMenuItemIndicatorContext, useMenuRadioGroupContext, useMenuRootContext } from './context';
import MenuItemImpl from './MenuItemImpl.vue';
import { ITEM_SELECT, getCheckedState } from './utils';
const { value, ...itemProps } = defineProps<MenuRadioItemProps>();
const emit = defineEmits<MenuRadioItemEmits>();
defineSlots<{ default?: (props: { checked: boolean }) => unknown }>();
const radioCtx = useMenuRadioGroupContext();
const rootCtx = useMenuRootContext();
const checkedState = computed(() => compare(radioCtx.modelValue.value, value));
provideMenuItemIndicatorContext({ checkedState });
function handleSelect(event: Event) {
radioCtx.onValueChange(value);
const target = event.currentTarget as HTMLElement;
const selectEvent = new CustomEvent(ITEM_SELECT, { bubbles: true, cancelable: true });
// Emit the cancelable ITEM_SELECT event so `@select` preventDefault works.
target.addEventListener(ITEM_SELECT, e => emit('select', e), { once: true });
target.dispatchEvent(selectEvent);
if (!selectEvent.defaultPrevented) rootCtx.onClose();
}
</script>
<template>
<MenuItemImpl
v-bind="itemProps"
role="menuitemradio"
:aria-checked="checkedState"
:data-state="getCheckedState(checkedState)"
@select="handleSelect"
>
<slot :checked="checkedState" />
</MenuItemImpl>
</template>
@@ -0,0 +1,63 @@
<script lang="ts">
import type { Direction } from '../../utilities/config-provider';
/**
* The unstyled, low-level menu engine that powers DropdownMenu, ContextMenu, and
* Menubar. It is built on Popper and wires up roving-focus keyboard navigation,
* typeahead, nested submenus, checkbox/radio items, and modal vs. non-modal
* dismissal — but it is deliberately trigger-agnostic, so consumers supply their
* own anchor and open logic.
*
* Use this directly only when composing a new menu-like primitive; for ordinary
* app menus reach for DropdownMenu or ContextMenu instead. MenuRoot owns open
* state and provides context to every part; bind `v-model:open` (or listen to
* `update:open`) to control or observe whether the menu is open.
*/
export interface MenuRootProps {
dir?: Direction;
modal?: boolean;
}
</script>
<script setup lang="ts">
import { shallowRef, toRef } from 'vue';
import { useConfig } from '../../utilities/config-provider';
import { PopperRoot } from '../../overlays/popper';
import { provideMenuContext, provideMenuRootContext } from './context';
import { useIsUsingKeyboard } from './useIsUsingKeyboard';
const {
dir: dirProp,
modal = true,
} = defineProps<MenuRootProps>();
const open = defineModel<boolean>('open', { default: false });
defineSlots<{ default?: () => unknown }>();
const config = useConfig();
const dirRef = toRef(() => dirProp ?? config.dir.value);
const isUsingKeyboardRef = useIsUsingKeyboard();
const content = shallowRef<HTMLElement | null>(null);
provideMenuContext({
open,
onOpenChange: (v) => { open.value = v; },
content,
onContentChange: (el) => { content.value = el; },
});
provideMenuRootContext({
onClose: () => { open.value = false; },
dir: dirRef,
isUsingKeyboardRef,
modal: toRef(() => modal),
});
</script>
<template>
<PopperRoot>
<slot />
</PopperRoot>
</template>
@@ -0,0 +1,44 @@
<script setup lang="ts">
// Internal modal variant of the menu content: traps focus, disables outside
// pointer events, locks body scroll, and hides sibling content from assistive
// tech. Selected by MenuContent when the root's `modal` prop is true.
import type { MenuContentImplEmits, MenuContentImplProps } from './MenuContentImpl.vue';
import { shallowRef, watchEffect } from 'vue';
import { useBodyScrollLock, useFocusGuard } from '@robonen/vue';
import { useHideOthers } from '../../internal/utils/useHideOthers';
import MenuContentImpl from './MenuContentImpl.vue';
import { useMenuContext } from './context';
const props = defineProps<MenuContentImplProps>();
const emit = defineEmits<MenuContentImplEmits>();
const menuCtx = useMenuContext();
const contentRef = shallowRef<HTMLElement | null>(null);
watchEffect(() => menuCtx.onContentChange(contentRef.value));
useFocusGuard();
useBodyScrollLock();
useHideOthers(contentRef);
</script>
<template>
<MenuContentImpl
v-bind="props"
:ref="(comp: any) => { contentRef = comp?.$el ?? null }"
:trap-focus="menuCtx.open.value"
:disable-outside-pointer-events="menuCtx.open.value"
@close-auto-focus="emit('closeAutoFocus', $event)"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside.prevent="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
@dismiss="() => { menuCtx.onOpenChange(false); emit('dismiss') }"
@entry-focus="emit('entryFocus', $event)"
@open-auto-focus="emit('openAutoFocus', $event)"
>
<slot />
</MenuContentImpl>
</template>
@@ -0,0 +1,38 @@
<script setup lang="ts">
// Internal non-modal variant of the menu content: leaves focus untrapped and
// outside pointer events enabled, so the rest of the page stays interactive
// while the menu is open. Selected by MenuContent when the root's `modal` prop
// is false.
import type { MenuContentImplEmits, MenuContentImplProps } from './MenuContentImpl.vue';
import { shallowRef, watchEffect } from 'vue';
import MenuContentImpl from './MenuContentImpl.vue';
import { useMenuContext } from './context';
const props = defineProps<MenuContentImplProps>();
const emit = defineEmits<MenuContentImplEmits>();
const menuCtx = useMenuContext();
const contentRef = shallowRef<HTMLElement | null>(null);
watchEffect(() => menuCtx.onContentChange(contentRef.value));
</script>
<template>
<MenuContentImpl
v-bind="props"
:ref="(comp: any) => { contentRef = comp?.$el ?? null }"
:trap-focus="false"
:disable-outside-pointer-events="false"
@close-auto-focus="emit('closeAutoFocus', $event)"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
@dismiss="() => { menuCtx.onOpenChange(false); emit('dismiss') }"
@entry-focus="emit('entryFocus', $event)"
@open-auto-focus="emit('openAutoFocus', $event)"
>
<slot />
</MenuContentImpl>
</template>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A visual and semantic divider (`role="separator"`) between groups of menu
* items. It is purely decorative for navigation and is skipped by keyboard focus.
*/
export interface MenuSeparatorProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
const { as = 'div' } = defineProps<MenuSeparatorProps>();
</script>
<template>
<Primitive :as="as" role="separator" aria-orientation="horizontal">
<slot />
</Primitive>
</template>
+74
View File
@@ -0,0 +1,74 @@
<script lang="ts">
/**
* Establishes a nested submenu. It provides a fresh menu context plus a sub
* context shared by its MenuSubTrigger and MenuSubContent, owning the submenu's
* open state. Bind `v-model:open` to control it, or leave it unbound to let the
* submenu manage its own open state (uncontrolled); the default slot also
* exposes the current `open` value.
*/
export interface MenuSubProps {
/** The controlled open state of the submenu. Use together with `update:open`. */
open?: boolean;
}
export interface MenuSubEmits {
'update:open': [value: boolean];
}
</script>
<script setup lang="ts">
import { shallowRef, watch } from 'vue';
import { useId } from '../../utilities/config-provider';
import { PopperRoot } from '../../overlays/popper';
import { provideMenuContext, provideMenuSubContext, useMenuContext } from './context';
defineProps<MenuSubProps>();
defineSlots<{ default?: (props: { open: boolean }) => unknown }>();
// Controlled when `v-model:open` is bound; otherwise the local ref drives it.
const local = shallowRef(false);
// The model shares the declared prop name on purpose — controlled/uncontrolled
// merge happens inside get/set — hence the dupe-keys exception.
// eslint-disable-next-line vue/no-dupe-keys
const open = defineModel<boolean>('open', {
get: external => external ?? local.value,
set: (value) => {
local.value = value;
return value;
},
});
const parentMenuCtx = useMenuContext();
const trigger = shallowRef<HTMLElement | null>(null);
// Real reactive content ref + working setter so the grace-area / positioning
// logic that reads `menuCtx.content` works for submenus too.
const content = shallowRef<HTMLElement | null>(null);
const contentId = useId(undefined, 'menu-sub-content');
const triggerId = useId(undefined, 'menu-sub-trigger');
// A submenu must never outlive its parent: when the parent closes, close it
// too, and always close on teardown so an orphaned submenu can't linger.
watch(() => parentMenuCtx.open.value, (parentOpen) => {
if (!parentOpen) open.value = false;
});
provideMenuContext({
open,
onOpenChange: (v) => { open.value = v; },
content,
onContentChange: (el) => { content.value = el; },
});
provideMenuSubContext({
contentId,
triggerId,
trigger,
onTriggerChange: (el) => { trigger.value = el; },
});
</script>
<template>
<PopperRoot>
<slot :open="open" />
</PopperRoot>
</template>
@@ -0,0 +1,89 @@
<script lang="ts">
import type { MenuContentImplEmits, MenuContentImplProps } from './MenuContentImpl.vue';
/**
* The popup surface for a submenu's items. It mounts while the submenu is open,
* positions itself to the side of its MenuSubTrigger (flipping for RTL), and
* always renders non-modally so the parent menu stays interactive. Must be used
* inside a MenuSub.
*/
export interface MenuSubContentProps extends MenuContentImplProps {
/** Force mounting the content even when closed, e.g. to control presence with an external animation library. */
forceMount?: boolean;
}
export type MenuSubContentEmits = MenuContentImplEmits;
</script>
<script setup lang="ts">
import { shallowRef, watchEffect } from 'vue';
import { Presence } from '../../utilities/presence';
import { useMenuContext, useMenuRootContext, useMenuSubContext } from './context';
import MenuContentImpl from './MenuContentImpl.vue';
import { SUB_CLOSE_KEYS } from './utils';
const { forceMount = false, ...contentProps } = defineProps<MenuSubContentProps>();
const emit = defineEmits<MenuSubContentEmits>();
const menuCtx = useMenuContext();
const subCtx = useMenuSubContext();
const rootCtx = useMenuRootContext();
// Track the sub-content element into this submenu's own MenuContext so the
// grace-area logic can measure it. SubContent renders MenuContentImpl directly
// (no modal/nonmodal wrapper), so we wire onContentChange here.
const subContentEl = shallowRef<HTMLElement | null>(null);
watchEffect(() => menuCtx.onContentChange(subContentEl.value));
function handleKeyDown(event: KeyboardEvent) {
// Submenu key events bubble through portals; only act on keys from inside.
const isKeyDownInside = (event.currentTarget as HTMLElement)?.contains(event.target as Node);
const isCloseKey = SUB_CLOSE_KEYS[rootCtx.dir.value]?.includes(event.key);
if (isKeyDownInside && isCloseKey) {
menuCtx.onOpenChange(false);
// We prevented close-auto-focus, so return focus to the trigger manually.
subCtx.trigger.value?.focus();
event.preventDefault();
}
}
function handleOpenAutoFocus(event: Event) {
// When opening a submenu, focus its content for keyboard users only.
if (rootCtx.isUsingKeyboardRef.value) subContentEl.value?.focus();
emit('openAutoFocus', event);
}
</script>
<template>
<Presence :present="forceMount || menuCtx.open.value">
<MenuContentImpl
:id="subCtx.contentId.value"
v-bind="contentProps"
:ref="(comp: any) => { subContentEl = comp?.$el ?? null }"
:aria-labelledby="subCtx.triggerId.value"
:trap-focus="false"
:disable-outside-pointer-events="false"
:side="rootCtx.dir.value === 'rtl' ? 'left' : 'right'"
align="start"
:side-offset="2"
:align-offset="-5"
@close-auto-focus="(event: Event) => { event.preventDefault(); emit('closeAutoFocus', event) }"
@escape-key-down="(event: KeyboardEvent) => {
emit('escapeKeyDown', event)
menuCtx.onOpenChange(false)
}"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="(event: FocusEvent) => {
if (subCtx.trigger.value?.contains(event.target as Node)) event.preventDefault()
emit('focusOutside', event)
}"
@interact-outside="emit('interactOutside', $event)"
@dismiss="menuCtx.onOpenChange(false)"
@entry-focus="emit('entryFocus', $event)"
@open-auto-focus="handleOpenAutoFocus"
@keydown="handleKeyDown"
>
<slot />
</MenuContentImpl>
</Presence>
</template>
@@ -0,0 +1,133 @@
<script lang="ts">
import type { MenuItemImplProps } from './MenuItemImpl.vue';
/**
* A menu item that opens its parent MenuSub's submenu. It acts as both the
* positioning anchor and the trigger, opening on hover (with a grace delay) or
* via the directional arrow key and closing on the opposite arrow. Must be used
* inside a MenuSub, alongside a MenuSubContent.
*/
export interface MenuSubTriggerProps extends MenuItemImplProps {}
</script>
<script setup lang="ts">
import { shallowRef } from 'vue';
import { PopperAnchor } from '../../overlays/popper';
import { useMenuContentContext, useMenuContext, useMenuItemSelectContext, useMenuRootContext, useMenuSubContext } from './context';
import type { Side } from './utils';
import MenuItemImpl from './MenuItemImpl.vue';
import { SUB_CLOSE_KEYS, SUB_OPEN_KEYS, buildSubmenuGraceArea, getOpenState, isMousePointer } from './utils';
const props = defineProps<MenuSubTriggerProps>();
const menuCtx = useMenuContext();
const subCtx = useMenuSubContext();
const rootCtx = useMenuRootContext();
const contentCtx = useMenuContentContext();
// Injected from the parent content (the sub-trigger lives inside it); falls
// back to never-typing when used outside a content during isolated tests.
const selectCtx = useMenuItemSelectContext({ isTypingAhead: shallowRef(false) });
let openTimer: ReturnType<typeof setTimeout> | undefined;
function open() {
clearTimeout(openTimer);
menuCtx.onOpenChange(true);
}
function close() {
clearTimeout(openTimer);
menuCtx.onOpenChange(false);
}
function handlePointerMove(event: PointerEvent) {
if (!isMousePointer(event)) return;
if (props.disabled) return;
if (contentCtx.onItemEnter(event)) return;
if (!menuCtx.open.value && !openTimer) {
// Cancel any pending grace intent before scheduling this trigger's open.
contentCtx.onPointerGraceIntentChange(null);
openTimer = setTimeout(() => {
menuCtx.onOpenChange(true);
openTimer = undefined;
}, 100);
}
}
function handlePointerLeave(event: PointerEvent) {
if (!isMousePointer(event)) return;
clearTimeout(openTimer);
openTimer = undefined;
const contentRect = menuCtx.content.value?.getBoundingClientRect();
if (contentRect?.width) {
const side = (menuCtx.content.value?.dataset['side'] as Side) ?? 'right';
// Register a safe diagonal triangle toward the open submenu so the cursor
// can travel to it without the submenu closing. Auto-expires after 300ms.
contentCtx.onPointerGraceIntentChange({
area: buildSubmenuGraceArea(event, contentRect, side),
side,
});
globalThis.clearTimeout(contentCtx.pointerGraceTimerRef.value);
contentCtx.pointerGraceTimerRef.value = globalThis.setTimeout(
() => contentCtx.onPointerGraceIntentChange(null),
300,
);
}
else {
if (contentCtx.onTriggerLeave(event)) return;
contentCtx.onPointerGraceIntentChange(null);
close();
}
}
function handleKeyDown(event: KeyboardEvent) {
if (props.disabled) return;
// While typing ahead, Space extends the search instead of opening the submenu.
if (selectCtx.isTypingAhead.value && event.key === ' ') return;
const openKeys = SUB_OPEN_KEYS[rootCtx.dir.value]!;
const closeKeys = SUB_CLOSE_KEYS[rootCtx.dir.value]!;
if (openKeys.includes(event.key)) {
event.preventDefault();
open();
}
if (closeKeys.includes(event.key)) {
event.preventDefault();
close();
}
}
function handleSelect(event: Event) {
// Sub triggers open their submenu instead of closing the menu tree —
// this is also the only open path for touch pointers.
event.preventDefault();
if (!menuCtx.open.value) open();
}
// PopperAnchor renders as="template" so the item element itself becomes the
// popper anchor and fallthrough attrs land on the element carrying
// data-state/highlight (a wrapper div would swallow them). The template must
// stay single-root without top-level comments — see MenuItemImpl.
</script>
<template>
<PopperAnchor as="template">
<MenuItemImpl
v-bind="props"
:id="subCtx.triggerId.value"
:ref="(el: unknown) => subCtx.onTriggerChange((el as any)?.$el ?? null)"
aria-haspopup="menu"
:aria-expanded="menuCtx.open.value"
:aria-controls="subCtx.contentId.value"
:data-state="getOpenState(menuCtx.open.value)"
role="menuitem"
@pointermove="handlePointerMove"
@pointerleave="handlePointerLeave"
@keydown="handleKeyDown"
@select="handleSelect"
>
<slot />
</MenuItemImpl>
</PopperAnchor>
</template>
@@ -0,0 +1,499 @@
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,
MenuCheckboxItem,
MenuContent,
MenuGroup,
MenuItem,
MenuItemIndicator,
MenuLabel,
MenuRadioGroup,
MenuRadioItem,
MenuRoot,
MenuSub,
MenuSubContent,
MenuSubTrigger,
} from '../index';
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;
}
// Writes back into a ref — keeps inline v-model handlers to a single statement.
function setter<T>(r: { value: T }): (v: T) => void {
return (v: T) => {
r.value = v;
};
}
async function openMenu(open: { value: boolean }) {
open.value = true;
await nextTick();
await nextTick();
}
function content(): HTMLElement {
return document.querySelector<HTMLElement>('[role="menu"]')!;
}
function keydown(el: HTMLElement, key: string, init: KeyboardEventInit = {}) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }));
}
function usePointer() {
document.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }));
}
function useKeyboard() {
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true }));
}
describe('menu — Group / Label accessible name wiring', () => {
it('points group aria-labelledby at the nested label id', async () => {
const open = ref(false);
const Harness = defineComponent({
setup() {
return () => h(MenuRoot, { open: open.value, 'onUpdate:open': setter(open) }, {
default: () => [
h(MenuAnchor, null, { default: () => h('button', 'Anchor') }),
h(MenuContent, null, {
default: () => h(MenuGroup, null, {
default: () => [
h(MenuLabel, { class: 'lbl' }, { default: () => 'Section' }),
h(MenuItem, null, { default: () => 'Alpha' }),
],
}),
}),
],
});
},
});
track(mount(Harness, { attachTo: document.body }));
await openMenu(open);
const group = document.querySelector<HTMLElement>('[role="group"]')!;
const label = document.querySelector<HTMLElement>('.lbl')!;
expect(group.getAttribute('aria-labelledby')).toBeTruthy();
expect(label.id).toBe(group.getAttribute('aria-labelledby'));
});
});
describe('menu — keyboard: Tab is trapped inside content', () => {
it('prevents default on Tab originating inside the content', async () => {
const open = ref(false);
const Harness = defineComponent({
setup() {
return () => h(MenuRoot, { open: open.value, 'onUpdate:open': setter(open) }, {
default: () => [
h(MenuAnchor, null, { default: () => h('button', 'Anchor') }),
h(MenuContent, null, { default: () => h(MenuItem, null, { default: () => 'Alpha' }) }),
],
});
},
});
track(mount(Harness, { attachTo: document.body }));
await openMenu(open);
const event = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
content().dispatchEvent(event);
await nextTick();
expect(event.defaultPrevented).toBe(true);
});
});
describe('menu — keyboard: Space during typeahead does not select', () => {
function mountTypeahead() {
const open = ref(false);
const selected: Event[] = [];
const Harness = defineComponent({
setup() {
return () => h(MenuRoot, { open: open.value, 'onUpdate:open': setter(open) }, {
default: () => [
h(MenuAnchor, null, { default: () => h('button', 'Anchor') }),
h(MenuContent, null, {
default: () => [
h(MenuItem, { onSelect: (e: Event) => selected.push(e) }, { default: () => 'Alpha' }),
h(MenuItem, null, { default: () => 'Bravo' }),
],
}),
],
});
},
});
track(mount(Harness, { attachTo: document.body }));
return { open, selected };
}
it('Space selects when not searching', async () => {
usePointer();
const { open, selected } = mountTypeahead();
await openMenu(open);
const item = document.querySelector<HTMLElement>('[role="menuitem"]')!;
item.focus();
keydown(item, ' ');
await nextTick();
expect(selected).toHaveLength(1);
});
it('Space is swallowed (no select) while a typeahead search is active', async () => {
usePointer();
const { open, selected } = mountTypeahead();
await openMenu(open);
// Begin a search by typing a character on the content.
keydown(content(), 'a');
await nextTick();
const item = document.querySelector<HTMLElement>('[role="menuitem"]')!;
item.focus();
keydown(item, ' ');
await nextTick();
expect(selected).toHaveLength(0);
expect(open.value).toBe(true);
});
});
describe('menu — MenuSub uncontrolled open + parent-close auto-dismiss', () => {
function mountSub() {
const open = ref(false);
const Harness = defineComponent({
setup() {
return () => h(MenuRoot, { open: open.value, 'onUpdate:open': setter(open) }, {
default: () => [
h(MenuAnchor, null, { default: () => h('button', 'Anchor') }),
h(MenuContent, null, {
default: () => h(MenuSub, null, {
// No v-model:open bound -> uncontrolled.
default: () => [
h(MenuSubTrigger, { class: 'sub-trigger' }, { default: () => 'More' }),
h(MenuSubContent, null, { default: () => h(MenuItem, null, { default: () => 'Nested' }) }),
],
}),
}),
],
});
},
});
track(mount(Harness, { attachTo: document.body }));
return { open };
}
it('opens the submenu without an external v-model (uncontrolled)', async () => {
const { open } = mountSub();
await openMenu(open);
const trigger = document.querySelector<HTMLElement>('.sub-trigger')!;
trigger.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await nextTick();
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('open');
expect(document.querySelectorAll('[role="menu"]').length).toBe(2);
});
it('closes the submenu when the parent menu closes', async () => {
const { open } = mountSub();
await openMenu(open);
const trigger = document.querySelector<HTMLElement>('.sub-trigger')!;
trigger.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await nextTick();
await nextTick();
expect(document.querySelectorAll('[role="menu"]').length).toBe(2);
open.value = false;
await nextTick();
await nextTick();
// Both the parent and the orphaned submenu are gone.
expect(document.querySelectorAll('[role="menu"]').length).toBe(0);
});
it('reopening the parent does not reopen a previously open submenu', async () => {
const { open } = mountSub();
await openMenu(open);
const trigger = document.querySelector<HTMLElement>('.sub-trigger')!;
trigger.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await nextTick();
await nextTick();
expect(document.querySelectorAll('[role="menu"]').length).toBe(2);
open.value = false;
await nextTick();
await nextTick();
await openMenu(open);
// Only the parent reopens.
expect(document.querySelectorAll('[role="menu"]').length).toBe(1);
});
});
describe('menu — submenu close key returns focus to the trigger', () => {
function mountSub(dir?: 'ltr' | 'rtl') {
const open = ref(false);
const subOpen = ref(false);
const Harness = defineComponent({
setup() {
return () => h(MenuRoot, { open: open.value, 'onUpdate:open': setter(open), dir }, {
default: () => [
h(MenuAnchor, null, { default: () => h('button', 'Anchor') }),
h(MenuContent, null, {
default: () => h(MenuSub, { open: subOpen.value, 'onUpdate:open': setter(subOpen) }, {
default: () => [
h(MenuSubTrigger, { class: 'sub-trigger' }, { default: () => 'More' }),
h(MenuSubContent, null, { default: () => h(MenuItem, null, { default: () => 'Nested' }) }),
],
}),
}),
],
});
},
});
track(mount(Harness, { attachTo: document.body }));
return { open, subOpen };
}
it('ArrowLeft inside an open submenu closes it and refocuses the trigger (ltr)', async () => {
useKeyboard();
const { open, subOpen } = mountSub('ltr');
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);
const subContent = document.querySelectorAll<HTMLElement>('[role="menu"]')[1]!;
keydown(subContent, 'ArrowLeft');
await nextTick();
await nextTick();
expect(subOpen.value).toBe(false);
expect(document.activeElement).toBe(trigger);
});
});
describe('menu — submenu pointer grace area', () => {
function mountSub() {
const open = ref(false);
const subOpen = ref(false);
const Harness = defineComponent({
setup() {
return () => h(MenuRoot, { open: open.value, 'onUpdate:open': setter(open) }, {
default: () => [
h(MenuAnchor, null, { default: () => h('button', 'Anchor') }),
h(MenuContent, { style: 'width:200px' }, {
default: () => [
h(MenuSub, { open: subOpen.value, 'onUpdate:open': setter(subOpen) }, {
default: () => [
h(MenuSubTrigger, { class: 'sub-trigger' }, { default: () => 'More' }),
h(MenuSubContent, { style: 'width:150px;height:80px' }, {
default: () => h(MenuItem, null, { default: () => 'Nested' }),
}),
],
}),
h(MenuItem, { class: 'sibling' }, { default: () => 'Sibling' }),
],
}),
],
});
},
});
track(mount(Harness, { attachTo: document.body }));
return { open, subOpen };
}
function pointerMove(el: HTMLElement, x: number, y: number) {
el.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, pointerType: 'mouse', clientX: x, clientY: y }));
}
it('keeps focus from being stolen by a sibling while the pointer travels toward the open submenu', async () => {
const { open, subOpen } = mountSub();
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);
const subContent = document.querySelectorAll<HTMLElement>('[role="menu"]')[1]!;
const subRect = subContent.getBoundingClientRect();
const parentContent = content();
const triggerRect = trigger.getBoundingClientRect();
// Establish rightward pointer direction on the parent content.
pointerMove(parentContent, triggerRect.left + 1, triggerRect.top + 5);
pointerMove(parentContent, triggerRect.left + 10, triggerRect.top + 5);
// Leave the trigger heading toward the submenu -> registers a grace area.
trigger.dispatchEvent(new PointerEvent('pointerleave', {
bubbles: true,
pointerType: 'mouse',
clientX: triggerRect.right,
clientY: triggerRect.top + 5,
}));
await nextTick();
// A pointermove over the sibling, but inside the grace triangle (between
// the trigger and the submenu, moving right), must NOT focus the sibling.
const sibling = document.querySelector<HTMLElement>('.sibling')!;
const insideGraceX = (triggerRect.right + subRect.left) / 2;
const insideGraceY = subRect.top + 2;
sibling.dispatchEvent(new PointerEvent('pointermove', {
bubbles: true,
pointerType: 'mouse',
clientX: insideGraceX,
clientY: insideGraceY,
}));
await nextTick();
// The grace area suppressed the sibling's focus steal mid-transit.
expect(document.activeElement).not.toBe(sibling);
});
it('focuses a sibling normally when moving away from the submenu (no grace match)', async () => {
const { open, subOpen } = mountSub();
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);
const parentContent = content();
const triggerRect = trigger.getBoundingClientRect();
// Establish *leftward* pointer direction (away from the right-side submenu).
pointerMove(parentContent, triggerRect.left + 20, triggerRect.top + 5);
pointerMove(parentContent, triggerRect.left + 5, triggerRect.top + 5);
// No grace intent registered yet; a sibling pointermove should focus it.
const sibling = document.querySelector<HTMLElement>('.sibling')!;
const sibRect = sibling.getBoundingClientRect();
sibling.dispatchEvent(new PointerEvent('pointermove', {
bubbles: true,
pointerType: 'mouse',
clientX: sibRect.left + 2,
clientY: sibRect.top + 2,
}));
await nextTick();
expect(document.activeElement).toBe(sibling);
});
});
describe('menu — checkbox / radio expose checked state via slot', () => {
it('MenuCheckboxItem default slot receives the checked state', async () => {
const open = ref(false);
const seen: unknown[] = [];
const Harness = defineComponent({
setup() {
return () => h(MenuRoot, { open: open.value, 'onUpdate:open': setter(open) }, {
default: () => [
h(MenuAnchor, null, { default: () => h('button', 'Anchor') }),
h(MenuContent, null, {
default: () => h(MenuCheckboxItem, { checked: true }, {
default: (slotProps: { checked: unknown }) => {
seen.push(slotProps.checked);
return 'Toggle';
},
}),
}),
],
});
},
});
track(mount(Harness, { attachTo: document.body }));
await openMenu(open);
expect(seen).toContain(true);
});
it('MenuRadioItem default slot receives whether it is checked', async () => {
const open = ref(false);
const seen: boolean[] = [];
const Harness = defineComponent({
setup() {
return () => h(MenuRoot, { open: open.value, 'onUpdate:open': setter(open) }, {
default: () => [
h(MenuAnchor, null, { default: () => h('button', 'Anchor') }),
h(MenuContent, null, {
default: () => h(MenuRadioGroup, { modelValue: 'a' }, {
default: () => [
h(MenuRadioItem, { value: 'a' }, {
default: (slotProps: { checked: boolean }) => {
seen.push(slotProps.checked);
return 'A';
},
}),
h(MenuRadioItem, { value: 'b' }, {
default: (slotProps: { checked: boolean }) => {
seen.push(slotProps.checked);
return 'B';
},
}),
],
}),
}),
],
});
},
});
track(mount(Harness, { attachTo: document.body }));
await openMenu(open);
expect(seen).toContain(true);
expect(seen).toContain(false);
});
});
describe('menu — radio group accepts non-string values and renders through a group', () => {
it('selects by numeric value and exposes role=group with a labelledby hook', async () => {
const open = ref(false);
const model = ref<number | undefined>(undefined);
const Harness = defineComponent({
setup() {
return () => h(MenuRoot, { open: open.value, 'onUpdate:open': setter(open) }, {
default: () => [
h(MenuAnchor, null, { default: () => h('button', 'Anchor') }),
h(MenuContent, null, {
default: () => h(MenuRadioGroup, {
modelValue: model.value,
'onUpdate:modelValue': setter(model),
}, {
default: () => [
h(MenuLabel, null, { default: () => 'Pick' }),
h(MenuRadioItem, { value: 1, class: 'r1', onSelect: (e: Event) => e.preventDefault() }, {
default: () => h(MenuItemIndicator, { class: 'ind' }, { default: () => '•' }),
}),
h(MenuRadioItem, { value: 2, class: 'r2' }, { default: () => 'Two' }),
],
}),
}),
],
});
},
});
track(mount(Harness, { attachTo: document.body }));
await openMenu(open);
const group = document.querySelector<HTMLElement>('[role="group"]')!;
expect(group).toBeTruthy();
expect(group.getAttribute('aria-labelledby')).toBeTruthy();
const r1 = document.querySelector<HTMLElement>('[role="menuitemradio"].r1')!;
expect(r1).toBeTruthy();
r1.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await nextTick();
await nextTick();
await nextTick();
expect(model.value).toBe(1);
expect(r1.getAttribute('aria-checked')).toBe('true');
});
});
@@ -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);
});
});
+70
View File
@@ -0,0 +1,70 @@
import type { CheckedState } from './types';
import type { AcceptableValue } from './utils';
import type { ComputedRef, Ref, ShallowRef } from 'vue';
import type { Direction } from '../../utilities/config-provider';
import { useContextFactory } from '@robonen/vue';
export interface MenuContext {
open: Ref<boolean>;
onOpenChange: (open: boolean) => void;
content: Ref<HTMLElement | null>;
onContentChange: (el: HTMLElement | null) => void;
}
export const { inject: useMenuContext, provide: provideMenuContext }
= useContextFactory<MenuContext>('MenuContext');
export interface MenuRootContext {
onClose: () => void;
dir: Ref<Direction>;
isUsingKeyboardRef: Ref<boolean>;
modal: Ref<boolean>;
}
export const { inject: useMenuRootContext, provide: provideMenuRootContext }
= useContextFactory<MenuRootContext>('MenuRootContext');
export interface MenuContentContext {
onItemEnter: (event: PointerEvent) => boolean;
onItemLeave: (event: PointerEvent) => void;
onTriggerLeave: (event: PointerEvent) => boolean;
searchRef: Ref<string>;
pointerGraceTimerRef: Ref<number>;
onPointerGraceIntentChange: (intent: { area: Array<{ x: number; y: number }>; side: 'left' | 'right' } | null) => void;
}
export interface MenuItemSelectContext {
/** Whether typeahead search is currently active — used to block Space from selecting mid-search. */
isTypingAhead: Ref<boolean>;
}
export const { inject: useMenuItemSelectContext, provide: provideMenuItemSelectContext }
= useContextFactory<MenuItemSelectContext>('MenuItemSelectContext');
export const { inject: useMenuContentContext, provide: provideMenuContentContext }
= useContextFactory<MenuContentContext>('MenuContentContext');
export interface MenuSubContext {
contentId: ComputedRef<string>;
triggerId: ComputedRef<string>;
trigger: ShallowRef<HTMLElement | null>;
onTriggerChange: (el: HTMLElement | null) => void;
}
export const { inject: useMenuSubContext, provide: provideMenuSubContext }
= useContextFactory<MenuSubContext>('MenuSubContext');
export interface MenuRadioGroupContext {
modelValue: Ref<AcceptableValue | undefined>;
onValueChange: (value: AcceptableValue) => void;
}
export const { inject: useMenuRadioGroupContext, provide: provideMenuRadioGroupContext }
= useContextFactory<MenuRadioGroupContext>('MenuRadioGroupContext');
export interface MenuItemIndicatorContext {
checkedState: Ref<CheckedState>;
}
export const { inject: useMenuItemIndicatorContext, provide: provideMenuItemIndicatorContext }
= useContextFactory<MenuItemIndicatorContext>('MenuItemIndicatorContext');
export interface MenuGroupContext {
id: string;
}
export const { inject: useMenuGroupContext, provide: provideMenuGroupContext }
= useContextFactory<MenuGroupContext>('MenuGroupContext');
+21
View File
@@ -0,0 +1,21 @@
export type { CheckedState } from './types';
export type { AcceptableValue } from './utils';
export { useMenuContext, useMenuContentContext, useMenuItemSelectContext, useMenuRootContext, useMenuSubContext } from './context';
export { default as MenuAnchor, type MenuAnchorProps } from './MenuAnchor.vue';
export { default as MenuArrow, type MenuArrowProps } from './MenuArrow.vue';
export { default as MenuCheckboxItem, type MenuCheckboxItemEmits, type MenuCheckboxItemProps } from './MenuCheckboxItem.vue';
export { default as MenuContent, type MenuContentEmits, type MenuContentProps } from './MenuContent.vue';
export { default as MenuGroup, type MenuGroupProps } from './MenuGroup.vue';
export { default as MenuItem, type MenuItemEmits, type MenuItemProps } from './MenuItem.vue';
export { default as MenuItemImpl, type MenuItemImplEmits, type MenuItemImplProps } from './MenuItemImpl.vue';
export { default as MenuItemIndicator, type MenuItemIndicatorProps } from './MenuItemIndicator.vue';
export { default as MenuLabel, type MenuLabelProps } from './MenuLabel.vue';
export { default as MenuPortal, type MenuPortalProps } from './MenuPortal.vue';
export { default as MenuRadioGroup, type MenuRadioGroupEmits, type MenuRadioGroupProps } from './MenuRadioGroup.vue';
export { default as MenuRadioItem, type MenuRadioItemEmits, type MenuRadioItemProps } from './MenuRadioItem.vue';
export { default as MenuRoot, type MenuRootProps } from './MenuRoot.vue';
export { default as MenuSeparator, type MenuSeparatorProps } from './MenuSeparator.vue';
export { default as MenuSub, type MenuSubEmits, type MenuSubProps } from './MenuSub.vue';
export { default as MenuSubContent, type MenuSubContentEmits, type MenuSubContentProps } from './MenuSubContent.vue';
export { default as MenuSubTrigger, type MenuSubTriggerProps } from './MenuSubTrigger.vue';
+1
View File
@@ -0,0 +1 @@
export type CheckedState = boolean | 'indeterminate';
@@ -0,0 +1,23 @@
import { ref } from 'vue';
const isUsingKeyboard = ref(false);
let initialized = false;
function init() {
if (initialized || typeof document === 'undefined') return;
initialized = true;
document.addEventListener('keydown', () => {
isUsingKeyboard.value = true;
}, { capture: true, passive: true });
document.addEventListener('pointerdown', () => {
isUsingKeyboard.value = false;
}, { capture: true, passive: true });
document.addEventListener('pointermove', () => {
isUsingKeyboard.value = false;
}, { capture: true, passive: true });
}
export function useIsUsingKeyboard() {
init();
return isUsingKeyboard;
}
+106
View File
@@ -0,0 +1,106 @@
import type { CheckedState } from './types';
import { getActiveElement } from '@robonen/platform/browsers';
import { isPointInPolygon } from '../../internal/utils/geometry';
/** Any serialisable value a radio item can carry (not just strings). */
export type AcceptableValue = string | number | boolean | Record<string, unknown>;
export const ITEM_SELECT = 'menu.itemSelect';
export const SELECTION_KEYS = ['Enter', ' '];
export const FIRST_KEYS = ['ArrowDown', 'PageUp', 'Home'];
export const LAST_KEYS = ['ArrowUp', 'PageDown', 'End'];
export const FIRST_LAST_KEYS = [...FIRST_KEYS, ...LAST_KEYS];
export const SUB_OPEN_KEYS: Record<string, string[]> = {
ltr: [...SELECTION_KEYS, 'ArrowRight'],
rtl: [...SELECTION_KEYS, 'ArrowLeft'],
};
export const SUB_CLOSE_KEYS: Record<string, string[]> = {
ltr: ['ArrowLeft'],
rtl: ['ArrowRight'],
};
export function getOpenState(open: boolean): 'open' | 'closed' {
return open ? 'open' : 'closed';
}
export function isIndeterminate(checked: CheckedState): checked is 'indeterminate' {
return checked === 'indeterminate';
}
export function getCheckedState(checked: CheckedState): 'checked' | 'unchecked' | 'indeterminate' {
if (isIndeterminate(checked)) return 'indeterminate';
return checked ? 'checked' : 'unchecked';
}
export function focusFirst(candidates: HTMLElement[]): void {
for (const candidate of candidates) {
const prev = getActiveElement();
candidate.focus({ preventScroll: true });
if (getActiveElement() !== prev) return;
}
}
export function getNextMatch(
items: HTMLElement[],
search: string,
currentItem?: HTMLElement | null,
): HTMLElement | undefined {
const isRepeating = search.length > 1 && Array.from(search).every(c => c === search[0]);
const normalizedSearch = isRepeating ? search[0]! : search;
const currentIndex = currentItem ? items.indexOf(currentItem) : -1;
const wrappedItems = currentIndex !== -1
? [...items.slice(currentIndex + 1), ...items.slice(0, currentIndex + 1)]
: items;
const getText = (el: HTMLElement) =>
el.dataset['primitiveMenuItemTextValue'] ?? el.textContent?.trim() ?? '';
return wrappedItems.find(item =>
getText(item).toLowerCase().startsWith(normalizedSearch.toLowerCase()),
);
}
export interface Point { x: number; y: number };
export type Polygon = Point[];
export type Side = 'left' | 'right';
export interface GraceIntent { area: Polygon; side: Side }
export function isPointerInGraceArea(event: PointerEvent, area?: Polygon | null): boolean {
if (!area) return false;
return isPointInPolygon({ x: event.clientX, y: event.clientY }, area);
}
export function isMouseEvent(event: Event): event is MouseEvent {
return ['mousedown', 'mouseup', 'mousemove', 'click'].includes(event.type);
}
/**
* Whether a pointer event came from a real mouse (not pen / touch). Hover-only
* menu behaviour (open-on-hover, highlight-on-move, grace area) must ignore pen
* and touch so taps don't trigger hover semantics.
*/
export function isMousePointer(event: PointerEvent): boolean {
return event.pointerType === 'mouse';
}
/**
* Builds the 5-point "grace area" polygon that lets the pointer travel
* diagonally from a sub-trigger toward its already-open submenu without the
* submenu closing. The polygon spans from the pointer exit point to the two
* vertical edges of the submenu content on the side it opened to.
*/
export function buildSubmenuGraceArea(event: PointerEvent, contentRect: DOMRect, side: Side): Polygon {
const rightSide = side === 'right';
const bleed = rightSide ? -5 : 5;
const contentNearEdge = contentRect[rightSide ? 'left' : 'right'];
const contentFarEdge = contentRect[rightSide ? 'right' : 'left'];
return [
{ x: event.clientX + bleed, y: event.clientY },
{ x: contentNearEdge, y: contentRect.top },
{ x: contentFarEdge, y: contentRect.top },
{ x: contentFarEdge, y: contentRect.bottom },
{ x: contentNearEdge, y: contentRect.bottom },
];
}
@@ -0,0 +1,19 @@
<script lang="ts">
import type { MenuArrowProps } from '../menu';
/**
* An optional arrow that points from a menu's content back toward its trigger.
* Render it inside the content; it tracks the trigger as the menu repositions.
*/
export interface MenubarArrowProps extends MenuArrowProps {}
</script>
<script setup lang="ts">
import { MenuArrow } from '../menu';
const props = defineProps<MenubarArrowProps>();
</script>
<template>
<MenuArrow v-bind="props"><slot /></MenuArrow>
</template>
@@ -0,0 +1,26 @@
<script lang="ts">
import type { MenuCheckboxItemEmits, MenuCheckboxItemProps } from '../menu';
/**
* A menu item that toggles a boolean (or indeterminate) state. Bind
* `v-model:checked` to track the value; pair it with MenubarItemIndicator to
* render a check mark when active.
*/
export interface MenubarCheckboxItemProps extends MenuCheckboxItemProps {}
export type MenubarCheckboxItemEmits = MenuCheckboxItemEmits;
</script>
<script setup lang="ts">
import { MenuCheckboxItem } from '../menu';
const props = defineProps<MenubarCheckboxItemProps>();
const emit = defineEmits<MenubarCheckboxItemEmits>();
</script>
<template>
<MenuCheckboxItem
v-bind="props"
@select="emit('select', $event)"
@update:checked="emit('update:checked', $event)"
><slot /></MenuCheckboxItem>
</template>
@@ -0,0 +1,103 @@
<script lang="ts">
import type { MenuContentEmits, MenuContentProps } from '../menu';
/**
* The floating surface that holds a menu's items, positioned below its
* MenubarTrigger. Handles focus management, typeahead, and dismissal on outside
* click or Escape; render it inside a MenubarPortal so it escapes overflow
* clipping.
*
* While open and focused, ArrowLeft / ArrowRight switch to the adjacent menubar
* menu (RTL-aware, loop-aware) — the core APG menubar interaction.
*/
export interface MenubarContentProps extends MenuContentProps {}
export type MenubarContentEmits = MenuContentEmits;
</script>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { MenuContent } from '../menu';
import { SUBTRIGGER_ATTR, useMenubarMenuContext, useMenubarRootContext } from './context';
const { align = 'start', ...props } = defineProps<MenubarContentProps>();
const emit = defineEmits<MenubarContentEmits>();
const rootCtx = useMenubarRootContext();
const menuCtx = useMenubarMenuContext();
// Set on @interact-outside so closeAutoFocus knows the user moved focus/clicked
// outside the menu — in that case focus must stay where they put it instead of
// snapping back to the trigger.
const hasInteractedOutside = ref(false);
const contentStyle = computed(() => ({
'--primitives-menubar-content-transform-origin': 'var(--popper-transform-origin)',
'--primitives-menubar-content-available-width': 'var(--popper-available-width)',
'--primitives-menubar-content-available-height': 'var(--popper-available-height)',
'--primitives-menubar-trigger-width': 'var(--popper-anchor-width)',
'--primitives-menubar-trigger-height': 'var(--popper-anchor-height)',
}));
// Switch to the adjacent menubar menu. Mirrors the trigger-level arrow nav but
// fires while focus is inside the open content (APG menubar pattern).
function handleArrowNavigation(event: KeyboardEvent) {
const target = event.target as HTMLElement;
// Opening a submenu uses the same arrow key as "next menu"; don't hijack it.
const targetIsSubTrigger = !!target.closest(`[${SUBTRIGGER_ATTR}]`);
const prevMenuKey = rootCtx.dir.value === 'rtl' ? 'ArrowRight' : 'ArrowLeft';
const isPrevKey = event.key === prevMenuKey;
if (!isPrevKey && targetIsSubTrigger) return;
const values = rootCtx.getTriggers().map(i => i.value).filter((v): v is string => v !== undefined);
if (values.length === 0) return;
if (isPrevKey) values.reverse();
const currentIndex = values.indexOf(menuCtx.value);
const len = values.length;
const startIndex = currentIndex + 1;
const next = rootCtx.loop.value
? values[startIndex % len]
: (startIndex < len ? values[startIndex] : undefined);
if (next) rootCtx.onMenuOpen(next);
}
</script>
<template>
<MenuContent
:id="menuCtx.contentId.value"
v-bind="props"
:align="align"
:aria-labelledby="menuCtx.triggerId.value"
:style="contentStyle"
@keydown.arrow-right.arrow-left="handleArrowNavigation"
@close-auto-focus="(event: Event) => {
if (!menuCtx.wasKeyboardTriggerOpenRef.value) event.preventDefault()
menuCtx.wasKeyboardTriggerOpenRef.value = false
// Refocus the trigger on close, but NOT when the user moved focus/clicked
// outside the menu (e.g. into an input) — leave focus where they put it.
if (!hasInteractedOutside) menuCtx.triggerRef.value?.focus({ preventScroll: true })
hasInteractedOutside = false
emit('closeAutoFocus', event)
}"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="(event: PointerEvent | MouseEvent) => {
const target = event.target as Node
const isMenubarTrigger = menuCtx.triggerRef.value?.contains(target)
if (isMenubarTrigger) event.preventDefault()
emit('pointerDownOutside', event)
}"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="(event: PointerEvent | MouseEvent | FocusEvent) => {
hasInteractedOutside = true
emit('interactOutside', event)
}"
@dismiss="rootCtx.onMenuClose(menuCtx.value)"
@entry-focus="emit('entryFocus', $event)"
@open-auto-focus="emit('openAutoFocus', $event)"
>
<slot />
</MenuContent>
</template>
@@ -0,0 +1,19 @@
<script lang="ts">
import type { MenuGroupProps } from '../menu';
/**
* Groups related items within a menu so assistive tech announces them together.
* Pair it with a MenubarLabel to give the group an accessible name.
*/
export interface MenubarGroupProps extends MenuGroupProps {}
</script>
<script setup lang="ts">
import { MenuGroup } from '../menu';
const props = defineProps<MenubarGroupProps>();
</script>
<template>
<MenuGroup v-bind="props"><slot /></MenuGroup>
</template>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { MenuItemEmits, MenuItemProps } from '../menu';
/**
* A single actionable row in a menu. Emits `select` on click or Enter/Space and
* closes the menu afterwards unless the event is prevented.
*/
export interface MenubarItemProps extends MenuItemProps {}
export type MenubarItemEmits = MenuItemEmits;
</script>
<script setup lang="ts">
import { MenuItem } from '../menu';
const props = defineProps<MenubarItemProps>();
const emit = defineEmits<MenubarItemEmits>();
</script>
<template>
<MenuItem v-bind="props" @select="emit('select', $event)"><slot /></MenuItem>
</template>
@@ -0,0 +1,19 @@
<script lang="ts">
import type { MenuItemIndicatorProps } from '../menu';
/**
* Renders its content only when the enclosing checkbox or radio item is checked.
* Put a check mark or dot inside it as the selection marker.
*/
export interface MenubarItemIndicatorProps extends MenuItemIndicatorProps {}
</script>
<script setup lang="ts">
import { MenuItemIndicator } from '../menu';
const props = defineProps<MenubarItemIndicatorProps>();
</script>
<template>
<MenuItemIndicator v-bind="props"><slot /></MenuItemIndicator>
</template>
@@ -0,0 +1,19 @@
<script lang="ts">
import type { MenuLabelProps } from '../menu';
/**
* A non-interactive heading that titles a section of a menu. It is skipped by
* keyboard navigation and is not selectable.
*/
export interface MenubarLabelProps extends MenuLabelProps {}
</script>
<script setup lang="ts">
import { MenuLabel } from '../menu';
const props = defineProps<MenubarLabelProps>();
</script>
<template>
<MenuLabel v-bind="props"><slot /></MenuLabel>
</template>
@@ -0,0 +1,57 @@
<script lang="ts">
/**
* A single menu within the menubar, pairing one MenubarTrigger with its
* MenubarContent. Its `value` identifies the menu to the root so it can track
* which one is open; if omitted, a stable id is generated automatically.
*/
export interface MenubarMenuProps {
value?: string;
}
</script>
<script setup lang="ts">
import { computed, ref, shallowRef } from 'vue';
import { useId } from '../../utilities/config-provider';
import { MenuRoot } from '../menu';
import { provideMenubarMenuContext, useMenubarRootContext } from './context';
const { value: valueProp } = defineProps<MenubarMenuProps>();
defineSlots<{ default?: (props: { open: boolean }) => unknown }>();
const rootCtx = useMenubarRootContext();
const autoValue = useId(undefined, 'menubar-menu');
const menuValue = valueProp ?? autoValue.value;
const triggerRef = shallowRef<HTMLElement | null>(null);
const triggerId = useId(undefined, 'menubar-trigger');
const contentId = useId(undefined, 'menubar-content');
const wasKeyboardTriggerOpenRef = ref(false);
const open = computed(() => rootCtx.value.value === menuValue);
provideMenubarMenuContext({
value: menuValue,
triggerId,
contentId,
triggerRef,
onTriggerChange: (el) => { triggerRef.value = el; },
wasKeyboardTriggerOpenRef,
});
</script>
<template>
<MenuRoot
:open="open"
:dir="rootCtx.dir.value"
:modal="false"
@update:open="(v) => {
// Pass our own value so a stale menu dismissing during a menu-switch can't
// clobber the sibling that just opened (only the active menu may close).
if (!v) rootCtx.onMenuClose(menuValue)
}"
>
<slot :open="open" />
</MenuRoot>
</template>
@@ -0,0 +1,19 @@
<script lang="ts">
import type { MenuPortalProps } from '../menu';
/**
* Teleports a menu's content into the document body (or a chosen target) so it
* renders above other content and escapes any `overflow: hidden` ancestor.
*/
export interface MenubarPortalProps extends MenuPortalProps {}
</script>
<script setup lang="ts">
import { MenuPortal } from '../menu';
const props = defineProps<MenubarPortalProps>();
</script>
<template>
<MenuPortal v-bind="props"><slot /></MenuPortal>
</template>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { MenuRadioGroupEmits, MenuRadioGroupProps } from '../menu';
/**
* Groups MenubarRadioItems into a single-choice set. Bind `v-model` to track the
* selected item's value across the group.
*/
export interface MenubarRadioGroupProps extends MenuRadioGroupProps {}
export type MenubarRadioGroupEmits = MenuRadioGroupEmits;
</script>
<script setup lang="ts">
import { MenuRadioGroup } from '../menu';
const props = defineProps<MenubarRadioGroupProps>();
const emit = defineEmits<MenubarRadioGroupEmits>();
</script>
<template>
<MenuRadioGroup v-bind="props" @update:model-value="emit('update:modelValue', $event)"><slot /></MenuRadioGroup>
</template>
@@ -0,0 +1,22 @@
<script lang="ts">
import type { MenuRadioItemEmits, MenuRadioItemProps } from '../menu';
/**
* One option within a MenubarRadioGroup. Selecting it sets the group's value to
* this item's `value`; pair it with MenubarItemIndicator to show which option is
* active.
*/
export interface MenubarRadioItemProps extends MenuRadioItemProps {}
export type MenubarRadioItemEmits = MenuRadioItemEmits;
</script>
<script setup lang="ts">
import { MenuRadioItem } from '../menu';
const props = defineProps<MenubarRadioItemProps>();
const emit = defineEmits<MenubarRadioItemEmits>();
</script>
<template>
<MenuRadioItem v-bind="props" @select="emit('select', $event)"><slot /></MenuRadioItem>
</template>
@@ -0,0 +1,119 @@
<script lang="ts">
import type { Direction } from '../../utilities/config-provider';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A horizontal bar of menus, like the File / Edit / View row in a desktop app.
* Each MenubarMenu owns a trigger and its dropdown; the root coordinates them so
* only one is open at a time, arrow keys move between triggers, and typeahead
* jumps to a trigger by name. Built on top of Menu, so every menu inherits
* keyboard navigation, nested submenus, and checkbox/radio items.
*
* Use it for application-style menu bars in editors, dashboards, and tools. The
* root holds which menu is open; bind `v-model` (or listen to
* `update:modelValue`) to control or observe the active menu's value.
*/
export interface MenubarRootProps extends PrimitiveProps {
defaultValue?: string;
dir?: Direction;
loop?: boolean;
}
</script>
<script setup lang="ts">
import { ref, toRef } from 'vue';
import { Primitive } from '../../internal/primitive';
import { provideMenubarRootContext } from './context';
import { useCollectionProvider } from '../../utilities/collection';
import { useConfig } from '../../utilities/config-provider';
import { refAutoReset, useForwardExpose } from '@robonen/vue';
const {
defaultValue,
dir: dirProp,
loop = true,
as = 'div',
} = defineProps<MenubarRootProps>();
defineSlots<{ default?: (props: { modelValue: string | undefined }) => unknown }>();
const localValue = ref<string | undefined>(defaultValue);
const value = defineModel<string | undefined>('modelValue', {
default: undefined,
get: v => v ?? localValue.value,
set: (v) => {
localValue.value = v;
return v;
},
});
const config = useConfig();
const dirRef = toRef(() => dirProp ?? config.dir.value);
const { forwardRef } = useForwardExpose();
const { getItems, CollectionSlot } = useCollectionProvider<string>();
// Roving tabindex: exactly one trigger is the menubar's tab stop. Seeded to the
// open menu's value (so reopening keeps the same stop) and updated on every
// open/toggle, mirroring how a roving-focus group tracks its current item.
const currentTabStopId = ref<string | undefined>(defaultValue);
// Typeahead buffer that auto-clears 1s after the last keystroke — each write
// restarts the idle timer (and it tears down on scope dispose). Mirrors the
// Select trigger's typeahead.
const searchRef = refAutoReset('', 1000);
provideMenubarRootContext({
value,
dir: dirRef,
loop: toRef(() => loop),
onMenuOpen: (v) => {
value.value = v;
currentTabStopId.value = v;
},
onMenuClose: (v) => {
// Ignore a close request from a menu that is no longer the open one — this
// happens when switching menus and the outgoing content fires a late
// dismiss after the incoming menu has already been opened.
if (v !== undefined && value.value !== v) return;
value.value = undefined;
},
onMenuToggle: (v) => {
value.value = value.value === v ? undefined : v;
// `onMenuOpen` and `onMenuToggle` are mutually exclusive, so the tab stop is
// updated here too — toggling moves the single tab stop onto this trigger.
currentTabStopId.value = v;
},
getTriggers: (includeDisabled = false) => getItems(includeDisabled),
searchRef,
currentTabStopId,
onTabStopChange: (v) => { currentTabStopId.value = v; },
});
function onKeyDownCapture(event: KeyboardEvent) {
// Typeahead at the menubar level: alphanumeric single-character key when no modifiers.
// Browsers report printable keys as `event.key.length === 1`; Space/Enter/Arrows are
// longer or are non-printable.
if (event.ctrlKey || event.altKey || event.metaKey) return;
if (event.key.length !== 1) return;
searchRef.value += event.key;
}
</script>
<template>
<CollectionSlot>
<Primitive
:ref="forwardRef"
:as="as"
role="menubar"
aria-orientation="horizontal"
:data-orientation="'horizontal'"
:dir="dirRef"
@keydown.capture="onKeyDownCapture"
>
<slot :model-value="value" />
</Primitive>
</CollectionSlot>
</template>
@@ -0,0 +1,19 @@
<script lang="ts">
import type { MenuSeparatorProps } from '../menu';
/**
* A horizontal divider used to visually separate groups of items within a menu.
* Decorative and skipped by keyboard navigation.
*/
export interface MenubarSeparatorProps extends MenuSeparatorProps {}
</script>
<script setup lang="ts">
import { MenuSeparator } from '../menu';
const props = defineProps<MenubarSeparatorProps>();
</script>
<template>
<MenuSeparator v-bind="props"><slot /></MenuSeparator>
</template>
@@ -0,0 +1,44 @@
<script lang="ts">
import type { MenuSubEmits, MenuSubProps } from '../menu';
/**
* Wraps a nested submenu, pairing a MenubarSubTrigger with its
* MenubarSubContent. Owns the submenu's open state: bind `v-model:open` to
* control it, set `defaultOpen` to start open in uncontrolled mode, or leave
* both unset to let it manage its own open state. The default slot exposes the
* current `open` value.
*/
export interface MenubarSubProps extends MenuSubProps {
/** Open state when initially rendered. Use when you do not control `open`. */
defaultOpen?: boolean;
}
export type MenubarSubEmits = MenuSubEmits;
</script>
<script setup lang="ts">
import { ref } from 'vue';
import { MenuSub } from '../menu';
const { defaultOpen = false } = defineProps<MenubarSubProps>();
defineSlots<{ default?: (props: { open: boolean }) => unknown }>();
const localOpen = ref<boolean>(defaultOpen);
const open = defineModel<boolean>('open', {
default: undefined,
// Controlled when `v-model:open` is bound; otherwise the local ref (seeded
// from `defaultOpen`) drives it — uncontrolled mode with an initial state.
get: external => external ?? localOpen.value,
set: (value) => {
localOpen.value = value;
return value;
},
});
</script>
<template>
<MenuSub :open="open" @update:open="open = $event">
<slot :open="open" />
</MenuSub>
</template>
@@ -0,0 +1,69 @@
<script lang="ts">
import type { MenuSubContentEmits, MenuSubContentProps } from '../menu';
/**
* The floating surface for a submenu's items, positioned alongside its
* MenubarSubTrigger. Place it inside a MenubarSub.
*
* While open, ArrowRight (the "next menu" key, RTL-aware) switches to the
* adjacent menubar menu, matching the top-level content behaviour.
*/
export interface MenubarSubContentProps extends MenuSubContentProps {}
export type MenubarSubContentEmits = MenuSubContentEmits;
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { MenuSubContent } from '../menu';
import { SUBTRIGGER_ATTR, useMenubarMenuContext, useMenubarRootContext } from './context';
const props = defineProps<MenubarSubContentProps>();
const emit = defineEmits<MenubarSubContentEmits>();
const rootCtx = useMenubarRootContext();
const menuCtx = useMenubarMenuContext();
const contentStyle = computed(() => ({
'--primitives-menubar-content-transform-origin': 'var(--popper-transform-origin)',
'--primitives-menubar-content-available-width': 'var(--popper-available-width)',
'--primitives-menubar-content-available-height': 'var(--popper-available-height)',
'--primitives-menubar-trigger-width': 'var(--popper-anchor-width)',
'--primitives-menubar-trigger-height': 'var(--popper-anchor-height)',
}));
// Inside a submenu, the "next menu" key (ArrowRight in LTR) jumps to the next
// menubar menu — unless it is a deeper sub-trigger opening its own submenu.
function handleArrowNavigation(event: KeyboardEvent) {
const target = event.target as HTMLElement;
if (target.closest(`[${SUBTRIGGER_ATTR}]`)) return;
const values = rootCtx.getTriggers().map(i => i.value).filter((v): v is string => v !== undefined);
if (values.length === 0) return;
const currentIndex = values.indexOf(menuCtx.value);
const len = values.length;
const startIndex = currentIndex + 1;
const next = rootCtx.loop.value
? values[startIndex % len]
: (startIndex < len ? values[startIndex] : undefined);
if (next) rootCtx.onMenuOpen(next);
}
</script>
<template>
<MenuSubContent
v-bind="props"
:style="contentStyle"
@keydown.arrow-right="handleArrowNavigation"
@close-auto-focus="emit('closeAutoFocus', $event)"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
@dismiss="emit('dismiss')"
@entry-focus="emit('entryFocus', $event)"
@open-auto-focus="emit('openAutoFocus', $event)"
><slot /></MenuSubContent>
</template>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { MenuSubTriggerProps } from '../menu';
/**
* The item that opens its submenu on hover or ArrowRight and closes it on
* ArrowLeft. Renders like a regular item but opens MenubarSubContent instead of
* emitting `select`.
*/
export interface MenubarSubTriggerProps extends MenuSubTriggerProps {}
</script>
<script setup lang="ts">
import { MenuSubTrigger } from '../menu';
import { SUBTRIGGER_ATTR } from './context';
const props = defineProps<MenubarSubTriggerProps>();
</script>
<template>
<MenuSubTrigger v-bind="props" :[SUBTRIGGER_ATTR]="''"><slot /></MenuSubTrigger>
</template>
@@ -0,0 +1,188 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The button in the menubar that opens its menu and anchors the content.
* Toggles on click, opens on Enter / Space / ArrowDown / ArrowUp, and — once any
* menu is open — switches to this menu on hover. Arrow keys, Home/End, and
* typeahead move focus between sibling triggers.
*/
export interface MenubarTriggerProps extends PrimitiveProps {
disabled?: boolean;
}
</script>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useCollectionInjector } from '../../utilities/collection';
import { MenuAnchor, useMenuContext } from '../menu';
import { getNextMatch } from '../menu/utils';
import { Primitive } from '../../internal/primitive';
import { useMenubarMenuContext, useMenubarRootContext } from './context';
const { disabled = false, as = 'button' } = defineProps<MenubarTriggerProps>();
const rootCtx = useMenubarRootContext();
const menuCtx = useMenubarMenuContext();
const menuMenuCtx = useMenuContext();
const collection = useCollectionInjector<string>();
const { forwardRef, currentElement } = useForwardExpose();
const isFocused = ref(false);
onMounted(() => menuCtx.onTriggerChange(currentElement.value ?? null));
onUnmounted(() => menuCtx.onTriggerChange(null));
// Roving tabindex: the menubar is a single tab stop. A trigger is tabbable when
// it owns the current tab stop, or — until one is chosen — when it is the first
// enabled trigger in DOM order. Keeping the existing Arrow/Home/End handler
// below means focus still moves with arrows once the bar is entered.
const isCurrentTabStop = computed(() => {
if (disabled) return false;
const current = rootCtx.currentTabStopId.value;
if (current === menuCtx.value) return true;
// Fall back to the first enabled trigger when no tab stop is chosen yet, or
// when the chosen one is no longer mounted (e.g. its menu was v-if'd away) —
// otherwise the menubar could end up with zero tabbable triggers.
const enabled = rootCtx.getTriggers();
if (current !== undefined && enabled.some(i => i.value === current)) return false;
return enabled.at(0)?.value === menuCtx.value;
});
const tabindex = computed(() => (isCurrentTabStop.value ? 0 : -1));
function handleFocus() {
isFocused.value = true;
// Entering this trigger (Tab, arrow, or programmatic) makes it the tab stop.
if (!disabled) rootCtx.onTabStopChange(menuCtx.value);
}
function handleBlur() {
isFocused.value = false;
}
function focusTrigger(el: HTMLElement | undefined) {
if (!el) return;
el.focus({ preventScroll: true });
}
function focusByIndex(items: HTMLElement[], from: number, delta: 1 | -1) {
if (items.length === 0) return;
const loop = rootCtx.loop.value;
let next = from + delta;
if (loop) {
next = (next + items.length) % items.length;
}
else {
next = Math.max(0, Math.min(items.length - 1, next));
}
focusTrigger(items[next]);
}
// Hover-switch: when a sibling menu is already open, hovering this trigger
// (focused or not) opens this one and moves focus over.
function handlePointerDown(event: PointerEvent) {
// Left button only; `ctrlKey` filters out a macOS ctrl+click (context menu)
// that the OS reports as a left button press.
if (disabled || event.button !== 0 || event.ctrlKey) return;
event.preventDefault();
rootCtx.onMenuToggle(menuCtx.value);
}
function handlePointerEnter() {
if (disabled) return;
if (rootCtx.value.value !== undefined && rootCtx.value.value !== menuCtx.value) {
rootCtx.onMenuOpen(menuCtx.value);
menuCtx.triggerRef.value?.focus({ preventScroll: true });
}
}
function handleKeyDown(event: KeyboardEvent) {
if (disabled) return;
// Open the menu on Enter / Space / ArrowDown / ArrowUp (per WAI-ARIA APG).
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
menuCtx.wasKeyboardTriggerOpenRef.value = true;
rootCtx.onMenuOpen(menuCtx.value);
return;
}
// Move focus between sibling triggers.
const triggers = collection
.getItems(true)
.map(i => i.ref)
.filter(el => el.dataset['disabled'] !== '');
if (triggers.length === 0) return;
const currentIdx = triggers.indexOf(currentElement.value as HTMLElement);
const dir = rootCtx.dir.value;
const nextKey = dir === 'rtl' ? 'ArrowLeft' : 'ArrowRight';
const prevKey = dir === 'rtl' ? 'ArrowRight' : 'ArrowLeft';
if (event.key === nextKey) {
event.preventDefault();
focusByIndex(triggers, currentIdx, 1);
return;
}
if (event.key === prevKey) {
event.preventDefault();
focusByIndex(triggers, currentIdx, -1);
return;
}
if (event.key === 'Home') {
event.preventDefault();
focusTrigger(triggers[0]);
return;
}
if (event.key === 'End') {
event.preventDefault();
focusTrigger(triggers[triggers.length - 1]);
}
}
// Typeahead — driven by the shared `searchRef` filled by MenubarRoot's
// keydown.capture. When it changes, jump focus to the matching trigger.
watch(() => rootCtx.searchRef.value, (search) => {
if (!search) return;
// Only react when this trigger currently has focus — prevents every trigger
// from racing for the same match.
if (document.activeElement !== currentElement.value) return;
const triggers = collection
.getItems(true)
.map(i => i.ref)
.filter(el => el.dataset['disabled'] !== '');
const match = getNextMatch(triggers, search, currentElement.value as HTMLElement | null);
if (match && match !== currentElement.value) focusTrigger(match);
});
</script>
<template>
<MenuAnchor>
<collection.CollectionItem :value="menuCtx.value">
<Primitive
:ref="forwardRef"
:as="as"
:id="menuCtx.triggerId.value"
role="menuitem"
aria-haspopup="menu"
:aria-expanded="menuMenuCtx.open.value"
:aria-controls="menuMenuCtx.open.value ? menuCtx.contentId.value : undefined"
:tabindex="tabindex"
:data-state="menuMenuCtx.open.value ? 'open' : 'closed'"
:data-highlighted="isFocused ? '' : undefined"
:data-disabled="disabled ? '' : undefined"
:data-value="menuCtx.value"
:disabled="as === 'button' ? disabled : undefined"
@pointerdown="handlePointerDown"
@pointerenter="handlePointerEnter"
@keydown="handleKeyDown"
@focus="handleFocus"
@blur="handleBlur"
>
<slot />
</Primitive>
</collection.CollectionItem>
</MenuAnchor>
</template>
@@ -0,0 +1,381 @@
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 {
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarPortal,
MenubarRoot,
MenubarSub,
MenubarSubContent,
MenubarSubTrigger,
MenubarTrigger,
} from '../index';
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;
}
function keydown(el: Element, key: string, init: KeyboardEventInit = {}) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }));
}
function triggers(): HTMLElement[] {
return Array.from(document.querySelectorAll<HTMLElement>('[data-value]'));
}
function content(): HTMLElement | null {
return document.querySelector<HTMLElement>('[role="menu"]');
}
/** A menubar with a content panel under each menu, optional disabled triggers. */
function mountFull(opts: { dir?: 'ltr' | 'rtl'; loop?: boolean; disabled?: string[] } = {}) {
const labels = ['File', 'Edit', 'View'];
const disabled = new Set(opts.disabled ?? []);
const Harness = defineComponent({
setup() {
return () =>
h(MenubarRoot, { dir: opts.dir, loop: opts.loop }, {
default: () =>
labels.map(label =>
h(MenubarMenu, { value: label.toLowerCase() }, {
default: () => [
h(MenubarTrigger, { disabled: disabled.has(label.toLowerCase()) }, { default: () => label }),
h(MenubarPortal, null, {
default: () =>
h(MenubarContent, null, {
default: () => h(MenubarItem, null, { default: () => `${label} item` }),
}),
}),
],
}),
),
});
},
});
return track(mount(Harness, { attachTo: document.body }));
}
describe('menubar — roving tabindex (single tab stop)', () => {
it('makes exactly one trigger tabbable; the first by default', async () => {
mountFull();
await nextTick();
const all = triggers();
expect(all.map(t => t.getAttribute('tabindex'))).toEqual(['0', '-1', '-1']);
});
it('moves the single tab stop to whichever trigger is focused', async () => {
mountFull();
const [, edit] = triggers();
edit!.focus();
await nextTick();
expect(triggers().map(t => t.getAttribute('tabindex'))).toEqual(['-1', '0', '-1']);
});
it('never makes a disabled trigger the tab stop', async () => {
mountFull({ disabled: ['file'] });
await nextTick();
const all = triggers();
// File is disabled, so the first tabbable falls through to Edit.
expect(all[0]!.getAttribute('tabindex')).toBe('-1');
expect(all[1]!.getAttribute('tabindex')).toBe('0');
});
});
describe('menubar — trigger attributes', () => {
it('exposes data-value mirroring the menu value', () => {
mountFull();
expect(triggers().map(t => t.dataset['value'])).toEqual(['file', 'edit', 'view']);
});
it('sets data-highlighted only while the trigger has focus', async () => {
mountFull();
const [file] = triggers();
expect(file!.hasAttribute('data-highlighted')).toBe(false);
file!.focus();
await nextTick();
expect(file!.hasAttribute('data-highlighted')).toBe(true);
file!.blur();
await nextTick();
expect(file!.hasAttribute('data-highlighted')).toBe(false);
});
it('omits aria-controls while the menu is closed and sets it once open', async () => {
mountFull();
const [file] = triggers();
expect(file!.hasAttribute('aria-controls')).toBe(false);
keydown(file!, 'Enter');
await nextTick();
await nextTick();
expect(file!.getAttribute('aria-expanded')).toBe('true');
expect(file!.getAttribute('aria-controls')).toBeTruthy();
});
});
describe('menubar — content props', () => {
it('honors a consumer-supplied align instead of forcing start', async () => {
const Harness = defineComponent({
setup() {
return () =>
h(MenubarRoot, { defaultValue: 'file' }, {
default: () =>
h(MenubarMenu, { value: 'file' }, {
default: () => [
h(MenubarTrigger, null, { default: () => 'File' }),
h(MenubarPortal, null, {
default: () =>
h(MenubarContent, { align: 'end' }, {
default: () => h(MenubarItem, null, { default: () => 'x' }),
}),
}),
],
}),
});
},
});
track(mount(Harness, { attachTo: document.body }));
await nextTick();
await nextTick();
expect(content()!.getAttribute('data-align')).toBe('end');
});
it('exposes the menubar trigger-size CSS custom properties on the content', async () => {
mountFull();
const [file] = triggers();
keydown(file!, 'Enter');
await nextTick();
await nextTick();
const style = content()!.getAttribute('style') ?? '';
expect(style).toContain('--primitives-menubar-trigger-width');
expect(style).toContain('--primitives-menubar-trigger-height');
});
});
describe('menubar — pointerdown guards', () => {
it('ignores a macOS ctrl+click (does not open the menu)', async () => {
mountFull();
const [file] = triggers();
file!.dispatchEvent(new PointerEvent('pointerdown', { button: 0, ctrlKey: true, bubbles: true, cancelable: true }));
await nextTick();
expect(file!.getAttribute('data-state')).toBe('closed');
});
it('opens on a plain left click', async () => {
mountFull();
const [file] = triggers();
file!.dispatchEvent(new PointerEvent('pointerdown', { button: 0, bubbles: true, cancelable: true }));
await nextTick();
await nextTick();
expect(file!.getAttribute('data-state')).toBe('open');
});
});
describe('menubar — cross-menu arrow navigation while a menu is open', () => {
it('ArrowRight inside open content switches to the next menubar menu (ltr)', async () => {
mountFull();
const [file] = triggers();
keydown(file!, 'Enter');
await nextTick();
await nextTick();
const panel = content();
expect(panel).toBeTruthy();
keydown(panel!, 'ArrowRight');
await nextTick();
await nextTick();
// The Edit menu is now the open one.
expect(triggers()[1]!.getAttribute('data-state')).toBe('open');
expect(triggers()[0]!.getAttribute('data-state')).toBe('closed');
});
it('ArrowLeft inside open content switches to the previous menubar menu (ltr)', async () => {
mountFull();
const [, edit] = triggers();
keydown(edit!, 'Enter');
await nextTick();
await nextTick();
keydown(content()!, 'ArrowLeft');
await nextTick();
await nextTick();
expect(triggers()[0]!.getAttribute('data-state')).toBe('open');
});
it('ArrowRight on the last menu loops to the first when loop=true', async () => {
mountFull({ loop: true });
const all = triggers();
keydown(all.at(-1)!, 'Enter');
await nextTick();
await nextTick();
keydown(content()!, 'ArrowRight');
await nextTick();
await nextTick();
expect(triggers()[0]!.getAttribute('data-state')).toBe('open');
});
it('ArrowRight on the last menu stays put when loop=false', async () => {
mountFull({ loop: false });
const all = triggers();
keydown(all.at(-1)!, 'Enter');
await nextTick();
await nextTick();
keydown(content()!, 'ArrowRight');
await nextTick();
await nextTick();
expect(triggers().at(-1)!.getAttribute('data-state')).toBe('open');
});
it('reverses the arrow direction in RTL', async () => {
mountFull({ dir: 'rtl' });
const [file] = triggers();
keydown(file!, 'Enter');
await nextTick();
await nextTick();
// In RTL the "next" key is ArrowLeft.
keydown(content()!, 'ArrowLeft');
await nextTick();
await nextTick();
expect(triggers()[1]!.getAttribute('data-state')).toBe('open');
});
});
describe('menubar — focus restore on close', () => {
it('does not yank focus back to the trigger after interacting outside', async () => {
// An external focus target the user moves into.
const outside = document.createElement('input');
document.body.appendChild(outside);
mountFull();
const [file] = triggers();
// Keyboard-open so the trigger would normally be refocused on close.
keydown(file!, 'Enter');
await nextTick();
await nextTick();
expect(content()).toBeTruthy();
// User clicks/focuses an element outside the menu.
outside.focus();
outside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true }));
document.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
await nextTick();
await nextTick();
await nextTick();
// Focus stayed where the user put it (not snapped back to the trigger).
expect(document.activeElement).not.toBe(file);
outside.remove();
});
it('refocuses the trigger after a keyboard-open menu is closed via Escape', async () => {
mountFull();
const [file] = triggers();
keydown(file!, 'Enter');
await nextTick();
await nextTick();
const panel = content();
expect(panel).toBeTruthy();
keydown(panel!, 'Escape');
await nextTick();
await nextTick();
await nextTick();
expect(document.activeElement).toBe(file);
});
});
describe('menubar — root slot', () => {
it('exposes the open menu value to the default slot', async () => {
const seen = ref<string | undefined>('untouched');
const Harness = defineComponent({
setup() {
return () =>
h(MenubarRoot, { defaultValue: 'edit' }, {
default: (slotProps: any) => {
seen.value = slotProps.modelValue;
return h(MenubarMenu, { value: 'edit' }, {
default: () => h(MenubarTrigger, null, { default: () => 'Edit' }),
});
},
});
},
});
track(mount(Harness, { attachTo: document.body }));
await nextTick();
expect(seen.value).toBe('edit');
});
});
describe('menubar — submenu uncontrolled mode', () => {
function mountWithSub(props: { defaultOpen?: boolean } = {}) {
const Harness = defineComponent({
setup() {
return () =>
h(MenubarRoot, { defaultValue: 'file' }, {
default: () =>
h(MenubarMenu, { value: 'file' }, {
default: () => [
h(MenubarTrigger, null, { default: () => 'File' }),
h(MenubarPortal, null, {
default: () =>
h(MenubarContent, null, {
default: () =>
h(MenubarSub, { defaultOpen: props.defaultOpen }, {
default: () => [
h(MenubarSubTrigger, null, { default: () => 'More' }),
h(MenubarPortal, null, {
default: () =>
h(MenubarSubContent, null, {
default: () => h(MenubarItem, null, { default: () => 'Deep' }),
}),
}),
],
}),
}),
}),
],
}),
});
},
});
return track(mount(Harness, { attachTo: document.body }));
}
it('opens the submenu initially when defaultOpen is true (uncontrolled)', async () => {
mountWithSub({ defaultOpen: true });
await nextTick();
await nextTick();
const subTrigger = document.querySelector<HTMLElement>('[data-primitives-menubar-subtrigger]');
expect(subTrigger).toBeTruthy();
expect(subTrigger!.getAttribute('data-state')).toBe('open');
});
it('keeps the submenu closed by default', async () => {
mountWithSub();
await nextTick();
await nextTick();
const subTrigger = document.querySelector<HTMLElement>('[data-primitives-menubar-subtrigger]');
expect(subTrigger!.getAttribute('data-state')).toBe('closed');
});
it('marks the sub-trigger so cross-menu nav does not hijack the open key', async () => {
mountWithSub();
await nextTick();
await nextTick();
expect(document.querySelector('[data-primitives-menubar-subtrigger]')).toBeTruthy();
});
});

Some files were not shown because too many files have changed in this diff Show More