feat(primitives): add menu, dropdown-menu, context-menu, and menubar primitives
Implements WAI-ARIA APG-compliant headless menu primitive families: - menu: base primitive with MenuRoot, MenuContent, MenuItem, MenuCheckboxItem, MenuRadioGroup/Item, MenuSub, and helpers - dropdown-menu: DropdownMenuRoot with trigger anchoring - context-menu: ContextMenuRoot with right-click virtual anchor - menubar: MenubarRoot with keyboard navigation between menus Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import type { PopperAnchorProps } from '../popper';
|
||||
|
||||
export interface MenuAnchorProps extends PopperAnchorProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PopperAnchor } from '../popper';
|
||||
|
||||
const props = defineProps<MenuAnchorProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopperAnchor v-bind="props">
|
||||
<slot />
|
||||
</PopperAnchor>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import type { PopperArrowProps } from '../popper';
|
||||
|
||||
export interface MenuArrowProps extends PopperArrowProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PopperArrow } from '../popper';
|
||||
|
||||
const props = defineProps<MenuArrowProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopperArrow v-bind="props">
|
||||
<slot />
|
||||
</PopperArrow>
|
||||
</template>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import type { MenuItemImplEmits, MenuItemImplProps } from './MenuItemImpl.vue';
|
||||
import type { CheckedState } from './types';
|
||||
|
||||
export interface MenuCheckboxItemProps extends MenuItemImplProps {
|
||||
checked?: CheckedState;
|
||||
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>();
|
||||
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 selectEvent = new CustomEvent(ITEM_SELECT, { bubbles: true, cancelable: true })
|
||||
;(event.currentTarget as HTMLElement).dispatchEvent(selectEvent);
|
||||
emit('select', event);
|
||||
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 />
|
||||
</MenuItemImpl>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import type { MenuContentImplEmits, MenuContentImplProps } from './MenuContentImpl.vue';
|
||||
|
||||
export interface MenuContentProps extends MenuContentImplProps {
|
||||
forceMount?: boolean;
|
||||
}
|
||||
export type MenuContentEmits = MenuContentImplEmits;
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Presence } from '../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,183 @@
|
||||
<script lang="ts">
|
||||
import type { PopperContentProps } from '../popper';
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
export interface MenuContentImplProps extends PrimitiveProps, Pick<PopperContentProps,
|
||||
| 'side' | 'sideOffset' | 'sideFlip' | 'align' | 'alignOffset' | 'alignFlip'
|
||||
| 'avoidCollisions' | 'collisionBoundary' | 'collisionPadding' | 'arrowPadding'
|
||||
| 'sticky' | 'hideWhenDetached' | 'positionStrategy' | 'updatePositionStrategy'
|
||||
| 'reference' | 'prioritizePosition'
|
||||
> {
|
||||
loop?: boolean;
|
||||
trapFocus?: boolean;
|
||||
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];
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onScopeDispose, ref } from 'vue';
|
||||
|
||||
import { DismissableLayer } from '../dismissable-layer';
|
||||
import { FocusScope } from '../focus-scope';
|
||||
import { PopperContent } from '../popper';
|
||||
import { RovingFocusGroup } from '../roving-focus';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { provideMenuContentContext, useMenuContext, useMenuRootContext } from './context';
|
||||
import { FIRST_LAST_KEYS, getNextMatch, getOpenState, isPointerInGraceArea } from './utils';
|
||||
|
||||
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();
|
||||
|
||||
const searchRef = ref('');
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function clearSearch() {
|
||||
clearTimeout(searchTimer);
|
||||
searchRef.value = '';
|
||||
}
|
||||
|
||||
onScopeDispose(clearSearch);
|
||||
|
||||
const pointerGraceTimerRef = ref<number>(0);
|
||||
const pointerGraceIntentRef = ref<{ area: Array<{ x: number; y: number }>; side: 'left' | 'right' } | null>(null);
|
||||
|
||||
provideMenuContentContext({
|
||||
onItemEnter: (event) => {
|
||||
if (pointerGraceIntentRef.value) {
|
||||
return isPointerInGraceArea(event, pointerGraceIntentRef.value.area);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onItemLeave: (_event) => {
|
||||
contentElement.value?.focus({ preventScroll: true });
|
||||
},
|
||||
onTriggerLeave: (event) => {
|
||||
if (pointerGraceIntentRef.value) {
|
||||
return isPointerInGraceArea(event, pointerGraceIntentRef.value.area);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
searchRef,
|
||||
pointerGraceTimerRef,
|
||||
onPointerGraceIntentChange: (intent) => {
|
||||
pointerGraceIntentRef.value = intent;
|
||||
},
|
||||
});
|
||||
|
||||
function handleMountAutoFocus(event: Event) {
|
||||
event.preventDefault();
|
||||
if (rootCtx.isUsingKeyboardRef.value) {
|
||||
contentElement.value?.focus({ preventScroll: true });
|
||||
}
|
||||
emit('openAutoFocus', event);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const isCharKey = event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey;
|
||||
if (isCharKey) {
|
||||
clearTimeout(searchTimer);
|
||||
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 });
|
||||
searchTimer = setTimeout(clearSearch, 1000);
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (FIRST_LAST_KEYS.includes(event.key)) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBlur(event: FocusEvent) {
|
||||
const content = contentElement.value;
|
||||
if (!content) return;
|
||||
if (!content.contains(event.relatedTarget as Node)) {
|
||||
clearSearch();
|
||||
}
|
||||
}
|
||||
</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="{
|
||||
'--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)',
|
||||
}"
|
||||
v-bind="popperProps"
|
||||
@keydown="handleKeyDown"
|
||||
@blur="handleBlur"
|
||||
>
|
||||
<slot />
|
||||
</PopperContent>
|
||||
</RovingFocusGroup>
|
||||
</DismissableLayer>
|
||||
</FocusScope>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
export interface MenuGroupProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useId } from '../config-provider';
|
||||
import { Primitive } from '../primitive';
|
||||
import { provideMenuGroupContext } from './context';
|
||||
|
||||
const { as = 'div', asChild } = defineProps<MenuGroupProps>();
|
||||
const id = useId(undefined, 'menu-group');
|
||||
provideMenuGroupContext({ id: id.value });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive :as="as" :as-child="asChild" role="group" :id="id">
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import type { MenuItemImplEmits, MenuItemImplProps } from './MenuItemImpl.vue';
|
||||
|
||||
export interface MenuItemProps extends MenuItemImplProps {}
|
||||
export type MenuItemEmits = MenuItemImplEmits;
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
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();
|
||||
|
||||
function handleSelect(event: Event) {
|
||||
const selectEvent = new CustomEvent(ITEM_SELECT, { bubbles: true, cancelable: true })
|
||||
;(event.currentTarget as HTMLElement).dispatchEvent(selectEvent);
|
||||
emit('select', event);
|
||||
if (!selectEvent.defaultPrevented) {
|
||||
rootCtx.onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MenuItemImpl v-bind="props" @select="handleSelect">
|
||||
<slot />
|
||||
</MenuItemImpl>
|
||||
</template>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
export interface MenuItemImplProps extends PrimitiveProps {
|
||||
disabled?: boolean;
|
||||
textValue?: string;
|
||||
}
|
||||
export interface MenuItemImplEmits {
|
||||
select: [event: Event];
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, shallowRef } from 'vue';
|
||||
|
||||
import { RovingFocusItem } from '../roving-focus';
|
||||
import { Primitive } from '../primitive';
|
||||
import { useMenuContentContext } from './context';
|
||||
|
||||
const {
|
||||
disabled = false,
|
||||
textValue: textValueProp,
|
||||
as = 'div',
|
||||
asChild,
|
||||
} = defineProps<MenuItemImplProps>();
|
||||
|
||||
const emit = defineEmits<MenuItemImplEmits>();
|
||||
|
||||
const contentCtx = useMenuContentContext();
|
||||
|
||||
const itemRef = shallowRef<HTMLElement | null>(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;
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
const el = event.currentTarget as HTMLElement;
|
||||
el.click();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RovingFocusItem as-child :focusable="!disabled" :active="isHighlighted">
|
||||
<Primitive
|
||||
:ref="(el: unknown) => { itemRef = el as HTMLElement | null }"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
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"
|
||||
:tabindex="isHighlighted ? 0 : -1"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerleave="handlePointerLeave"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@click="handleClick"
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</RovingFocusItem>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
export interface MenuItemIndicatorProps extends PrimitiveProps {
|
||||
forceMount?: boolean;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Presence } from '../presence';
|
||||
import { Primitive } from '../primitive';
|
||||
import { useMenuItemIndicatorContext } from './context';
|
||||
import { getCheckedState, isIndeterminate } from './utils';
|
||||
|
||||
const { as = 'span', asChild, 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"
|
||||
:as-child="asChild"
|
||||
:data-state="getCheckedState(ctx.checkedState.value)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</Presence>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
export interface MenuLabelProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Primitive } from '../primitive';
|
||||
|
||||
const { as = 'div', asChild } = defineProps<MenuLabelProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive :as="as" :as-child="asChild">
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import type { PortalProps } from '../teleport';
|
||||
|
||||
export interface MenuPortalProps extends PortalProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Portal } from '../teleport';
|
||||
|
||||
const { to, defer, disabled } = defineProps<MenuPortalProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Portal :to="to" :defer="defer" :disabled="disabled">
|
||||
<slot />
|
||||
</Portal>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
export interface MenuRadioGroupProps extends PrimitiveProps {
|
||||
modelValue?: string;
|
||||
defaultValue?: string;
|
||||
}
|
||||
export interface MenuRadioGroupEmits {
|
||||
'update:modelValue': [value: string];
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Primitive } from '../primitive';
|
||||
import { provideMenuRadioGroupContext } from './context';
|
||||
|
||||
const { modelValue, defaultValue, as = 'div', asChild } = defineProps<MenuRadioGroupProps>();
|
||||
const emit = defineEmits<MenuRadioGroupEmits>();
|
||||
|
||||
const local = ref(defaultValue);
|
||||
const value = computed(() => modelValue !== undefined ? modelValue : local.value);
|
||||
|
||||
provideMenuRadioGroupContext({
|
||||
modelValue: value,
|
||||
onValueChange: (v) => {
|
||||
local.value = v;
|
||||
emit('update:modelValue', v);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive :as="as" :as-child="asChild" role="group">
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import type { MenuItemImplEmits, MenuItemImplProps } from './MenuItemImpl.vue';
|
||||
|
||||
export interface MenuRadioItemProps extends MenuItemImplProps {
|
||||
value: string;
|
||||
}
|
||||
export type MenuRadioItemEmits = MenuItemImplEmits;
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
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>();
|
||||
|
||||
const radioCtx = useMenuRadioGroupContext();
|
||||
const rootCtx = useMenuRootContext();
|
||||
const checkedState = computed(() => radioCtx.modelValue.value === value);
|
||||
|
||||
provideMenuItemIndicatorContext({ checkedState });
|
||||
|
||||
function handleSelect(event: Event) {
|
||||
radioCtx.onValueChange(value);
|
||||
const selectEvent = new CustomEvent(ITEM_SELECT, { bubbles: true, cancelable: true })
|
||||
;(event.currentTarget as HTMLElement).dispatchEvent(selectEvent);
|
||||
emit('select', event);
|
||||
if (!selectEvent.defaultPrevented) rootCtx.onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MenuItemImpl
|
||||
v-bind="itemProps"
|
||||
role="menuitemradio"
|
||||
:aria-checked="checkedState"
|
||||
:data-state="getCheckedState(checkedState)"
|
||||
@select="handleSelect"
|
||||
>
|
||||
<slot />
|
||||
</MenuItemImpl>
|
||||
</template>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import type { Direction } from '../config-provider';
|
||||
|
||||
export interface MenuRootProps {
|
||||
open?: boolean;
|
||||
dir?: Direction;
|
||||
modal?: boolean;
|
||||
}
|
||||
export interface MenuRootEmits {
|
||||
'update:open': [value: boolean];
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { shallowRef, toRef } from 'vue';
|
||||
|
||||
import { useConfig } from '../config-provider';
|
||||
import { PopperRoot } from '../popper';
|
||||
import { provideMenuContext, provideMenuRootContext } from './context';
|
||||
import { useIsUsingKeyboard } from './useIsUsingKeyboard';
|
||||
|
||||
const {
|
||||
open = false,
|
||||
dir: dirProp,
|
||||
modal = true,
|
||||
} = defineProps<MenuRootProps>();
|
||||
|
||||
const emit = defineEmits<MenuRootEmits>();
|
||||
|
||||
defineSlots<{ default?: () => unknown }>();
|
||||
|
||||
const config = useConfig();
|
||||
const dirRef = toRef(() => dirProp ?? config.dir.value);
|
||||
const isUsingKeyboardRef = useIsUsingKeyboard();
|
||||
const content = shallowRef<HTMLElement | null>(null);
|
||||
const openRef = toRef(() => open);
|
||||
|
||||
provideMenuContext({
|
||||
open: openRef,
|
||||
onOpenChange: v => emit('update:open', v),
|
||||
content,
|
||||
onContentChange: (el) => { content.value = el; },
|
||||
});
|
||||
|
||||
provideMenuRootContext({
|
||||
onClose: () => emit('update:open', false),
|
||||
dir: dirRef,
|
||||
isUsingKeyboardRef,
|
||||
modal: toRef(() => modal),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopperRoot>
|
||||
<slot />
|
||||
</PopperRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import type { MenuContentImplEmits, MenuContentImplProps } from './MenuContentImpl.vue';
|
||||
|
||||
import { shallowRef, watchEffect } from 'vue';
|
||||
|
||||
import { useBodyScrollLock, useFocusGuard } from '@robonen/vue';
|
||||
import { useHideOthers } from '../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="emit('focusOutside', $event)"
|
||||
@interact-outside="emit('interactOutside', $event)"
|
||||
@dismiss="emit('dismiss')"
|
||||
@entry-focus="emit('entryFocus', $event)"
|
||||
@open-auto-focus="emit('openAutoFocus', $event)"
|
||||
>
|
||||
<slot />
|
||||
</MenuContentImpl>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
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="emit('dismiss')"
|
||||
@entry-focus="emit('entryFocus', $event)"
|
||||
@open-auto-focus="emit('openAutoFocus', $event)"
|
||||
>
|
||||
<slot />
|
||||
</MenuContentImpl>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
export interface MenuSeparatorProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Primitive } from '../primitive';
|
||||
|
||||
const { as = 'div', asChild } = defineProps<MenuSeparatorProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive :as="as" :as-child="asChild" role="separator" aria-orientation="horizontal">
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
export interface MenuSubProps {
|
||||
open?: boolean;
|
||||
}
|
||||
export interface MenuSubEmits {
|
||||
'update:open': [value: boolean];
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { shallowRef, toRef } from 'vue';
|
||||
|
||||
import { useId } from '../config-provider';
|
||||
import { PopperRoot } from '../popper';
|
||||
import { provideMenuContext, provideMenuSubContext } from './context';
|
||||
|
||||
const { open = false } = defineProps<MenuSubProps>();
|
||||
const emit = defineEmits<MenuSubEmits>();
|
||||
defineSlots<{ default?: (props: { open: boolean }) => unknown }>();
|
||||
|
||||
const openRef = toRef(() => open);
|
||||
const trigger = shallowRef<HTMLElement | null>(null);
|
||||
const contentId = useId(undefined, 'menu-sub-content');
|
||||
const triggerId = useId(undefined, 'menu-sub-trigger');
|
||||
|
||||
provideMenuContext({
|
||||
open: openRef,
|
||||
onOpenChange: v => emit('update:open', v),
|
||||
content: shallowRef(null),
|
||||
onContentChange: () => {},
|
||||
});
|
||||
|
||||
provideMenuSubContext({
|
||||
contentId,
|
||||
triggerId,
|
||||
trigger,
|
||||
onTriggerChange: (el) => { trigger.value = el; },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopperRoot>
|
||||
<slot :open="open" />
|
||||
</PopperRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import type { MenuContentImplEmits, MenuContentImplProps } from './MenuContentImpl.vue';
|
||||
|
||||
export interface MenuSubContentProps extends MenuContentImplProps {
|
||||
forceMount?: boolean;
|
||||
}
|
||||
export type MenuSubContentEmits = MenuContentImplEmits;
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Presence } from '../presence';
|
||||
import { useMenuContext, useMenuRootContext, useMenuSubContext } from './context';
|
||||
import MenuContentImpl from './MenuContentImpl.vue';
|
||||
|
||||
const { forceMount = false, ...contentProps } = defineProps<MenuSubContentProps>();
|
||||
const emit = defineEmits<MenuSubContentEmits>();
|
||||
|
||||
const menuCtx = useMenuContext();
|
||||
const subCtx = useMenuSubContext();
|
||||
const rootCtx = useMenuRootContext();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Presence :present="forceMount || menuCtx.open.value">
|
||||
<MenuContentImpl
|
||||
:id="subCtx.contentId.value"
|
||||
v-bind="contentProps"
|
||||
: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="emit('openAutoFocus', $event)"
|
||||
>
|
||||
<slot />
|
||||
</MenuContentImpl>
|
||||
</Presence>
|
||||
</template>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import type { MenuItemImplProps } from './MenuItemImpl.vue';
|
||||
|
||||
export interface MenuSubTriggerProps extends MenuItemImplProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PopperAnchor } from '../popper';
|
||||
import { useMenuContentContext, useMenuContext, useMenuRootContext, useMenuSubContext } from './context';
|
||||
import MenuItemImpl from './MenuItemImpl.vue';
|
||||
import { SUB_CLOSE_KEYS, SUB_OPEN_KEYS, getOpenState, isPointerInGraceArea } from './utils';
|
||||
|
||||
const props = defineProps<MenuSubTriggerProps>();
|
||||
|
||||
const menuCtx = useMenuContext();
|
||||
const subCtx = useMenuSubContext();
|
||||
const rootCtx = useMenuRootContext();
|
||||
const contentCtx = useMenuContentContext();
|
||||
|
||||
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 (event.pointerType === 'touch') return;
|
||||
if (props.disabled) return;
|
||||
if (contentCtx.onItemEnter(event)) return;
|
||||
if (!menuCtx.open.value) {
|
||||
clearTimeout(openTimer);
|
||||
openTimer = setTimeout(() => menuCtx.onOpenChange(true), 100);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerLeave(event: PointerEvent) {
|
||||
if (event.pointerType === 'touch') return;
|
||||
clearTimeout(openTimer);
|
||||
if (contentCtx.onTriggerLeave(event)) return;
|
||||
close();
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (props.disabled) 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();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopperAnchor as-child>
|
||||
<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.prevent
|
||||
>
|
||||
<slot />
|
||||
</MenuItemImpl>
|
||||
</PopperAnchor>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { CheckedState } from './types';
|
||||
import type { ComputedRef, Ref, ShallowRef } from 'vue';
|
||||
import type { Direction } from '../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 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<string | undefined>;
|
||||
onValueChange: (value: string) => 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,20 @@
|
||||
export type { CheckedState } from './types';
|
||||
|
||||
export { useMenuContext, useMenuContentContext, 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 MenuRootEmits, 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,86 @@
|
||||
import type { CheckedState } from './types';
|
||||
|
||||
import { getActiveElement } from '@robonen/platform/browsers';
|
||||
|
||||
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 isPointInPolygon(point: Point, polygon: Polygon): boolean {
|
||||
const { x, y } = point;
|
||||
let inside = false;
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const xi = polygon[i]!.x;
|
||||
const yi = polygon[i]!.y;
|
||||
const xj = polygon[j]!.x;
|
||||
const yj = polygon[j]!.y;
|
||||
const intersects = (yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
|
||||
if (intersects) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
export function isPointerInGraceArea(event: PointerEvent, area: Polygon): boolean {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user