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:
@@ -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');
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
· Status bar: <span class="font-medium text-fg">{{ showStatusBar ? 'on' : 'off' }}</span>
|
||||
· Activity bar: <span class="font-medium text-fg">{{ showActivityBar ? 'on' : 'off' }}</span>
|
||||
<template v-if="lastAction">
|
||||
· 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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user