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.
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import type { PopperAnchorProps } from '../../overlays/popper';
|
||||
|
||||
/**
|
||||
* The element the popup is positioned against, typically wrapping the Input and Trigger.
|
||||
* Acts as the Popper anchor and the boundary used for the blur-to-close heuristic.
|
||||
*/
|
||||
export interface ComboboxAnchorProps extends PopperAnchorProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, watchPostEffect } from 'vue';
|
||||
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { PopperAnchor } from '../../overlays/popper';
|
||||
import { useComboboxRootContext } from './context';
|
||||
|
||||
const props = defineProps<ComboboxAnchorProps>();
|
||||
|
||||
const { forwardRef, currentElement } = useForwardExpose();
|
||||
const rootCtx = useComboboxRootContext();
|
||||
|
||||
watchPostEffect(() => rootCtx.onParentChange(currentElement.value));
|
||||
onBeforeUnmount(() => rootCtx.onParentChange(undefined));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- PopperAnchor IS the single anchor element: the consumer's class (e.g. a
|
||||
flex row of Input + Trigger) and the slotted children must live on the
|
||||
SAME element. A nested Primitive split them — the class landed here while
|
||||
the children sat in an unstyled inner div, stacking them vertically. -->
|
||||
<PopperAnchor
|
||||
:ref="forwardRef"
|
||||
:as="props.as ?? 'div'"
|
||||
:reference="props.reference"
|
||||
>
|
||||
<slot />
|
||||
</PopperAnchor>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import type { PopperArrowProps } from '../../overlays/popper';
|
||||
|
||||
/**
|
||||
* An optional arrow that visually points from the popup back to the anchor. Renders only
|
||||
* while the combobox is open. Place inside ComboboxContent.
|
||||
*/
|
||||
export type ComboboxArrowProps = PopperArrowProps;
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
|
||||
import { PopperArrow } from '../../overlays/popper';
|
||||
import { useComboboxRootContext } from './context';
|
||||
|
||||
const props = defineProps<ComboboxArrowProps>();
|
||||
const { forwardRef } = useForwardExpose();
|
||||
const rootCtx = useComboboxRootContext();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopperArrow
|
||||
v-if="rootCtx.open.value"
|
||||
:ref="forwardRef"
|
||||
v-bind="props"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* A button that clears the current search term and refocuses the input. Typically shown
|
||||
* as an "x" inside the field while the user is typing.
|
||||
*/
|
||||
export interface ComboboxCancelProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { useComboboxRootContext } from './context';
|
||||
|
||||
const { as = 'button' } = defineProps<ComboboxCancelProps>();
|
||||
|
||||
const { forwardRef } = useForwardExpose();
|
||||
const rootCtx = useComboboxRootContext();
|
||||
|
||||
function handleClick() {
|
||||
rootCtx.onSearchTermChange('');
|
||||
const input = rootCtx.inputElement.value;
|
||||
if (input) {
|
||||
input.value = '';
|
||||
input.focus();
|
||||
}
|
||||
rootCtx.onUserInputtedChange(false);
|
||||
if (rootCtx.resetModelValueOnClear.value) rootCtx.clearModelValue();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
:type="as === 'button' ? 'button' : undefined"
|
||||
tabindex="-1"
|
||||
aria-label="Clear"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import type { ComboboxContentImplEmits, ComboboxContentImplProps } from './ComboboxContentImpl.vue';
|
||||
|
||||
/**
|
||||
* The popup listbox that holds the options. Mounts only while open (via Presence) and
|
||||
* positions itself relative to the anchor. Place the Viewport, Items, and Empty inside it.
|
||||
*/
|
||||
export interface ComboboxContentProps extends ComboboxContentImplProps {
|
||||
/** Keep the content mounted even while closed (for external animation libraries). @default false */
|
||||
forceMount?: boolean;
|
||||
}
|
||||
export type ComboboxContentEmits = ComboboxContentImplEmits;
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Presence } from '../../utilities/presence';
|
||||
import ComboboxContentImpl from './ComboboxContentImpl.vue';
|
||||
import { useComboboxRootContext } from './context';
|
||||
|
||||
const { forceMount = false, ...contentProps } = defineProps<ComboboxContentProps>();
|
||||
const emit = defineEmits<ComboboxContentEmits>();
|
||||
const rootCtx = useComboboxRootContext();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Presence :present="rootCtx.open.value" :force-mount="forceMount">
|
||||
<ComboboxContentImpl
|
||||
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)"
|
||||
>
|
||||
<slot />
|
||||
</ComboboxContentImpl>
|
||||
</Presence>
|
||||
</template>
|
||||
@@ -0,0 +1,187 @@
|
||||
<script lang="ts">
|
||||
import type { DismissableLayerEmits } from '../../utilities/dismissable-layer';
|
||||
import type { FocusScopeEmits } from '../../utilities/focus-scope';
|
||||
import type { PopperContentProps } from '../../overlays/popper';
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* Internal implementation of the content popup: wires up focus scoping, dismiss-on-outside,
|
||||
* Popper positioning, and the screen-reader result announcer. Use ComboboxContent instead.
|
||||
*/
|
||||
export interface ComboboxContentImplProps extends PrimitiveProps, /* @vue-ignore */ Partial<PopperContentProps> {
|
||||
/** Position strategy. @default 'popper' */
|
||||
position?: 'inline' | 'popper';
|
||||
/** Block outside pointer events. @default false */
|
||||
disableOutsidePointerEvents?: boolean;
|
||||
/** Lock body scroll while the content is open (reuses the shared body-scroll-lock). @default false */
|
||||
bodyLock?: boolean;
|
||||
/** Hide the content (`display: none`) when no items match the filter. @default false */
|
||||
hideWhenEmpty?: boolean;
|
||||
}
|
||||
|
||||
export interface ComboboxContentImplEmits {
|
||||
closeAutoFocus: FocusScopeEmits['unmountAutoFocus'];
|
||||
escapeKeyDown: DismissableLayerEmits['escapeKeyDown'];
|
||||
pointerDownOutside: DismissableLayerEmits['pointerDownOutside'];
|
||||
focusOutside: FocusScopeEmits['unmountAutoFocus'];
|
||||
interactOutside: DismissableLayerEmits['interactOutside'];
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, shallowRef, toRef, watch, watchPostEffect } from 'vue';
|
||||
|
||||
import { useBodyScrollLock, useForwardExpose } from '@robonen/vue';
|
||||
import { DismissableLayer } from '../../utilities/dismissable-layer';
|
||||
import { FocusScope } from '../../utilities/focus-scope';
|
||||
import { PopperContent } from '../../overlays/popper';
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { VisuallyHidden } from '../../utilities/visually-hidden';
|
||||
import { useHideOthers } from '../../internal/utils/useHideOthers';
|
||||
import { provideComboboxContentContext, useComboboxRootContext } from './context';
|
||||
|
||||
const props = defineProps<ComboboxContentImplProps>();
|
||||
const emit = defineEmits<ComboboxContentImplEmits>();
|
||||
|
||||
const { forwardRef, currentElement } = useForwardExpose();
|
||||
const rootCtx = useComboboxRootContext();
|
||||
|
||||
const viewportElement = shallowRef<HTMLElement | undefined>(undefined);
|
||||
|
||||
watchPostEffect(() => rootCtx.onContentChange(currentElement.value));
|
||||
onBeforeUnmount(() => rootCtx.onContentChange(undefined));
|
||||
|
||||
useHideOthers(toRef(() => rootCtx.parentElement.value));
|
||||
|
||||
const isEmpty = computed(() => rootCtx.filterState.value.count === 0);
|
||||
|
||||
// Body scroll lock is reference-counted and opt-in: acquire only while `bodyLock`
|
||||
// is set, releasing as soon as it flips off or the content unmounts.
|
||||
let releaseScrollLock: (() => void) | undefined;
|
||||
watch(
|
||||
() => props.bodyLock ?? false,
|
||||
(locked) => {
|
||||
if (locked && !releaseScrollLock) {
|
||||
releaseScrollLock = useBodyScrollLock();
|
||||
}
|
||||
else if (!locked && releaseScrollLock) {
|
||||
releaseScrollLock();
|
||||
releaseScrollLock = undefined;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
onBeforeUnmount(() => {
|
||||
releaseScrollLock?.();
|
||||
releaseScrollLock = undefined;
|
||||
});
|
||||
|
||||
provideComboboxContentContext({
|
||||
viewportElement,
|
||||
onViewportChange: (el) => { viewportElement.value = el; },
|
||||
position: toRef(() => props.position ?? 'popper'),
|
||||
});
|
||||
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
rootCtx.onOpenChange(false);
|
||||
emit('escapeKeyDown', event);
|
||||
}
|
||||
|
||||
// Interactions within the anchor (input, trigger, cancel button, padding) must not
|
||||
// dismiss the popup — e.g. the root focuses the input right after opening, which
|
||||
// fires a focus-outside from the content layer's perspective.
|
||||
function handleInteractOutside(event: PointerEvent | MouseEvent | FocusEvent) {
|
||||
const target = event.target as Element | null;
|
||||
const parent = rootCtx.parentElement.value;
|
||||
const input = rootCtx.inputElement.value;
|
||||
const trigger = rootCtx.triggerElement.value;
|
||||
if (target && (parent?.contains(target) || input?.contains(target) || trigger?.contains(target))) {
|
||||
event.preventDefault();
|
||||
}
|
||||
emit('interactOutside', event);
|
||||
}
|
||||
|
||||
function handlePointerDownOutside(event: PointerEvent | MouseEvent) {
|
||||
emit('pointerDownOutside', event);
|
||||
}
|
||||
|
||||
function handleFocusOutside(event: FocusEvent) {
|
||||
emit('focusOutside', event);
|
||||
}
|
||||
|
||||
function handleCloseAutoFocus(event: Event) {
|
||||
emit('closeAutoFocus', event);
|
||||
}
|
||||
|
||||
const dataEmpty = computed(() => (isEmpty.value ? '' : undefined));
|
||||
const hiddenStyle = computed(() =>
|
||||
(props.hideWhenEmpty && isEmpty.value) ? ({ display: 'none' } as const) : undefined,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FocusScope
|
||||
as="template"
|
||||
:loop="false"
|
||||
:trapped="false"
|
||||
@mount-auto-focus.prevent
|
||||
@unmount-auto-focus="handleCloseAutoFocus"
|
||||
>
|
||||
<DismissableLayer
|
||||
as="template"
|
||||
:disable-outside-pointer-events="props.disableOutsidePointerEvents ?? false"
|
||||
@escape-key-down="handleEscape"
|
||||
@interact-outside="handleInteractOutside"
|
||||
@pointer-down-outside="handlePointerDownOutside"
|
||||
@focus-outside="handleFocusOutside"
|
||||
@dismiss="rootCtx.onOpenChange(false)"
|
||||
>
|
||||
<PopperContent
|
||||
v-if="(props.position ?? 'popper') === 'popper'"
|
||||
:ref="forwardRef"
|
||||
:as="props.as ?? 'div'"
|
||||
:side="props.side ?? 'bottom'"
|
||||
:side-offset="props.sideOffset ?? 4"
|
||||
:align="props.align ?? 'start'"
|
||||
:align-offset="props.alignOffset"
|
||||
:avoid-collisions="props.avoidCollisions"
|
||||
:collision-boundary="props.collisionBoundary"
|
||||
:collision-padding="props.collisionPadding"
|
||||
:arrow-padding="props.arrowPadding"
|
||||
:sticky="props.sticky"
|
||||
:hide-when-detached="props.hideWhenDetached"
|
||||
:update-position-strategy="props.updatePositionStrategy"
|
||||
:id="rootCtx.contentId.value"
|
||||
role="listbox"
|
||||
:aria-multiselectable="rootCtx.multiple.value || undefined"
|
||||
:data-state="rootCtx.open.value ? 'open' : 'closed'"
|
||||
:data-empty="dataEmpty"
|
||||
:style="hiddenStyle"
|
||||
data-primitives-combobox-content
|
||||
>
|
||||
<VisuallyHidden role="status" aria-live="polite" data-primitives-combobox-announce>
|
||||
{{ rootCtx.filterState.value.count === 1 ? '1 result available.' : `${rootCtx.filterState.value.count} results available.` }}
|
||||
</VisuallyHidden>
|
||||
<slot />
|
||||
</PopperContent>
|
||||
|
||||
<Primitive
|
||||
v-else
|
||||
:ref="forwardRef"
|
||||
:as="props.as ?? 'div'"
|
||||
:id="rootCtx.contentId.value"
|
||||
role="listbox"
|
||||
:aria-multiselectable="rootCtx.multiple.value || undefined"
|
||||
:data-state="rootCtx.open.value ? 'open' : 'closed'"
|
||||
:data-empty="dataEmpty"
|
||||
:style="hiddenStyle"
|
||||
data-primitives-combobox-content
|
||||
>
|
||||
<VisuallyHidden role="status" aria-live="polite" data-primitives-combobox-announce>
|
||||
{{ rootCtx.filterState.value.count === 1 ? '1 result available.' : `${rootCtx.filterState.value.count} results available.` }}
|
||||
</VisuallyHidden>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</DismissableLayer>
|
||||
</FocusScope>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* Fallback content shown when the current search term matches no items. Renders only when
|
||||
* the filtered count is zero, unless `always` is set.
|
||||
*/
|
||||
export interface ComboboxEmptyProps extends PrimitiveProps {
|
||||
/** Render even when items exist but none are filtered out. */
|
||||
always?: boolean;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { useComboboxRootContext } from './context';
|
||||
|
||||
const { as = 'div', always = false } = defineProps<ComboboxEmptyProps>();
|
||||
|
||||
const { forwardRef } = useForwardExpose();
|
||||
const rootCtx = useComboboxRootContext();
|
||||
|
||||
const shouldRender = computed(() => {
|
||||
if (always) return true;
|
||||
return rootCtx.filterState.value.count === 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
v-if="shouldRender"
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
role="presentation"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* Groups related items under a shared ComboboxLabel. Hides itself automatically when none
|
||||
* of its items survive the current filter.
|
||||
*/
|
||||
export interface ComboboxGroupProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { useId } from '../../utilities/config-provider';
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { provideComboboxGroupContext, useComboboxRootContext } from './context';
|
||||
|
||||
const { as = 'div' } = defineProps<ComboboxGroupProps>();
|
||||
|
||||
const { forwardRef } = useForwardExpose();
|
||||
const rootCtx = useComboboxRootContext();
|
||||
const id = useId(undefined, 'combobox-group');
|
||||
|
||||
const labelId = ref<string | undefined>(undefined);
|
||||
|
||||
const isVisible = computed(() => rootCtx.filterState.value.groups.has(id.value));
|
||||
|
||||
onMounted(() => rootCtx.onGroupRegister(id.value));
|
||||
onBeforeUnmount(() => rootCtx.onGroupUnregister(id.value));
|
||||
|
||||
provideComboboxGroupContext({
|
||||
id,
|
||||
labelId,
|
||||
registerLabel: (labelElId) => { labelId.value = labelElId; },
|
||||
unregisterLabel: () => { labelId.value = undefined; },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
v-show="isVisible"
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
role="group"
|
||||
:aria-labelledby="labelId"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,254 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* The text field users type into to filter options. Owns the search term, ARIA combobox
|
||||
* semantics, and keyboard navigation (arrows, Home/End, Enter to select, Escape to close).
|
||||
*/
|
||||
export interface ComboboxInputProps extends PrimitiveProps {
|
||||
/** Disable the input. */
|
||||
disabled?: boolean;
|
||||
/** Focus the input on mount. */
|
||||
autoFocus?: boolean;
|
||||
/** Open the combobox when the input is focused. Combined (OR) with the Root-level `openOnFocus`. */
|
||||
openOnFocus?: boolean;
|
||||
/** Open the combobox when the input is clicked. Combined (OR) with the Root-level `openOnClick`. */
|
||||
openOnClick?: boolean;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, watch } from 'vue';
|
||||
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { useComboboxRootContext } from './context';
|
||||
import { INPUT_OPEN_KEYS } from './utils';
|
||||
|
||||
const props = defineProps<ComboboxInputProps>();
|
||||
const {
|
||||
as = 'input',
|
||||
disabled = false,
|
||||
autoFocus = false,
|
||||
} = props;
|
||||
|
||||
const { forwardRef, currentElement } = useForwardExpose();
|
||||
const rootCtx = useComboboxRootContext();
|
||||
|
||||
// Opens when EITHER the per-input prop or the Root-level default opts in. (Boolean
|
||||
// props coerce an absent value to `false`, so a nullish-merge can't distinguish
|
||||
// "unset" from "false" — OR composition is the correct, back-compatible semantic.)
|
||||
const shouldOpenOnFocus = computed(() => props.openOnFocus || rootCtx.openOnFocus.value);
|
||||
const shouldOpenOnClick = computed(() => props.openOnClick || rootCtx.openOnClick.value);
|
||||
|
||||
// IME composition guard: while composing, Enter confirms the candidate and must
|
||||
// not commit a combobox selection.
|
||||
let isComposing = false;
|
||||
|
||||
const isDisabled = computed(() => disabled || rootCtx.disabled.value);
|
||||
const activeDescendant = computed(() => rootCtx.selectedValueId.value);
|
||||
|
||||
function displayString(value: unknown): string {
|
||||
if (rootCtx.displayValue) return rootCtx.displayValue(value);
|
||||
if (value === undefined || value === null) return '';
|
||||
if (Array.isArray(value)) return '';
|
||||
if (typeof value === 'object') return '';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function syncDisplayValue() {
|
||||
const input = currentElement.value as HTMLInputElement | undefined;
|
||||
if (!input) return;
|
||||
const next = displayString(rootCtx.modelValue.value);
|
||||
if (input.value !== next) input.value = next;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const el = currentElement.value as HTMLInputElement | undefined;
|
||||
rootCtx.onInputChange(el);
|
||||
if (el) {
|
||||
el.value = rootCtx.searchTerm.value || displayString(rootCtx.modelValue.value);
|
||||
}
|
||||
if (autoFocus) setTimeout(() => el?.focus(), 1);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => rootCtx.onInputChange(undefined));
|
||||
|
||||
watch(() => rootCtx.modelValue.value, () => {
|
||||
if (rootCtx.isUserInputted.value) return;
|
||||
if (!rootCtx.resetSearchTermOnSelect.value && rootCtx.searchTerm.value) return;
|
||||
rootCtx.onSearchTermChange('');
|
||||
syncDisplayValue();
|
||||
}, { deep: true });
|
||||
|
||||
watch(() => rootCtx.searchTerm.value, (v) => {
|
||||
const input = currentElement.value as HTMLInputElement | undefined;
|
||||
if (!input) return;
|
||||
if (!v && !rootCtx.isUserInputted.value) {
|
||||
syncDisplayValue();
|
||||
return;
|
||||
}
|
||||
if (input.value !== v) input.value = v;
|
||||
});
|
||||
|
||||
watch(() => rootCtx.filterState.value, (newState, oldState) => {
|
||||
if (oldState && oldState.count === 0 && newState.count > 0) {
|
||||
rootCtx.highlightFirstItem();
|
||||
}
|
||||
});
|
||||
|
||||
function moveHighlight(delta: number) {
|
||||
const els = rootCtx.getVisibleItemElements();
|
||||
if (els.length === 0) return;
|
||||
const curId = rootCtx.selectedValueId.value;
|
||||
let idx = -1;
|
||||
if (curId) {
|
||||
for (let i = 0; i < els.length; i++) {
|
||||
if (els[i]!.id === curId) {
|
||||
idx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let nextIdx: number;
|
||||
if (idx === -1) nextIdx = delta > 0 ? 0 : els.length - 1;
|
||||
else nextIdx = (idx + delta + els.length) % els.length;
|
||||
rootCtx.highlightItemById(els[nextIdx]!.id);
|
||||
}
|
||||
|
||||
function commitHighlighted(event: KeyboardEvent) {
|
||||
const id = rootCtx.selectedValueId.value;
|
||||
if (id) {
|
||||
const info = rootCtx.allItems.value.get(id);
|
||||
// Route through the highlighted item's cancelable `select` flow so keyboard
|
||||
// Enter fires the same `select` event (and honours preventDefault) as a click.
|
||||
if (info) return info.select(event);
|
||||
}
|
||||
const value = rootCtx.selectedValue.value;
|
||||
if (value === undefined) return false;
|
||||
rootCtx.onValueChange(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const next = target.value;
|
||||
rootCtx.onUserInputtedChange(true);
|
||||
rootCtx.onSearchTermChange(next);
|
||||
if (!rootCtx.open.value) {
|
||||
rootCtx.onOpenChange(true);
|
||||
nextTick(() => rootCtx.highlightFirstItem());
|
||||
}
|
||||
else {
|
||||
nextTick(() => rootCtx.highlightFirstItem());
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (isDisabled.value) return;
|
||||
const { key } = event;
|
||||
|
||||
if (!rootCtx.open.value && INPUT_OPEN_KEYS.includes(key)) {
|
||||
event.preventDefault();
|
||||
rootCtx.onOpenChange(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rootCtx.open.value) return;
|
||||
|
||||
switch (key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
moveHighlight(1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
moveHighlight(-1);
|
||||
break;
|
||||
case 'Home': {
|
||||
event.preventDefault();
|
||||
const first = rootCtx.getVisibleItemElements()[0];
|
||||
if (first) rootCtx.highlightItemById(first.id);
|
||||
break;
|
||||
}
|
||||
case 'End': {
|
||||
event.preventDefault();
|
||||
const list = rootCtx.getVisibleItemElements();
|
||||
const last = list[list.length - 1];
|
||||
if (last) rootCtx.highlightItemById(last.id);
|
||||
break;
|
||||
}
|
||||
case 'Enter':
|
||||
// While composing an IME candidate, Enter confirms the candidate — it must
|
||||
// not select a combobox item.
|
||||
if (isComposing) break;
|
||||
if (commitHighlighted(event)) event.preventDefault();
|
||||
break;
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
rootCtx.onOpenChange(false);
|
||||
if (rootCtx.resetSearchTermOnBlur.value) rootCtx.onSearchTermChange('');
|
||||
break;
|
||||
case 'Tab':
|
||||
rootCtx.onOpenChange(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
if (shouldOpenOnFocus.value && !rootCtx.open.value) rootCtx.onOpenChange(true);
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (shouldOpenOnClick.value && !rootCtx.open.value) rootCtx.onOpenChange(true);
|
||||
}
|
||||
|
||||
function handleCompositionStart() {
|
||||
isComposing = true;
|
||||
}
|
||||
|
||||
function handleCompositionEnd() {
|
||||
isComposing = false;
|
||||
}
|
||||
|
||||
function handleBlur(event: FocusEvent) {
|
||||
if (!rootCtx.open.value) return;
|
||||
const nextFocus = event.relatedTarget as Element | null;
|
||||
if (!nextFocus) return;
|
||||
|
||||
const parent = rootCtx.parentElement.value;
|
||||
const content = rootCtx.contentElement.value;
|
||||
if (parent?.contains(nextFocus) || content?.contains(nextFocus)) return;
|
||||
|
||||
rootCtx.onOpenChange(false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
type="text"
|
||||
role="combobox"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
aria-autocomplete="list"
|
||||
:aria-expanded="rootCtx.open.value"
|
||||
:aria-controls="rootCtx.contentId.value"
|
||||
:aria-activedescendant="activeDescendant"
|
||||
:aria-disabled="isDisabled || undefined"
|
||||
:aria-required="rootCtx.required.value || undefined"
|
||||
:disabled="isDisabled || undefined"
|
||||
:data-state="rootCtx.open.value ? 'open' : 'closed'"
|
||||
:data-disabled="isDisabled ? '' : undefined"
|
||||
@input="handleInput"
|
||||
@keydown="handleKeyDown"
|
||||
@focus="handleFocus"
|
||||
@click="handleClick"
|
||||
@blur="handleBlur"
|
||||
@compositionstart="handleCompositionStart"
|
||||
@compositionend="handleCompositionEnd"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,169 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
import type { AcceptableValue } from './utils';
|
||||
|
||||
/**
|
||||
* A single selectable option in the list. Registers itself for filtering and keyboard
|
||||
* navigation, toggles selection on click, and highlights on pointer move.
|
||||
*/
|
||||
export interface ComboboxItemProps<T extends AcceptableValue = AcceptableValue> extends PrimitiveProps {
|
||||
/** Item value. Selected/registered identity. */
|
||||
value: T;
|
||||
/** Optional explicit text for filter + typeahead. */
|
||||
textValue?: string;
|
||||
/** Disable this item. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail of the cancelable `select` event. Call `preventDefault()` on the event
|
||||
* (or read `defaultPrevented`) to block the default selection/auto-close — the
|
||||
* interception point for create-on-enter and custom selection flows.
|
||||
*/
|
||||
export interface ComboboxItemSelectEvent<T extends AcceptableValue = AcceptableValue> extends CustomEvent {
|
||||
detail: { value: T; originalEvent: PointerEvent | KeyboardEvent | MouseEvent };
|
||||
}
|
||||
|
||||
export interface ComboboxItemEmits<T extends AcceptableValue = AcceptableValue> {
|
||||
/** Cancelable. Fires before selection commits; `preventDefault()` blocks it. */
|
||||
select: [event: ComboboxItemSelectEvent<T>];
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue">
|
||||
import type { ComboboxGroupContext } from './context';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { useId } from '../../utilities/config-provider';
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { provideComboboxItemContext, useComboboxGroupContext, useComboboxRootContext } from './context';
|
||||
|
||||
const props = defineProps<ComboboxItemProps<T>>();
|
||||
const emit = defineEmits<ComboboxItemEmits<T>>();
|
||||
|
||||
if (props.value === ('' as unknown as T)) {
|
||||
throw new Error(
|
||||
'[ComboboxItem] `value` must not be an empty string — the empty string is reserved for the cleared selection state.',
|
||||
);
|
||||
}
|
||||
|
||||
const rootCtx = useComboboxRootContext();
|
||||
let groupCtx: ComboboxGroupContext | null = null;
|
||||
try {
|
||||
groupCtx = useComboboxGroupContext();
|
||||
}
|
||||
catch {
|
||||
groupCtx = null;
|
||||
}
|
||||
|
||||
const id = useId(undefined, 'combobox-item');
|
||||
const textValue = ref(props.textValue ?? '');
|
||||
|
||||
const isDisabled = computed(() => rootCtx.disabled.value || !!props.disabled);
|
||||
const isSelected = computed(() => rootCtx.isSelected(props.value));
|
||||
const isHighlighted = computed(() => rootCtx.selectedValueId.value === id.value);
|
||||
const isVisible = computed(() => rootCtx.filterState.value.items.has(id.value));
|
||||
|
||||
// defineExpose must run BEFORE useForwardExpose: the composable absorbs a prior
|
||||
// expose() into the forwarded object, while a later one would trigger Vue's
|
||||
// "expose() should be called only once" warning and clobber the forwarded API.
|
||||
defineExpose({ id, isVisible, isHighlighted });
|
||||
const { forwardRef, currentElement } = useForwardExpose();
|
||||
|
||||
function syncRegistration() {
|
||||
rootCtx.onItemRegister(id.value, {
|
||||
value: props.value,
|
||||
textValue: textValue.value,
|
||||
disabled: isDisabled.value,
|
||||
select: trySelect,
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const el = currentElement.value as HTMLElement | undefined;
|
||||
if (el && !props.textValue) {
|
||||
textValue.value = el.textContent?.trim() ?? '';
|
||||
}
|
||||
syncRegistration();
|
||||
if (groupCtx) rootCtx.onGroupItemRegister(groupCtx.id.value, id.value);
|
||||
});
|
||||
|
||||
watch(() => [props.value, props.textValue, isDisabled.value], () => {
|
||||
if (props.textValue) textValue.value = props.textValue;
|
||||
syncRegistration();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
rootCtx.onItemUnregister(id.value);
|
||||
if (groupCtx) rootCtx.onGroupItemUnregister(groupCtx.id.value, id.value);
|
||||
if (rootCtx.selectedValueId.value === id.value) {
|
||||
rootCtx.onSelectedValueChange(undefined, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Runs the selection flow guarded by a cancelable `select` event. Consumers can
|
||||
* call `preventDefault()` on the event to block selection (e.g. create-on-enter).
|
||||
* Returns `true` when selection committed.
|
||||
*/
|
||||
function trySelect(originalEvent: PointerEvent | KeyboardEvent | MouseEvent): boolean {
|
||||
if (isDisabled.value) return false;
|
||||
const selectEvent = new CustomEvent('combobox-item-select', {
|
||||
bubbles: false,
|
||||
cancelable: true,
|
||||
detail: { value: props.value, originalEvent },
|
||||
}) as ComboboxItemSelectEvent<T>;
|
||||
emit('select', selectEvent);
|
||||
if (selectEvent.defaultPrevented) return false;
|
||||
|
||||
rootCtx.onValueChange(props.value);
|
||||
if (rootCtx.resetSearchTermOnSelect.value && !rootCtx.multiple.value) {
|
||||
rootCtx.onSearchTermChange('');
|
||||
rootCtx.onUserInputtedChange(false);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleClick(event: MouseEvent) {
|
||||
if (isDisabled.value) return;
|
||||
event.preventDefault();
|
||||
trySelect(event);
|
||||
}
|
||||
|
||||
function handlePointerMove() {
|
||||
if (isDisabled.value || !rootCtx.highlightOnHover.value) return;
|
||||
if (rootCtx.selectedValueId.value !== id.value) {
|
||||
rootCtx.onSelectedValueChange(props.value, id.value);
|
||||
}
|
||||
}
|
||||
|
||||
provideComboboxItemContext({
|
||||
id,
|
||||
value: props.value,
|
||||
textValue,
|
||||
isSelected,
|
||||
isDisabled,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
v-show="isVisible"
|
||||
:ref="forwardRef"
|
||||
:id="id"
|
||||
:as="props.as ?? 'div'"
|
||||
role="option"
|
||||
:aria-selected="isSelected"
|
||||
:aria-disabled="isDisabled || undefined"
|
||||
:data-state="isSelected ? 'checked' : 'unchecked'"
|
||||
:data-highlighted="isHighlighted ? '' : undefined"
|
||||
:data-disabled="isDisabled ? '' : undefined"
|
||||
:tabindex="-1"
|
||||
data-primitives-combobox-item
|
||||
@click="handleClick"
|
||||
@pointermove="handlePointerMove"
|
||||
>
|
||||
<slot :selected="isSelected" :highlighted="isHighlighted" />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* Marks the selected state of its parent ComboboxItem, e.g. a checkmark. Renders only when
|
||||
* that item is selected.
|
||||
*/
|
||||
export interface ComboboxItemIndicatorProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { useComboboxItemContext } from './context';
|
||||
|
||||
const { as = 'span' } = defineProps<ComboboxItemIndicatorProps>();
|
||||
const { forwardRef } = useForwardExpose();
|
||||
const itemCtx = useComboboxItemContext();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
v-if="itemCtx.isSelected.value"
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* An accessible label for a ComboboxGroup. Its id is referenced by the group's
|
||||
* `aria-labelledby`, so place it as a direct child of ComboboxGroup.
|
||||
*/
|
||||
export interface ComboboxLabelProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount } from 'vue';
|
||||
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { useId } from '../../utilities/config-provider';
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { useComboboxGroupContext } from './context';
|
||||
|
||||
const { as = 'div' } = defineProps<ComboboxLabelProps>();
|
||||
const { forwardRef } = useForwardExpose();
|
||||
const groupCtx = useComboboxGroupContext();
|
||||
|
||||
const id = useId(undefined, 'combobox-group-label');
|
||||
groupCtx.registerLabel(id.value);
|
||||
onBeforeUnmount(() => groupCtx.unregisterLabel());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
:id="id"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { PortalProps } from '../../utilities/teleport';
|
||||
|
||||
/**
|
||||
* Teleports the ComboboxContent into another part of the DOM (defaults to `body`) to escape
|
||||
* overflow/stacking-context clipping. Wrap ComboboxContent with it.
|
||||
*/
|
||||
export interface ComboboxPortalProps extends PortalProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Portal } from '../../utilities/teleport';
|
||||
|
||||
const { to, defer, disabled } = defineProps<ComboboxPortalProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Portal :to="to" :defer="defer" :disabled="disabled">
|
||||
<slot />
|
||||
</Portal>
|
||||
</template>
|
||||
@@ -0,0 +1,433 @@
|
||||
<script lang="ts">
|
||||
import type { Direction } from '../../utilities/config-provider';
|
||||
import type { AcceptableValue, ComboboxFilterFunction, ComboboxFilterItem } from './utils';
|
||||
|
||||
/**
|
||||
* An autocomplete / typeahead input that filters a list of options as the user types.
|
||||
* Combine a text input with a popup listbox, supporting single or multiple selection,
|
||||
* custom filtering, and full keyboard navigation. Reach for it when users must pick from
|
||||
* a large or searchable set of options; for a small fixed list a plain Select is simpler.
|
||||
* Wraps everything in a Popper and provides shared state to every other Combobox part.
|
||||
*/
|
||||
export interface ComboboxRootProps<T extends AcceptableValue = AcceptableValue> {
|
||||
/** Controlled selected value. Use `v-model`. */
|
||||
modelValue?: T | T[];
|
||||
/** Uncontrolled initial value. */
|
||||
defaultValue?: T | T[];
|
||||
/** Uncontrolled default open state. */
|
||||
defaultOpen?: boolean;
|
||||
/** Allow selecting multiple values. */
|
||||
multiple?: boolean;
|
||||
/** Reading direction. Falls back to `ConfigProvider`. */
|
||||
dir?: Direction;
|
||||
/** Disable the whole combobox. */
|
||||
disabled?: boolean;
|
||||
/** Mark as required for native form validation. */
|
||||
required?: boolean;
|
||||
/** Native input name for form submission. */
|
||||
name?: string;
|
||||
/** Reset the search term when the input is blurred. @default true */
|
||||
resetSearchTermOnBlur?: boolean;
|
||||
/** Reset the search term when a value is selected (single mode). @default true */
|
||||
resetSearchTermOnSelect?: boolean;
|
||||
/** Clear the model value (to `undefined`, or `[]` when `multiple`) when the search is cleared via ComboboxCancel. @default false */
|
||||
resetModelValueOnClear?: boolean;
|
||||
/** Open the combobox when the input is focused. Root-level default for every ComboboxInput; a per-input prop still overrides it. @default false */
|
||||
openOnFocus?: boolean;
|
||||
/** Open the combobox when the input is clicked. Root-level default for every ComboboxInput; a per-input prop still overrides it. @default false */
|
||||
openOnClick?: boolean;
|
||||
/** Highlight items on pointer hover. Set `false` to opt out of pointermove highlighting. @default true */
|
||||
highlightOnHover?: boolean;
|
||||
/** Skip the built-in filter; render every item regardless of search term. */
|
||||
ignoreFilter?: boolean;
|
||||
/** Custom filter implementation. Overrides the default substring match. */
|
||||
filterFunction?: ComboboxFilterFunction;
|
||||
/** Map the current model value to the input's display value. */
|
||||
displayValue?: (value: T | T[] | undefined) => string;
|
||||
/** Compare values by key, or via a custom comparator. */
|
||||
by?: string | ((a: T, b: T) => boolean);
|
||||
}
|
||||
|
||||
export interface ComboboxRootEmits<T extends AcceptableValue = AcceptableValue> {
|
||||
'update:modelValue': [value: T | T[] | undefined];
|
||||
'update:open': [open: boolean];
|
||||
/** Fired whenever the highlighted (active-descendant) item changes; payload is `undefined` when nothing is highlighted. */
|
||||
highlight: [payload: { value: T; ref: HTMLElement | undefined } | undefined];
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue">
|
||||
import type { ShallowRef } from 'vue';
|
||||
import type { ComboboxFilterState, ComboboxItemInfo } from './context';
|
||||
|
||||
import { computed, nextTick, ref, shallowRef, toRef, triggerRef, watch } from 'vue';
|
||||
|
||||
import { useConfig, useId } from '../../utilities/config-provider';
|
||||
import { PopperRoot } from '../../overlays/popper';
|
||||
import { provideComboboxRootContext } from './context';
|
||||
import { defaultFilter, valueComparator } from './utils';
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const {
|
||||
modelValue,
|
||||
defaultValue,
|
||||
defaultOpen = false,
|
||||
multiple = false,
|
||||
dir,
|
||||
disabled = false,
|
||||
required = false,
|
||||
name,
|
||||
resetSearchTermOnBlur = true,
|
||||
resetSearchTermOnSelect = true,
|
||||
resetModelValueOnClear = false,
|
||||
openOnFocus = false,
|
||||
openOnClick = false,
|
||||
highlightOnHover = true,
|
||||
ignoreFilter = false,
|
||||
filterFunction,
|
||||
displayValue,
|
||||
by,
|
||||
} = defineProps<ComboboxRootProps<T>>();
|
||||
|
||||
const emit = defineEmits<ComboboxRootEmits<T>>();
|
||||
|
||||
const config = useConfig();
|
||||
const direction = computed(() => dir ?? config.dir.value);
|
||||
|
||||
/** Controlled open state. Use `v-model:open`. */
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
if (defaultOpen && !open.value) open.value = true;
|
||||
|
||||
/** Controlled selected value. Use `v-model`. `undefined` from the parent means "no selection". */
|
||||
const value = defineModel<T | T[] | undefined>('modelValue');
|
||||
if (modelValue === undefined && defaultValue !== undefined) {
|
||||
value.value = multiple
|
||||
? (Array.isArray(defaultValue) ? defaultValue.slice() : [defaultValue]) as T[]
|
||||
: (Array.isArray(defaultValue) ? defaultValue[0] : defaultValue) as T;
|
||||
}
|
||||
|
||||
const searchTerm = ref('');
|
||||
const isUserInputted = ref(false);
|
||||
|
||||
const contentId = useId(undefined, 'combobox-content');
|
||||
|
||||
const triggerElement = shallowRef<HTMLElement | undefined>(undefined);
|
||||
const inputElement = shallowRef<HTMLInputElement | undefined>(undefined);
|
||||
const contentElement = shallowRef<HTMLElement | undefined>(undefined);
|
||||
const parentElement = shallowRef<HTMLElement | undefined>(undefined);
|
||||
|
||||
const selectedValue = shallowRef<T | undefined>(undefined) as ShallowRef<T | undefined>;
|
||||
const selectedValueId = ref<string | undefined>(undefined);
|
||||
|
||||
const allItems = shallowRef(new Map<string, ComboboxItemInfo<T>>());
|
||||
const allGroups = shallowRef(new Map<string, Set<string>>());
|
||||
|
||||
function onItemRegister(id: string, info: ComboboxItemInfo<T>) {
|
||||
allItems.value.set(id, info);
|
||||
triggerRef(allItems);
|
||||
}
|
||||
|
||||
function onItemUnregister(id: string) {
|
||||
allItems.value.delete(id);
|
||||
triggerRef(allItems);
|
||||
}
|
||||
|
||||
function onGroupRegister(groupId: string) {
|
||||
if (!allGroups.value.has(groupId)) {
|
||||
allGroups.value.set(groupId, new Set());
|
||||
triggerRef(allGroups);
|
||||
}
|
||||
}
|
||||
|
||||
function onGroupUnregister(groupId: string) {
|
||||
allGroups.value.delete(groupId);
|
||||
triggerRef(allGroups);
|
||||
}
|
||||
|
||||
function onGroupItemRegister(groupId: string, itemId: string) {
|
||||
let set = allGroups.value.get(groupId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
allGroups.value.set(groupId, set);
|
||||
}
|
||||
set.add(itemId);
|
||||
triggerRef(allGroups);
|
||||
}
|
||||
|
||||
function onGroupItemUnregister(groupId: string, itemId: string) {
|
||||
const set = allGroups.value.get(groupId);
|
||||
if (set) {
|
||||
set.delete(itemId);
|
||||
triggerRef(allGroups);
|
||||
}
|
||||
}
|
||||
|
||||
const filterRef = toRef(() => filterFunction);
|
||||
const ignoreFilterRef = toRef(() => ignoreFilter);
|
||||
|
||||
const filterState = computed<ComboboxFilterState>(() => {
|
||||
const items = allItems.value;
|
||||
const groups = allGroups.value;
|
||||
|
||||
if (!searchTerm.value || ignoreFilterRef.value || !isUserInputted.value) {
|
||||
return {
|
||||
count: items.size,
|
||||
items: new Set(items.keys()),
|
||||
groups: new Set(groups.keys()),
|
||||
};
|
||||
}
|
||||
|
||||
const candidates: ComboboxFilterItem[] = [];
|
||||
for (const [id, info] of items) candidates.push({ id, textValue: info.textValue });
|
||||
|
||||
const fn = filterRef.value ?? defaultFilter;
|
||||
const filtered = fn(candidates, searchTerm.value);
|
||||
|
||||
const visibleItems = new Set<string>();
|
||||
for (let i = 0; i < filtered.length; i++) visibleItems.add(filtered[i]!.id);
|
||||
|
||||
const visibleGroups = new Set<string>();
|
||||
for (const [groupId, set] of groups) {
|
||||
for (const itemId of set) {
|
||||
if (visibleItems.has(itemId)) {
|
||||
visibleGroups.add(groupId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
count: visibleItems.size,
|
||||
items: visibleItems,
|
||||
groups: visibleGroups,
|
||||
};
|
||||
});
|
||||
|
||||
function isSelected(v: T): boolean {
|
||||
return valueComparator(value.value as T | T[] | undefined, v, by);
|
||||
}
|
||||
|
||||
function commitValue(next: T | T[] | undefined) {
|
||||
value.value = next;
|
||||
}
|
||||
|
||||
function onValueChange(v: T) {
|
||||
if (multiple) {
|
||||
const cur = Array.isArray(value.value) ? [...(value.value as T[])] : [];
|
||||
const idx = cur.findIndex(i => valueComparator(i, v, by));
|
||||
if (idx === -1) cur.push(v);
|
||||
else cur.splice(idx, 1);
|
||||
commitValue(cur);
|
||||
inputElement.value?.focus();
|
||||
}
|
||||
else {
|
||||
commitValue(v);
|
||||
open.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onOpenChange(next: boolean) {
|
||||
open.value = next;
|
||||
if (next) {
|
||||
// When the open was initiated by typing, ComboboxInput already set
|
||||
// searchTerm/isUserInputted — resetting here would wipe the first keystroke.
|
||||
if (!isUserInputted.value) searchTerm.value = '';
|
||||
nextTick(() => {
|
||||
inputElement.value?.focus();
|
||||
highlightSelectedOrFirst();
|
||||
});
|
||||
}
|
||||
else {
|
||||
setTimeout(() => {
|
||||
if (resetSearchTermOnBlur) searchTerm.value = '';
|
||||
isUserInputted.value = false;
|
||||
}, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectedValueChange(v: T | undefined, id?: string) {
|
||||
selectedValue.value = v;
|
||||
selectedValueId.value = id;
|
||||
}
|
||||
|
||||
function clearModelValue() {
|
||||
commitValue(multiple ? ([] as T[]) : undefined);
|
||||
}
|
||||
|
||||
// `selectedValueId` is the single source of truth for the active-descendant, so
|
||||
// emitting `highlight` off its change covers keyboard, hover, and programmatic
|
||||
// highlight paths without instrumenting each call site.
|
||||
watch(selectedValueId, (id) => {
|
||||
if (id === undefined) {
|
||||
emit('highlight', undefined);
|
||||
return;
|
||||
}
|
||||
const value = selectedValue.value;
|
||||
if (value === undefined) {
|
||||
emit('highlight', undefined);
|
||||
return;
|
||||
}
|
||||
const root = contentElement.value ?? parentElement.value;
|
||||
const el = root?.querySelector<HTMLElement>(`#${CSS.escape(id)}`) ?? undefined;
|
||||
emit('highlight', { value, ref: el });
|
||||
});
|
||||
|
||||
function getVisibleItemElements(): HTMLElement[] {
|
||||
const root = contentElement.value ?? parentElement.value;
|
||||
if (!root) return [];
|
||||
const all = Array.from(root.querySelectorAll<HTMLElement>('[data-primitives-combobox-item]'));
|
||||
const visible: HTMLElement[] = [];
|
||||
const filterIds = filterState.value.items;
|
||||
for (let i = 0; i < all.length; i++) {
|
||||
const el = all[i]!;
|
||||
if (el.dataset['disabled'] === '') continue;
|
||||
const id = el.id;
|
||||
if (!id || filterIds.has(id)) visible.push(el);
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
function readValueFromElement(el: HTMLElement): T | undefined {
|
||||
const id = el.id;
|
||||
if (!id) return undefined;
|
||||
return allItems.value.get(id)?.value;
|
||||
}
|
||||
|
||||
function highlightItemById(id: string | undefined) {
|
||||
if (!id) {
|
||||
selectedValue.value = undefined;
|
||||
selectedValueId.value = undefined;
|
||||
return;
|
||||
}
|
||||
const info = allItems.value.get(id);
|
||||
if (!info) return;
|
||||
selectedValue.value = info.value;
|
||||
selectedValueId.value = id;
|
||||
const root = contentElement.value ?? parentElement.value;
|
||||
const el = root?.querySelector<HTMLElement>(`#${CSS.escape(id)}`);
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function highlightFirstItem() {
|
||||
const els = getVisibleItemElements();
|
||||
if (els.length === 0) {
|
||||
selectedValue.value = undefined;
|
||||
selectedValueId.value = undefined;
|
||||
return;
|
||||
}
|
||||
highlightItemById(els[0]!.id);
|
||||
}
|
||||
|
||||
function highlightSelectedOrFirst() {
|
||||
const cur = value.value;
|
||||
if (cur !== undefined && !Array.isArray(cur)) {
|
||||
for (const [id, info] of allItems.value) {
|
||||
if (valueComparator(cur, info.value, by) && !info.disabled) {
|
||||
highlightItemById(id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
highlightFirstItem();
|
||||
}
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (!isOpen) {
|
||||
selectedValue.value = undefined;
|
||||
selectedValueId.value = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
function onSearchTermChange(v: string) {
|
||||
searchTerm.value = v;
|
||||
}
|
||||
|
||||
function onUserInputtedChange(v: boolean) {
|
||||
isUserInputted.value = v;
|
||||
}
|
||||
|
||||
provideComboboxRootContext({
|
||||
modelValue: value,
|
||||
onValueChange,
|
||||
multiple: toRef(() => multiple),
|
||||
open,
|
||||
onOpenChange,
|
||||
disabled: toRef(() => disabled),
|
||||
dir: direction,
|
||||
name: toRef(() => name),
|
||||
required: toRef(() => required),
|
||||
by,
|
||||
isSelected,
|
||||
|
||||
searchTerm,
|
||||
onSearchTermChange,
|
||||
resetSearchTermOnBlur: toRef(() => resetSearchTermOnBlur),
|
||||
resetSearchTermOnSelect: toRef(() => resetSearchTermOnSelect),
|
||||
resetModelValueOnClear: toRef(() => resetModelValueOnClear),
|
||||
openOnFocus: toRef(() => openOnFocus),
|
||||
openOnClick: toRef(() => openOnClick),
|
||||
highlightOnHover: toRef(() => highlightOnHover),
|
||||
ignoreFilter: ignoreFilterRef,
|
||||
filterFunction: filterRef,
|
||||
displayValue: displayValue as ((v: unknown) => string) | undefined,
|
||||
clearModelValue,
|
||||
|
||||
isUserInputted,
|
||||
onUserInputtedChange,
|
||||
|
||||
contentId,
|
||||
triggerElement,
|
||||
onTriggerChange: (el) => { triggerElement.value = el; },
|
||||
inputElement,
|
||||
onInputChange: (el) => { inputElement.value = el; },
|
||||
contentElement,
|
||||
onContentChange: (el) => { contentElement.value = el; },
|
||||
parentElement,
|
||||
onParentChange: (el) => { parentElement.value = el; },
|
||||
|
||||
selectedValue,
|
||||
selectedValueId,
|
||||
onSelectedValueChange,
|
||||
|
||||
allItems,
|
||||
onItemRegister,
|
||||
onItemUnregister,
|
||||
allGroups,
|
||||
onGroupRegister,
|
||||
onGroupUnregister,
|
||||
onGroupItemRegister,
|
||||
onGroupItemUnregister,
|
||||
|
||||
filterState,
|
||||
|
||||
getVisibleItemElements,
|
||||
highlightItemById,
|
||||
highlightFirstItem,
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
filterState,
|
||||
highlightFirstItem,
|
||||
highlightItemById,
|
||||
// Avoid unused warnings — surfaced for advanced consumers
|
||||
readValueFromElement,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopperRoot>
|
||||
<slot :open="open" :model-value="value" />
|
||||
<input
|
||||
v-if="name"
|
||||
type="hidden"
|
||||
:name="name"
|
||||
:value="Array.isArray(value) ? JSON.stringify(value) : (value ?? '')"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
aria-hidden="true"
|
||||
style="display: none"
|
||||
tabindex="-1"
|
||||
/>
|
||||
</PopperRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* A purely visual divider between items or groups inside the popup. Decorative and hidden
|
||||
* from assistive technology.
|
||||
*/
|
||||
export interface ComboboxSeparatorProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
|
||||
const { as = 'div' } = defineProps<ComboboxSeparatorProps>();
|
||||
const { forwardRef } = useForwardExpose();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* A button, usually a chevron next to the input, that toggles the popup open and closed.
|
||||
* Optional: typing in the Input also opens the list.
|
||||
*/
|
||||
export interface ComboboxTriggerProps extends PrimitiveProps {
|
||||
/** Disable the trigger independently from the root. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, watchPostEffect } from 'vue';
|
||||
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { useComboboxRootContext } from './context';
|
||||
import { getOpenState } from './utils';
|
||||
|
||||
const { as = 'button', disabled = false } = defineProps<ComboboxTriggerProps>();
|
||||
|
||||
const { forwardRef, currentElement } = useForwardExpose();
|
||||
const rootCtx = useComboboxRootContext();
|
||||
|
||||
const isDisabled = computed(() => disabled || rootCtx.disabled.value);
|
||||
|
||||
watchPostEffect(() => rootCtx.onTriggerChange(currentElement.value));
|
||||
onBeforeUnmount(() => rootCtx.onTriggerChange(undefined));
|
||||
|
||||
function handleClick(event: MouseEvent) {
|
||||
if (isDisabled.value) return;
|
||||
event.preventDefault();
|
||||
rootCtx.onOpenChange(!rootCtx.open.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
:type="as === 'button' ? 'button' : undefined"
|
||||
tabindex="-1"
|
||||
aria-haspopup="listbox"
|
||||
aria-label="Show options"
|
||||
:aria-controls="rootCtx.contentId.value"
|
||||
:aria-expanded="rootCtx.open.value"
|
||||
:aria-disabled="isDisabled || undefined"
|
||||
:disabled="isDisabled || undefined"
|
||||
:data-state="getOpenState(rootCtx.open.value)"
|
||||
:data-disabled="isDisabled ? '' : undefined"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* The scrollable region inside ComboboxContent that holds the items. Provides the overflow
|
||||
* container that keeps the highlighted item scrolled into view.
|
||||
*/
|
||||
export interface ComboboxViewportProps extends PrimitiveProps {
|
||||
/**
|
||||
* CSP `nonce` applied to the injected scrollbar-hiding `<style>` tag. Falls
|
||||
* back to the `ConfigProvider` nonce when omitted.
|
||||
*/
|
||||
nonce?: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { toRef, watchPostEffect } from 'vue';
|
||||
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { useNonce } from '../../utilities/config-provider';
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { useComboboxContentContext } from './context';
|
||||
|
||||
const props = defineProps<ComboboxViewportProps>();
|
||||
const { as = 'div' } = props;
|
||||
|
||||
const { forwardRef, currentElement } = useForwardExpose();
|
||||
const contentCtx = useComboboxContentContext();
|
||||
const nonce = useNonce(toRef(() => props.nonce));
|
||||
|
||||
watchPostEffect(() => contentCtx.onViewportChange(currentElement.value));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
role="presentation"
|
||||
data-primitives-combobox-viewport
|
||||
style="position: relative; flex: 1 1 0%; overflow: hidden auto"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
<Primitive as="style" :nonce="nonce">
|
||||
[data-primitives-combobox-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}
|
||||
[data-primitives-combobox-viewport]::-webkit-scrollbar{display:none;}
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,433 @@
|
||||
import type { Ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { userEvent } from 'vitest/browser';
|
||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||
|
||||
import {
|
||||
ComboboxAnchor,
|
||||
ComboboxCancel,
|
||||
ComboboxContent,
|
||||
ComboboxGroup,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxLabel,
|
||||
ComboboxPortal,
|
||||
ComboboxRoot,
|
||||
ComboboxTrigger,
|
||||
ComboboxViewport,
|
||||
accentInsensitiveContains,
|
||||
} from '../index';
|
||||
|
||||
interface MountOptions {
|
||||
defaultOpen?: boolean;
|
||||
multiple?: boolean;
|
||||
highlightOnHover?: boolean;
|
||||
resetModelValueOnClear?: boolean;
|
||||
openOnFocus?: boolean;
|
||||
openOnClick?: boolean;
|
||||
model?: Ref<unknown>;
|
||||
onSelect?: (e: CustomEvent) => void;
|
||||
onHighlight?: (p: unknown) => void;
|
||||
items?: Array<{ value: string; textValue: string }>;
|
||||
}
|
||||
|
||||
const DEFAULT_ITEMS = [
|
||||
{ value: 'apple', textValue: 'Apple' },
|
||||
{ value: 'banana', textValue: 'Banana' },
|
||||
{ value: 'cherry', textValue: 'Cherry' },
|
||||
];
|
||||
|
||||
function mountCombobox(options: MountOptions = {}) {
|
||||
const items = options.items ?? DEFAULT_ITEMS;
|
||||
const Harness = defineComponent({
|
||||
setup: () => () => h(ComboboxRoot, {
|
||||
defaultOpen: options.defaultOpen ?? false,
|
||||
multiple: options.multiple ?? false,
|
||||
...(options.highlightOnHover !== undefined ? { highlightOnHover: options.highlightOnHover } : {}),
|
||||
...(options.resetModelValueOnClear !== undefined ? { resetModelValueOnClear: options.resetModelValueOnClear } : {}),
|
||||
...(options.openOnFocus !== undefined ? { openOnFocus: options.openOnFocus } : {}),
|
||||
...(options.openOnClick !== undefined ? { openOnClick: options.openOnClick } : {}),
|
||||
...(options.onHighlight ? { onHighlight: options.onHighlight } : {}),
|
||||
...(options.model
|
||||
? {
|
||||
modelValue: options.model.value,
|
||||
'onUpdate:modelValue': (v: unknown) => { options.model!.value = v; },
|
||||
}
|
||||
: {}),
|
||||
}, {
|
||||
default: () => [
|
||||
h(ComboboxAnchor, { id: 'anchor' }, {
|
||||
default: () => [
|
||||
h(ComboboxInput, { id: 'input' }),
|
||||
h(ComboboxCancel, { id: 'cancel' }, { default: () => 'x' }),
|
||||
h(ComboboxTrigger, { id: 'trigger' }, { default: () => 'v' }),
|
||||
],
|
||||
}),
|
||||
h(ComboboxPortal, {}, {
|
||||
default: () => h(ComboboxContent, {}, {
|
||||
default: () => h(ComboboxViewport, {}, {
|
||||
default: () => items.map(it => h(ComboboxItem, {
|
||||
key: it.value,
|
||||
value: it.value,
|
||||
textValue: it.textValue,
|
||||
...(options.onSelect ? { onSelect: options.onSelect } : {}),
|
||||
}, { default: () => it.textValue })),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
return mount(Harness, { attachTo: document.body });
|
||||
}
|
||||
|
||||
function getInput(): HTMLInputElement {
|
||||
return document.querySelector<HTMLInputElement>('#input')!;
|
||||
}
|
||||
|
||||
function getItem(text: string): HTMLElement {
|
||||
return Array.from(document.querySelectorAll<HTMLElement>('[data-primitives-combobox-item]'))
|
||||
.find(el => el.textContent?.includes(text))!;
|
||||
}
|
||||
|
||||
function getContent(): HTMLElement | null {
|
||||
return document.querySelector('[data-primitives-combobox-content]');
|
||||
}
|
||||
|
||||
async function flush(times = 4) {
|
||||
for (let i = 0; i < times; i++) await nextTick();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('Combobox — cancelable item select event', () => {
|
||||
it('fires select on click and commits when not prevented', async () => {
|
||||
const model = ref<unknown>(undefined);
|
||||
const onSelect = vi.fn();
|
||||
const w = mountCombobox({ defaultOpen: true, model, onSelect });
|
||||
await flush();
|
||||
|
||||
await userEvent.click(getItem('Banana'));
|
||||
await flush();
|
||||
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
const event = onSelect.mock.calls[0]![0] as CustomEvent;
|
||||
expect(event.detail.value).toBe('banana');
|
||||
expect(model.value).toBe('banana');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('blocks selection when the consumer calls preventDefault', async () => {
|
||||
const model = ref<unknown>(undefined);
|
||||
const onSelect = (e: CustomEvent) => e.preventDefault();
|
||||
const w = mountCombobox({ defaultOpen: true, model, onSelect });
|
||||
await flush();
|
||||
|
||||
await userEvent.click(getItem('Banana'));
|
||||
await flush();
|
||||
|
||||
expect(model.value).toBeUndefined();
|
||||
// List stays open because selection was blocked (single-select would close on commit).
|
||||
expect(getContent()).toBeTruthy();
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('fires the same cancelable select on keyboard Enter', async () => {
|
||||
const model = ref<unknown>(undefined);
|
||||
const onSelect = vi.fn((e: CustomEvent) => e.preventDefault());
|
||||
const w = mountCombobox({ model, onSelect });
|
||||
await flush();
|
||||
|
||||
const input = getInput();
|
||||
await userEvent.click(input);
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await flush();
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await flush();
|
||||
|
||||
expect(onSelect).toHaveBeenCalled();
|
||||
// preventDefault blocked the commit.
|
||||
expect(model.value).toBeUndefined();
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Combobox — empty-string item value guard', () => {
|
||||
it('throws when an item has an empty-string value', () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
expect(() => mountCombobox({
|
||||
defaultOpen: true,
|
||||
items: [{ value: '', textValue: 'Empty' }],
|
||||
})).toThrow(/empty string/i);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Combobox — resetModelValueOnClear', () => {
|
||||
it('clears the model value when Cancel is clicked and the prop is set', async () => {
|
||||
const model = ref<unknown>('banana');
|
||||
const w = mountCombobox({ defaultOpen: true, model, resetModelValueOnClear: true });
|
||||
await flush();
|
||||
|
||||
await userEvent.click(document.querySelector('#cancel')!);
|
||||
await flush();
|
||||
|
||||
expect(model.value).toBeUndefined();
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('leaves the model value intact by default', async () => {
|
||||
const model = ref<unknown>('banana');
|
||||
const w = mountCombobox({ defaultOpen: true, model });
|
||||
await flush();
|
||||
|
||||
await userEvent.click(document.querySelector('#cancel')!);
|
||||
await flush();
|
||||
|
||||
expect(model.value).toBe('banana');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('resets to an empty array in multiple mode', async () => {
|
||||
const model = ref<unknown>(['banana']);
|
||||
const w = mountCombobox({ defaultOpen: true, multiple: true, model, resetModelValueOnClear: true });
|
||||
await flush();
|
||||
|
||||
await userEvent.click(document.querySelector('#cancel')!);
|
||||
await flush();
|
||||
|
||||
expect(model.value).toEqual([]);
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Combobox — highlightOnHover opt-out', () => {
|
||||
it('does not highlight on pointermove when highlightOnHover is false', async () => {
|
||||
const w = mountCombobox({ defaultOpen: true, highlightOnHover: false });
|
||||
await flush();
|
||||
|
||||
const cherry = getItem('Cherry');
|
||||
cherry.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
expect(cherry.getAttribute('data-highlighted')).toBeNull();
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('highlights on pointermove by default', async () => {
|
||||
const w = mountCombobox({ defaultOpen: true });
|
||||
await flush();
|
||||
|
||||
const cherry = getItem('Cherry');
|
||||
cherry.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
expect(cherry.getAttribute('data-highlighted')).toBe('');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Combobox — highlight emit', () => {
|
||||
it('emits highlight with the active item when navigation moves', async () => {
|
||||
const onHighlight = vi.fn();
|
||||
const w = mountCombobox({ onHighlight });
|
||||
await flush();
|
||||
|
||||
const input = getInput();
|
||||
await userEvent.click(input);
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await flush();
|
||||
|
||||
const lastPayload = onHighlight.mock.calls.at(-1)?.[0];
|
||||
expect(lastPayload).toBeTruthy();
|
||||
expect(lastPayload.value).toBe('apple');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('emits highlight undefined when the list closes', async () => {
|
||||
const onHighlight = vi.fn();
|
||||
const w = mountCombobox({ defaultOpen: true, onHighlight });
|
||||
await flush();
|
||||
|
||||
const cherry = getItem('Cherry');
|
||||
cherry.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }));
|
||||
await flush();
|
||||
onHighlight.mockClear();
|
||||
|
||||
await userEvent.keyboard('{Escape}');
|
||||
await flush();
|
||||
|
||||
expect(onHighlight).toHaveBeenCalledWith(undefined);
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Combobox — accent-insensitive default filter', () => {
|
||||
it('matches accented text against an unaccented search term', () => {
|
||||
expect(accentInsensitiveContains('Café', 'cafe')).toBe(true);
|
||||
expect(accentInsensitiveContains('Crème brûlée', 'creme')).toBe(true);
|
||||
expect(accentInsensitiveContains('Apple', 'xyz')).toBe(false);
|
||||
});
|
||||
|
||||
it('filters list items ignoring diacritics', async () => {
|
||||
const w = mountCombobox({
|
||||
items: [
|
||||
{ value: 'cafe', textValue: 'Café' },
|
||||
{ value: 'tea', textValue: 'Tea' },
|
||||
],
|
||||
});
|
||||
await flush();
|
||||
|
||||
const input = getInput();
|
||||
await userEvent.click(input);
|
||||
await userEvent.type(input, 'cafe');
|
||||
await flush();
|
||||
|
||||
const visible = Array.from(document.querySelectorAll<HTMLElement>('[data-primitives-combobox-item]'))
|
||||
.filter(el => el.style.display !== 'none')
|
||||
.map(el => el.textContent?.trim());
|
||||
expect(visible).toEqual(['Café']);
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Combobox — IME composition guard', () => {
|
||||
it('does not select an item when Enter fires during composition', async () => {
|
||||
const model = ref<unknown>(undefined);
|
||||
const w = mountCombobox({ model });
|
||||
await flush();
|
||||
|
||||
const input = getInput();
|
||||
await userEvent.click(input);
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await flush();
|
||||
|
||||
input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
|
||||
await flush();
|
||||
|
||||
expect(model.value).toBeUndefined();
|
||||
|
||||
input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }));
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
|
||||
await flush();
|
||||
|
||||
expect(model.value).toBe('apple');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Combobox — Root-level openOnFocus / openOnClick', () => {
|
||||
it('opens on input focus when Root openOnFocus is set', async () => {
|
||||
const w = mountCombobox({ openOnFocus: true });
|
||||
await flush();
|
||||
|
||||
getInput().focus();
|
||||
await flush();
|
||||
|
||||
expect(getContent()).toBeTruthy();
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('opens on input click when Root openOnClick is set', async () => {
|
||||
const w = mountCombobox({ openOnClick: true });
|
||||
await flush();
|
||||
|
||||
await userEvent.click(getInput());
|
||||
await flush();
|
||||
|
||||
expect(getContent()).toBeTruthy();
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Combobox — group label relationship', () => {
|
||||
function mountWithGroup(withLabel: boolean) {
|
||||
const Harness = defineComponent({
|
||||
setup: () => () => h(ComboboxRoot, { defaultOpen: true }, {
|
||||
default: () => [
|
||||
h(ComboboxAnchor, {}, { default: () => h(ComboboxInput, { id: 'input' }) }),
|
||||
h(ComboboxPortal, {}, {
|
||||
default: () => h(ComboboxContent, {}, {
|
||||
default: () => h(ComboboxViewport, {}, {
|
||||
default: () => h(ComboboxGroup, { id: 'group' }, {
|
||||
default: () => [
|
||||
...(withLabel ? [h(ComboboxLabel, { class: 'group-label' }, { default: () => 'Fruits' })] : []),
|
||||
h(ComboboxItem, { value: 'apple', textValue: 'Apple' }, { default: () => 'Apple' }),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
return mount(Harness, { attachTo: document.body });
|
||||
}
|
||||
|
||||
it('points aria-labelledby at the rendered label id', async () => {
|
||||
const w = mountWithGroup(true);
|
||||
await flush();
|
||||
|
||||
const group = document.querySelector('#group')!;
|
||||
const label = document.querySelector('.group-label')!;
|
||||
const labelledby = group.getAttribute('aria-labelledby');
|
||||
expect(labelledby).toBeTruthy();
|
||||
expect(label.id).toBeTruthy();
|
||||
expect(labelledby).toBe(label.id);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('omits aria-labelledby when no label is rendered (no dangling id)', async () => {
|
||||
const w = mountWithGroup(false);
|
||||
await flush();
|
||||
|
||||
const group = document.querySelector('#group')!;
|
||||
expect(group.getAttribute('aria-labelledby')).toBeNull();
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Combobox — hideWhenEmpty + data-empty', () => {
|
||||
function mountHideWhenEmpty() {
|
||||
const Harness = defineComponent({
|
||||
setup: () => () => h(ComboboxRoot, { defaultOpen: true }, {
|
||||
default: () => [
|
||||
h(ComboboxAnchor, {}, { default: () => h(ComboboxInput, { id: 'input' }) }),
|
||||
h(ComboboxPortal, {}, {
|
||||
default: () => h(ComboboxContent, { hideWhenEmpty: true }, {
|
||||
default: () => h(ComboboxViewport, {}, {
|
||||
default: () => DEFAULT_ITEMS.map(it => h(ComboboxItem, {
|
||||
key: it.value,
|
||||
value: it.value,
|
||||
textValue: it.textValue,
|
||||
}, { default: () => it.textValue })),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
return mount(Harness, { attachTo: document.body });
|
||||
}
|
||||
|
||||
it('marks the content data-empty and hides it when no items match', async () => {
|
||||
const w = mountHideWhenEmpty();
|
||||
await flush();
|
||||
|
||||
const content = getContent()!;
|
||||
expect(content.getAttribute('data-empty')).toBeNull();
|
||||
|
||||
const input = getInput();
|
||||
await userEvent.click(input);
|
||||
await userEvent.type(input, 'zzz');
|
||||
await flush();
|
||||
|
||||
expect(content.getAttribute('data-empty')).toBe('');
|
||||
expect(content.style.display).toBe('none');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { userEvent } from 'vitest/browser';
|
||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||
|
||||
import {
|
||||
ComboboxContent,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxPortal,
|
||||
ComboboxRoot,
|
||||
ComboboxTrigger,
|
||||
ComboboxViewport,
|
||||
} from '../index';
|
||||
|
||||
function mountCombobox() {
|
||||
const search = ref('');
|
||||
const Harness = defineComponent({
|
||||
setup: () => () => h(ComboboxRoot, { defaultOpen: true, multiple: false }, {
|
||||
default: () => [
|
||||
h(ComboboxTrigger, { id: 'trigger' }, {
|
||||
default: () => h(ComboboxInput, {
|
||||
id: 'input',
|
||||
'onUpdate:searchTerm': (v: string) => { search.value = v; },
|
||||
}),
|
||||
}),
|
||||
h(ComboboxPortal, {}, {
|
||||
default: () => h(ComboboxContent, {}, {
|
||||
default: () => h(ComboboxViewport, {}, {
|
||||
default: () => [
|
||||
h(ComboboxItem, { value: 'apple', textValue: 'Apple' }, { default: () => 'Apple' }),
|
||||
h(ComboboxItem, { value: 'banana', textValue: 'Banana' }, { default: () => 'Banana' }),
|
||||
h(ComboboxItem, { value: 'cherry', textValue: 'Cherry' }, { default: () => 'Cherry' }),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
return { wrapper: mount(Harness, { attachTo: document.body }), search };
|
||||
}
|
||||
|
||||
function getLiveRegion(): HTMLElement | null {
|
||||
return document.querySelector('[data-primitives-combobox-announce]');
|
||||
}
|
||||
|
||||
describe('Combobox — filtered-results live region', () => {
|
||||
it('announces "N results available." reflecting the unfiltered count on open', async () => {
|
||||
const { wrapper } = mountCombobox();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
const live = getLiveRegion();
|
||||
expect(live).toBeTruthy();
|
||||
expect(live!.getAttribute('role')).toBe('status');
|
||||
expect(live!.getAttribute('aria-live')).toBe('polite');
|
||||
expect(live!.textContent?.trim()).toBe('3 results available.');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('updates the count as the search term filters items', async () => {
|
||||
const { wrapper } = mountCombobox();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
const input = document.querySelector<HTMLInputElement>('#input')!;
|
||||
await userEvent.click(input);
|
||||
await userEvent.type(input, 'app');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(getLiveRegion()!.textContent?.trim()).toBe('1 result available.');
|
||||
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'zz');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(getLiveRegion()!.textContent?.trim()).toBe('0 results available.');
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import type { Ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { userEvent } from 'vitest/browser';
|
||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||
|
||||
import {
|
||||
ComboboxAnchor,
|
||||
ComboboxCancel,
|
||||
ComboboxContent,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxPortal,
|
||||
ComboboxRoot,
|
||||
ComboboxTrigger,
|
||||
ComboboxViewport,
|
||||
} from '../index';
|
||||
|
||||
interface MountOptions {
|
||||
defaultOpen?: boolean;
|
||||
model?: Ref<string | undefined>;
|
||||
}
|
||||
|
||||
function mountCombobox(options: MountOptions = {}) {
|
||||
const Harness = defineComponent({
|
||||
setup: () => () => h(ComboboxRoot, {
|
||||
defaultOpen: options.defaultOpen ?? false,
|
||||
...(options.model
|
||||
? {
|
||||
modelValue: options.model.value,
|
||||
'onUpdate:modelValue': (v: unknown) => { options.model!.value = v as string | undefined; },
|
||||
}
|
||||
: {}),
|
||||
}, {
|
||||
default: () => [
|
||||
h(ComboboxAnchor, { id: 'anchor' }, {
|
||||
default: () => [
|
||||
h(ComboboxInput, { id: 'input' }),
|
||||
h(ComboboxCancel, { id: 'cancel' }, { default: () => 'x' }),
|
||||
h(ComboboxTrigger, { id: 'trigger' }, { default: () => 'v' }),
|
||||
],
|
||||
}),
|
||||
h(ComboboxPortal, {}, {
|
||||
default: () => h(ComboboxContent, {}, {
|
||||
default: () => h(ComboboxViewport, {}, {
|
||||
default: () => [
|
||||
h(ComboboxItem, { value: 'apple', textValue: 'Apple' }, { default: () => 'Apple' }),
|
||||
h(ComboboxItem, { value: 'banana', textValue: 'Banana' }, { default: () => 'Banana' }),
|
||||
h(ComboboxItem, { value: 'cherry', textValue: 'Cherry' }, { default: () => 'Cherry' }),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
return mount(Harness, { attachTo: document.body });
|
||||
}
|
||||
|
||||
function getListbox(): HTMLElement | null {
|
||||
return document.querySelector('[data-primitives-combobox-content]');
|
||||
}
|
||||
|
||||
function getInput(): HTMLInputElement {
|
||||
return document.querySelector<HTMLInputElement>('#input')!;
|
||||
}
|
||||
|
||||
function visibleItemTexts(): string[] {
|
||||
return Array.from(document.querySelectorAll<HTMLElement>('[data-primitives-combobox-item]'))
|
||||
.filter(el => el.style.display !== 'none')
|
||||
.map(el => el.textContent?.trim() ?? '');
|
||||
}
|
||||
|
||||
async function flush(times = 3) {
|
||||
for (let i = 0; i < times; i++) await nextTick();
|
||||
}
|
||||
|
||||
describe('Combobox — open / dismiss / filtering', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('stays open and focuses the input after clicking the trigger', async () => {
|
||||
const w = mountCombobox();
|
||||
await nextTick();
|
||||
|
||||
await userEvent.click(document.querySelector('#trigger')!);
|
||||
await flush();
|
||||
|
||||
// The popup must survive the input auto-focus (focus lands in the anchor,
|
||||
// outside the content layer) instead of dismissing itself immediately.
|
||||
expect(getListbox()).toBeTruthy();
|
||||
expect(document.activeElement).toBe(getInput());
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('keeps the popup open and clears the search when the cancel button is clicked', async () => {
|
||||
const w = mountCombobox();
|
||||
await nextTick();
|
||||
const input = getInput();
|
||||
|
||||
await userEvent.click(input);
|
||||
await userEvent.type(input, 'ban');
|
||||
await flush();
|
||||
expect(getListbox()).toBeTruthy();
|
||||
expect(visibleItemTexts()).toEqual(['Banana']);
|
||||
|
||||
await userEvent.click(document.querySelector('#cancel')!);
|
||||
await flush();
|
||||
|
||||
expect(getListbox()).toBeTruthy();
|
||||
expect(input.value).toBe('');
|
||||
expect(visibleItemTexts()).toEqual(['Apple', 'Banana', 'Cherry']);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('closes on outside pointerdown', async () => {
|
||||
const w = mountCombobox();
|
||||
await nextTick();
|
||||
|
||||
await userEvent.click(document.querySelector('#trigger')!);
|
||||
await flush();
|
||||
expect(getListbox()).toBeTruthy();
|
||||
|
||||
document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true }));
|
||||
await flush();
|
||||
|
||||
expect(getListbox()).toBeNull();
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('preserves the first keystroke when typing opens the combobox', async () => {
|
||||
const w = mountCombobox();
|
||||
await nextTick();
|
||||
const input = getInput();
|
||||
|
||||
await userEvent.click(input);
|
||||
await userEvent.type(input, 'b');
|
||||
await flush();
|
||||
|
||||
expect(getListbox()).toBeTruthy();
|
||||
expect(input.value).toBe('b');
|
||||
expect(visibleItemTexts()).toEqual(['Banana']);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('lets Space type into a closed input instead of swallowing it', async () => {
|
||||
const w = mountCombobox();
|
||||
await nextTick();
|
||||
const input = getInput();
|
||||
|
||||
await userEvent.click(input);
|
||||
await userEvent.type(input, ' ');
|
||||
await flush();
|
||||
|
||||
expect(input.value).toBe(' ');
|
||||
// Typing (any printable character) still opens the list.
|
||||
expect(getListbox()).toBeTruthy();
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('does not open on caret keys (Home/End/PageDown) while closed', async () => {
|
||||
const w = mountCombobox();
|
||||
await nextTick();
|
||||
|
||||
await userEvent.click(getInput());
|
||||
await userEvent.keyboard('{Home}{End}{PageDown}');
|
||||
await flush();
|
||||
|
||||
expect(getListbox()).toBeNull();
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('opens on ArrowDown while closed', async () => {
|
||||
const w = mountCombobox();
|
||||
await nextTick();
|
||||
|
||||
await userEvent.click(getInput());
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await flush();
|
||||
|
||||
expect(getListbox()).toBeTruthy();
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('clears the selection when the parent clears v-model', async () => {
|
||||
const model = ref<string | undefined>(undefined);
|
||||
const w = mountCombobox({ model });
|
||||
await nextTick();
|
||||
|
||||
await userEvent.click(document.querySelector('#trigger')!);
|
||||
await flush();
|
||||
const banana = Array.from(document.querySelectorAll<HTMLElement>('[data-primitives-combobox-item]'))
|
||||
.find(el => el.textContent?.includes('Banana'))!;
|
||||
await userEvent.click(banana);
|
||||
await flush();
|
||||
// Let the close-path setTimeout(1) reset isUserInputted before asserting.
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(model.value).toBe('banana');
|
||||
expect(getInput().value).toBe('banana');
|
||||
|
||||
model.value = undefined;
|
||||
await flush();
|
||||
|
||||
expect(getInput().value).toBe('');
|
||||
|
||||
await userEvent.click(document.querySelector('#trigger')!);
|
||||
await flush();
|
||||
const bananaReopened = Array.from(document.querySelectorAll<HTMLElement>('[data-primitives-combobox-item]'))
|
||||
.find(el => el.textContent?.includes('Banana'))!;
|
||||
expect(bananaReopened.getAttribute('aria-selected')).toBe('false');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('does not warn "expose() should be called only once" when mounting items', async () => {
|
||||
const warn = vi.spyOn(console, 'warn');
|
||||
const w = mountCombobox({ defaultOpen: true });
|
||||
await flush();
|
||||
|
||||
const exposeWarnings = warn.mock.calls.filter(args =>
|
||||
args.some(arg => typeof arg === 'string' && arg.includes('expose() should be called only once')),
|
||||
);
|
||||
expect(exposeWarnings).toEqual([]);
|
||||
w.unmount();
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,139 @@
|
||||
import type { ComputedRef, Ref, ShallowRef } from 'vue';
|
||||
import type { Direction } from '../../utilities/config-provider';
|
||||
import type { AcceptableValue, ComboboxFilterFunction } from './utils';
|
||||
|
||||
import { useContextFactory } from '@robonen/vue';
|
||||
|
||||
export interface ComboboxItemInfo<T = AcceptableValue> {
|
||||
value: T;
|
||||
textValue: string;
|
||||
disabled: boolean;
|
||||
/**
|
||||
* Runs the item's cancelable `select` flow (fires the `select` event, commits
|
||||
* unless prevented). Lets keyboard Enter on the Input go through the same
|
||||
* interception point as a click. Returns whether selection committed.
|
||||
*/
|
||||
select: (originalEvent: KeyboardEvent | PointerEvent | MouseEvent) => boolean;
|
||||
}
|
||||
|
||||
export interface ComboboxFilterState {
|
||||
count: number;
|
||||
items: Set<string>;
|
||||
groups: Set<string>;
|
||||
}
|
||||
|
||||
export interface ComboboxRootContext<T = AcceptableValue> {
|
||||
modelValue: Ref<T | T[] | undefined>;
|
||||
onValueChange: (value: T) => void;
|
||||
multiple: Ref<boolean>;
|
||||
open: Ref<boolean>;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
disabled: Ref<boolean>;
|
||||
dir: Ref<Direction>;
|
||||
name: Ref<string | undefined>;
|
||||
required: Ref<boolean>;
|
||||
by?: string | ((a: T, b: T) => boolean);
|
||||
isSelected: (value: T) => boolean;
|
||||
|
||||
searchTerm: Ref<string>;
|
||||
onSearchTermChange: (value: string) => void;
|
||||
resetSearchTermOnBlur: Ref<boolean>;
|
||||
resetSearchTermOnSelect: Ref<boolean>;
|
||||
resetModelValueOnClear: Ref<boolean>;
|
||||
openOnFocus: Ref<boolean>;
|
||||
openOnClick: Ref<boolean>;
|
||||
highlightOnHover: Ref<boolean>;
|
||||
ignoreFilter: Ref<boolean>;
|
||||
filterFunction: Ref<ComboboxFilterFunction | undefined>;
|
||||
displayValue?: (value: T | T[] | undefined) => string;
|
||||
/** Clears the current selection (resets the model). */
|
||||
clearModelValue: () => void;
|
||||
|
||||
isUserInputted: Ref<boolean>;
|
||||
onUserInputtedChange: (value: boolean) => void;
|
||||
|
||||
contentId: Ref<string>;
|
||||
triggerElement: ShallowRef<HTMLElement | undefined>;
|
||||
onTriggerChange: (el: HTMLElement | undefined) => void;
|
||||
inputElement: ShallowRef<HTMLInputElement | undefined>;
|
||||
onInputChange: (el: HTMLInputElement | undefined) => void;
|
||||
contentElement: ShallowRef<HTMLElement | undefined>;
|
||||
onContentChange: (el: HTMLElement | undefined) => void;
|
||||
parentElement: ShallowRef<HTMLElement | undefined>;
|
||||
onParentChange: (el: HTMLElement | undefined) => void;
|
||||
|
||||
selectedValue: ShallowRef<T | undefined>;
|
||||
selectedValueId: Ref<string | undefined>;
|
||||
onSelectedValueChange: (value: T | undefined, id?: string) => void;
|
||||
|
||||
allItems: ShallowRef<Map<string, ComboboxItemInfo<T>>>;
|
||||
onItemRegister: (id: string, info: ComboboxItemInfo<T>) => void;
|
||||
onItemUnregister: (id: string) => void;
|
||||
allGroups: ShallowRef<Map<string, Set<string>>>;
|
||||
onGroupRegister: (groupId: string) => void;
|
||||
onGroupUnregister: (groupId: string) => void;
|
||||
onGroupItemRegister: (groupId: string, itemId: string) => void;
|
||||
onGroupItemUnregister: (groupId: string, itemId: string) => void;
|
||||
|
||||
filterState: ComputedRef<ComboboxFilterState>;
|
||||
|
||||
/** Returns visible, enabled item elements in DOM order. */
|
||||
getVisibleItemElements: () => HTMLElement[];
|
||||
/** Highlights an item element by its id. */
|
||||
highlightItemById: (id: string | undefined) => void;
|
||||
/** Highlights the first visible item. */
|
||||
highlightFirstItem: () => void;
|
||||
}
|
||||
|
||||
export interface ComboboxContentContext {
|
||||
viewportElement: ShallowRef<HTMLElement | undefined>;
|
||||
onViewportChange: (el: HTMLElement | undefined) => void;
|
||||
position: Ref<'inline' | 'popper'>;
|
||||
}
|
||||
|
||||
export interface ComboboxGroupContext {
|
||||
id: Ref<string>;
|
||||
/**
|
||||
* The id of the rendered `ComboboxLabel`, or `undefined` until one mounts.
|
||||
* The group only points `aria-labelledby` at it once it exists, avoiding a
|
||||
* dangling reference when no label is rendered.
|
||||
*/
|
||||
labelId: Ref<string | undefined>;
|
||||
registerLabel: (id: string) => void;
|
||||
unregisterLabel: () => void;
|
||||
}
|
||||
|
||||
export interface ComboboxItemContext<T = AcceptableValue> {
|
||||
id: Ref<string>;
|
||||
value: T;
|
||||
textValue: Ref<string>;
|
||||
isSelected: Ref<boolean>;
|
||||
isDisabled: Ref<boolean>;
|
||||
}
|
||||
|
||||
// `any` (not `AcceptableValue`): the generic `ComboboxRoot` provides a
|
||||
// `ComboboxRootContext<T>` whose contravariant callbacks ((value: T) => void)
|
||||
// are not assignable to the concrete `AcceptableValue` instantiation. `any` is
|
||||
// the type-erasure boundary that lets the generic root provide and arbitrary
|
||||
// `ComboboxItem<T>` consumers inject without per-component casts.
|
||||
export const {
|
||||
inject: useComboboxRootContext,
|
||||
provide: provideComboboxRootContext,
|
||||
} = useContextFactory<ComboboxRootContext<any>>('ComboboxRoot');
|
||||
|
||||
export const {
|
||||
inject: useComboboxContentContext,
|
||||
provide: provideComboboxContentContext,
|
||||
} = useContextFactory<ComboboxContentContext>('ComboboxContent');
|
||||
|
||||
export const {
|
||||
inject: useComboboxGroupContext,
|
||||
provide: provideComboboxGroupContext,
|
||||
} = useContextFactory<ComboboxGroupContext>('ComboboxGroup');
|
||||
|
||||
// `any` for the same generic type-erasure reason as the root context above:
|
||||
// `ComboboxItem<T>` provides `ComboboxItemContext<T>`, injected generically.
|
||||
export const {
|
||||
inject: useComboboxItemContext,
|
||||
provide: provideComboboxItemContext,
|
||||
} = useContextFactory<ComboboxItemContext<any>>('ComboboxItem');
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import {
|
||||
ComboboxAnchor,
|
||||
ComboboxCancel,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxItemIndicator,
|
||||
ComboboxLabel,
|
||||
ComboboxPortal,
|
||||
ComboboxRoot,
|
||||
ComboboxTrigger,
|
||||
ComboboxViewport,
|
||||
} from '@robonen/primitives';
|
||||
|
||||
interface Framework {
|
||||
value: string;
|
||||
label: string;
|
||||
group: 'JavaScript' | 'Native';
|
||||
}
|
||||
|
||||
const frameworks: Framework[] = [
|
||||
{ value: 'vue', label: 'Vue', group: 'JavaScript' },
|
||||
{ value: 'react', label: 'React', group: 'JavaScript' },
|
||||
{ value: 'svelte', label: 'Svelte', group: 'JavaScript' },
|
||||
{ value: 'solid', label: 'Solid', group: 'JavaScript' },
|
||||
{ value: 'angular', label: 'Angular', group: 'JavaScript' },
|
||||
{ value: 'swiftui', label: 'SwiftUI', group: 'Native' },
|
||||
{ value: 'compose', label: 'Jetpack Compose', group: 'Native' },
|
||||
{ value: 'flutter', label: 'Flutter', group: 'Native' },
|
||||
];
|
||||
|
||||
const selected = ref<string>();
|
||||
|
||||
function labelFor(value: string | undefined) {
|
||||
return frameworks.find(f => f.value === value)?.label ?? '';
|
||||
}
|
||||
|
||||
const groups = ['JavaScript', 'Native'] as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full max-w-xs flex-col gap-3">
|
||||
<ComboboxRoot
|
||||
v-model="selected"
|
||||
:display-value="labelFor"
|
||||
class="relative"
|
||||
>
|
||||
<ComboboxAnchor
|
||||
class="flex items-center gap-1 rounded-lg border border-border bg-bg-inset px-2 py-1.5 focus-within:border-accent focus-within:ring-2 focus-within:ring-ring"
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder="Search a framework..."
|
||||
open-on-click
|
||||
class="min-w-0 flex-1 bg-transparent px-1 text-sm text-fg outline-none placeholder:text-fg-subtle"
|
||||
/>
|
||||
|
||||
<ComboboxCancel
|
||||
class="grid size-5 place-items-center rounded text-fg-subtle hover:bg-bg-subtle hover:text-fg"
|
||||
>
|
||||
<span aria-hidden="true" class="text-xs">✕</span>
|
||||
</ComboboxCancel>
|
||||
|
||||
<ComboboxTrigger
|
||||
class="grid size-5 place-items-center rounded text-fg-muted hover:bg-bg-subtle hover:text-fg data-[state=open]:rotate-180"
|
||||
>
|
||||
<span aria-hidden="true" class="text-xs">▾</span>
|
||||
</ComboboxTrigger>
|
||||
</ComboboxAnchor>
|
||||
|
||||
<ComboboxPortal>
|
||||
<ComboboxContent
|
||||
:side-offset="6"
|
||||
class="z-50 w-(--popper-anchor-width) overflow-hidden rounded-lg border border-border bg-bg-elevated shadow-lg"
|
||||
>
|
||||
<ComboboxViewport class="max-h-60 p-1">
|
||||
<ComboboxEmpty class="px-3 py-6 text-center text-sm text-fg-subtle">
|
||||
No frameworks found.
|
||||
</ComboboxEmpty>
|
||||
|
||||
<ComboboxGroup
|
||||
v-for="group in groups"
|
||||
:key="group"
|
||||
class="mb-1 last:mb-0"
|
||||
>
|
||||
<ComboboxLabel
|
||||
class="demo-label px-2 py-1"
|
||||
>
|
||||
{{ group }}
|
||||
</ComboboxLabel>
|
||||
|
||||
<ComboboxItem
|
||||
v-for="framework in frameworks.filter(f => f.group === group)"
|
||||
:key="framework.value"
|
||||
:value="framework.value"
|
||||
:text-value="framework.label"
|
||||
class="flex cursor-pointer items-center justify-between rounded-md px-2 py-1.5 text-sm text-fg outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-fg data-[disabled]:opacity-50"
|
||||
>
|
||||
<span>{{ framework.label }}</span>
|
||||
<ComboboxItemIndicator>
|
||||
<span aria-hidden="true">✓</span>
|
||||
</ComboboxItemIndicator>
|
||||
</ComboboxItem>
|
||||
</ComboboxGroup>
|
||||
</ComboboxViewport>
|
||||
</ComboboxContent>
|
||||
</ComboboxPortal>
|
||||
</ComboboxRoot>
|
||||
|
||||
<p class="text-sm text-fg-muted">
|
||||
Selected:
|
||||
<span class="font-medium text-fg">{{ selected ? labelFor(selected) : 'none' }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
export { default as ComboboxAnchor } from './ComboboxAnchor.vue';
|
||||
export { default as ComboboxArrow } from './ComboboxArrow.vue';
|
||||
export { default as ComboboxCancel } from './ComboboxCancel.vue';
|
||||
export { default as ComboboxContent } from './ComboboxContent.vue';
|
||||
export { default as ComboboxContentImpl } from './ComboboxContentImpl.vue';
|
||||
export { default as ComboboxEmpty } from './ComboboxEmpty.vue';
|
||||
export { default as ComboboxGroup } from './ComboboxGroup.vue';
|
||||
export { default as ComboboxInput } from './ComboboxInput.vue';
|
||||
export { default as ComboboxItem } from './ComboboxItem.vue';
|
||||
export { default as ComboboxItemIndicator } from './ComboboxItemIndicator.vue';
|
||||
export { default as ComboboxLabel } from './ComboboxLabel.vue';
|
||||
export { default as ComboboxPortal } from './ComboboxPortal.vue';
|
||||
export { default as ComboboxRoot } from './ComboboxRoot.vue';
|
||||
export { default as ComboboxSeparator } from './ComboboxSeparator.vue';
|
||||
export { default as ComboboxTrigger } from './ComboboxTrigger.vue';
|
||||
export { default as ComboboxViewport } from './ComboboxViewport.vue';
|
||||
|
||||
export {
|
||||
useComboboxContentContext,
|
||||
useComboboxGroupContext,
|
||||
useComboboxItemContext,
|
||||
useComboboxRootContext,
|
||||
} from './context';
|
||||
|
||||
export type {
|
||||
ComboboxContentContext,
|
||||
ComboboxFilterState,
|
||||
ComboboxGroupContext,
|
||||
ComboboxItemContext,
|
||||
ComboboxItemInfo,
|
||||
ComboboxRootContext,
|
||||
} from './context';
|
||||
|
||||
export { accentInsensitiveContains } from './utils';
|
||||
export type { AcceptableValue, ComboboxFilterFunction, ComboboxFilterItem } from './utils';
|
||||
|
||||
export type { ComboboxAnchorProps } from './ComboboxAnchor.vue';
|
||||
export type { ComboboxArrowProps } from './ComboboxArrow.vue';
|
||||
export type { ComboboxCancelProps } from './ComboboxCancel.vue';
|
||||
export type { ComboboxContentEmits, ComboboxContentProps } from './ComboboxContent.vue';
|
||||
export type { ComboboxContentImplEmits, ComboboxContentImplProps } from './ComboboxContentImpl.vue';
|
||||
export type { ComboboxEmptyProps } from './ComboboxEmpty.vue';
|
||||
export type { ComboboxGroupProps } from './ComboboxGroup.vue';
|
||||
export type { ComboboxInputProps } from './ComboboxInput.vue';
|
||||
export type { ComboboxItemIndicatorProps } from './ComboboxItemIndicator.vue';
|
||||
export type { ComboboxItemEmits, ComboboxItemProps, ComboboxItemSelectEvent } from './ComboboxItem.vue';
|
||||
export type { ComboboxLabelProps } from './ComboboxLabel.vue';
|
||||
export type { ComboboxPortalProps } from './ComboboxPortal.vue';
|
||||
export type { ComboboxRootEmits, ComboboxRootProps } from './ComboboxRoot.vue';
|
||||
export type { ComboboxSeparatorProps } from './ComboboxSeparator.vue';
|
||||
export type { ComboboxTriggerProps } from './ComboboxTrigger.vue';
|
||||
export type { ComboboxViewportProps } from './ComboboxViewport.vue';
|
||||
@@ -0,0 +1,54 @@
|
||||
export type AcceptableValue = string | number | boolean | Record<string, unknown>;
|
||||
|
||||
export const OPEN_KEYS = ['Enter', ' ', 'ArrowDown', 'ArrowUp', 'PageUp', 'PageDown', 'Home', 'End'];
|
||||
// The input is a text field: Space must type a space and Home/End/Page* must move
|
||||
// the caret, so only the arrow keys open a closed list (typing opens it via input).
|
||||
export const INPUT_OPEN_KEYS = ['ArrowDown', 'ArrowUp'];
|
||||
export const SELECTION_KEYS = ['Enter', ' '];
|
||||
|
||||
export function getOpenState(open: boolean): 'open' | 'closed' {
|
||||
return open ? 'open' : 'closed';
|
||||
}
|
||||
|
||||
// `valueComparator` is listbox's `includes` under a combobox-specific name —
|
||||
// shared from ../utils/compare-values to avoid duplicating the comparison logic.
|
||||
export { includes as valueComparator } from '../../internal/utils/compare-values';
|
||||
|
||||
export interface ComboboxFilterItem {
|
||||
id: string;
|
||||
textValue: string;
|
||||
}
|
||||
|
||||
export type ComboboxFilterFunction = (
|
||||
items: ComboboxFilterItem[],
|
||||
searchTerm: string,
|
||||
) => ComboboxFilterItem[];
|
||||
|
||||
/**
|
||||
* Substring match that ignores case AND diacritics via Unicode canonical
|
||||
* decomposition (`café` matches `cafe`), using only native `String.normalize`
|
||||
* — no `Intl`/locale dependency, deterministic across runtimes.
|
||||
*/
|
||||
function foldAccents(value: string): string {
|
||||
// NFD splits accented characters into base + combining marks; \p{M} (Unicode
|
||||
// Mark) strips those marks, leaving the bare letters.
|
||||
return value.normalize('NFD').replaceAll(/\p{M}/gu, '').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Accent/diacritic-insensitive substring match. Reused by {@link defaultFilter}
|
||||
* and exposed so a custom `filterFunction` can opt into the same folding.
|
||||
*/
|
||||
export function accentInsensitiveContains(haystack: string, needle: string): boolean {
|
||||
return foldAccents(haystack).includes(foldAccents(needle));
|
||||
}
|
||||
|
||||
export const defaultFilter: ComboboxFilterFunction = (items, searchTerm) => {
|
||||
if (!searchTerm) return items;
|
||||
const out: ComboboxFilterItem[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const it = items[i]!;
|
||||
if (accentInsensitiveContains(it.textValue, searchTerm)) out.push(it);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* The scrollable list container (`role="listbox"`) that wraps the items. It
|
||||
* receives focus, owns the keyboard handlers (navigation, enter, type-ahead),
|
||||
* and exposes the collection of items to the root.
|
||||
*/
|
||||
export interface ListboxContentProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { ref } from 'vue';
|
||||
import { useCollectionInjector } from '../../utilities/collection';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { useListboxRootContext } from './context';
|
||||
|
||||
// Module-scoped to avoid re-allocating on every keydown.
|
||||
const NAV_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown']);
|
||||
|
||||
const { as = 'div' } = defineProps<ListboxContentProps>();
|
||||
|
||||
const ctx = useListboxRootContext();
|
||||
const { forwardRef } = useForwardExpose();
|
||||
const { CollectionSlot } = useCollectionInjector();
|
||||
|
||||
const isClickFocus = ref(false);
|
||||
|
||||
function onMouseDown(event: MouseEvent): void {
|
||||
if (event.button !== 0) return;
|
||||
isClickFocus.value = true;
|
||||
setTimeout(() => {
|
||||
isClickFocus.value = false;
|
||||
}, 10);
|
||||
}
|
||||
|
||||
function onFocus(event: FocusEvent): void {
|
||||
if (isClickFocus.value) return;
|
||||
ctx.onEnter(event);
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent): void {
|
||||
const { key } = event;
|
||||
const o = ctx.orientation.value;
|
||||
if ((o === 'vertical' && (key === 'ArrowLeft' || key === 'ArrowRight'))
|
||||
|| (o === 'horizontal' && (key === 'ArrowUp' || key === 'ArrowDown'))) return;
|
||||
|
||||
if (key === 'Enter') return ctx.onKeydownEnter(event);
|
||||
|
||||
if (NAV_KEYS.has(key)) {
|
||||
event.preventDefault();
|
||||
if (ctx.focusable.value) ctx.onKeydownNavigation(event);
|
||||
return;
|
||||
}
|
||||
ctx.onKeydownTypeAhead(event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CollectionSlot>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
role="listbox"
|
||||
:tabindex="ctx.focusable.value ? (ctx.highlightedElement.value ? '-1' : '0') : '-1'"
|
||||
:aria-orientation="ctx.orientation.value"
|
||||
:aria-multiselectable="ctx.multiple.value ? true : undefined"
|
||||
:data-orientation="ctx.orientation.value"
|
||||
@mousedown="onMouseDown"
|
||||
@focus="onFocus"
|
||||
@keydown="onKeyDown"
|
||||
@compositionstart="ctx.onCompositionStart"
|
||||
@compositionend="ctx.onCompositionEnd"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</CollectionSlot>
|
||||
</template>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* An optional text input for filtering the list. While mounted it takes over
|
||||
* focus from the content (driving the list via `aria-activedescendant`),
|
||||
* resets the highlight to the first item on each keystroke, and forwards
|
||||
* Enter / arrow / Home / End keys to the listbox. Filtering of the items
|
||||
* themselves is left to the consumer via `v-model`.
|
||||
*/
|
||||
export interface ListboxFilterProps extends PrimitiveProps {
|
||||
/** Controlled input value. */
|
||||
modelValue?: string;
|
||||
/** Focus on mount. */
|
||||
autoFocus?: boolean;
|
||||
/** Disable input. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ListboxFilterEmits {
|
||||
'update:modelValue': [value: string];
|
||||
}
|
||||
|
||||
export interface ListboxFilterSlots {
|
||||
default?: (props: { modelValue: string }) => unknown;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch, watchSyncEffect } from 'vue';
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { useListboxRootContext } from './context';
|
||||
|
||||
// Module-scoped nav key set: avoids per-keydown array allocation.
|
||||
const NAV_KEYS = new Set(['ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown']);
|
||||
|
||||
const {
|
||||
as = 'input',
|
||||
modelValue,
|
||||
autoFocus = false,
|
||||
disabled = false,
|
||||
} = defineProps<ListboxFilterProps>();
|
||||
|
||||
const emit = defineEmits<ListboxFilterEmits>();
|
||||
defineSlots<ListboxFilterSlots>();
|
||||
|
||||
const ctx = useListboxRootContext();
|
||||
const { forwardRef, currentElement } = useForwardExpose();
|
||||
|
||||
const localValue = ref<string>(modelValue ?? '');
|
||||
watch(() => modelValue, (v) => {
|
||||
if (v === undefined || v === localValue.value) return;
|
||||
localValue.value = v;
|
||||
});
|
||||
|
||||
const isDisabled = computed(() => disabled || ctx.disabled.value);
|
||||
const activedescendant = ref<string | undefined>();
|
||||
watchSyncEffect(() => {
|
||||
activedescendant.value = ctx.highlightedElement.value?.id;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
ctx.focusable.value = false;
|
||||
if (!autoFocus) return;
|
||||
setTimeout(() => {
|
||||
(currentElement.value as HTMLInputElement | undefined)?.focus();
|
||||
}, 1);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
ctx.focusable.value = true;
|
||||
});
|
||||
|
||||
function onInput(event: Event): void {
|
||||
const v = (event.target as HTMLInputElement).value;
|
||||
localValue.value = v;
|
||||
emit('update:modelValue', v);
|
||||
ctx.highlightFirstItem();
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent): void {
|
||||
const { key } = event;
|
||||
if (key === 'Enter') return ctx.onKeydownEnter(event);
|
||||
if (NAV_KEYS.has(key)) {
|
||||
event.preventDefault();
|
||||
ctx.onKeydownNavigation(event);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
type="text"
|
||||
:value="localValue"
|
||||
:disabled="isDisabled || undefined"
|
||||
:aria-disabled="isDisabled ? true : undefined"
|
||||
:aria-activedescendant="activedescendant"
|
||||
:data-disabled="isDisabled ? '' : undefined"
|
||||
@input="onInput"
|
||||
@keydown="onKeyDown"
|
||||
@compositionstart="ctx.onCompositionStart"
|
||||
@compositionend="ctx.onCompositionEnd"
|
||||
>
|
||||
<slot :model-value="localValue" />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* Groups related items under a shared label (`role="group"`). Wraps a set of
|
||||
* `ListboxItem`s and is labelled by its `ListboxGroupLabel` via `aria-labelledby`.
|
||||
*/
|
||||
export interface ListboxGroupProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { provideListboxGroupContext } from './context';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { useId } from '../../utilities/config-provider';
|
||||
|
||||
const { as = 'div' } = defineProps<ListboxGroupProps>();
|
||||
|
||||
const { forwardRef } = useForwardExpose();
|
||||
const id = useId(undefined, 'listbox-group').value;
|
||||
|
||||
provideListboxGroupContext({ id });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
role="group"
|
||||
:aria-labelledby="id"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* The accessible label for a `ListboxGroup`. Its id is wired to the group's
|
||||
* `aria-labelledby`, so place it inside the group to name the set of items.
|
||||
*/
|
||||
export interface ListboxGroupLabelProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { useListboxGroupContext } from './context';
|
||||
|
||||
const { as = 'div' } = defineProps<ListboxGroupLabelProps>();
|
||||
|
||||
const group = useListboxGroupContext();
|
||||
const { forwardRef } = useForwardExpose();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
:id="group.id"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script lang="ts" generic="T extends ListboxValue = ListboxValue">
|
||||
import type { ListboxValue } from './utils';
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* A single selectable option (`role="option"`). Clicking, pressing Space, or
|
||||
* pressing Enter while highlighted toggles its selection and emits `select`.
|
||||
* Exposes `isSelected` / `isHighlighted` to its default slot.
|
||||
*/
|
||||
export interface ListboxItemProps<U extends ListboxValue = ListboxValue> extends PrimitiveProps {
|
||||
/** The value of the item. */
|
||||
value: U;
|
||||
/** Disable this item. */
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Text used for type-ahead matching. Defaults to the item's `textContent`.
|
||||
* Provide this when the visible text differs from what users will type
|
||||
* (e.g. an item rendered with icons/markup).
|
||||
*/
|
||||
textValue?: string;
|
||||
}
|
||||
|
||||
export interface ListboxItemEmits<U extends ListboxValue = ListboxValue> {
|
||||
/** Fired before the value change commits. Call `event.preventDefault()` to cancel. */
|
||||
select: [event: CustomEvent<{ originalEvent: Event; value: U }>];
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="T extends ListboxValue = ListboxValue">
|
||||
import { provideListboxItemContext, useListboxRootContext } from './context';
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { computed } from 'vue';
|
||||
import { useCollectionInjector } from '../../utilities/collection';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { useId } from '../../utilities/config-provider';
|
||||
|
||||
const { as = 'div', value, disabled = false, textValue } = defineProps<ListboxItemProps<T>>();
|
||||
const emit = defineEmits<ListboxItemEmits<T>>();
|
||||
|
||||
const ctx = useListboxRootContext();
|
||||
const { forwardRef, currentElement } = useForwardExpose();
|
||||
const { CollectionItem } = useCollectionInjector();
|
||||
|
||||
const id = useId(undefined, 'listbox-item').value;
|
||||
const isHighlighted = computed(() => currentElement.value === ctx.highlightedElement.value);
|
||||
const isSelected = computed(() => ctx.isSelected(value));
|
||||
const isDisabled = computed(() => disabled || ctx.disabled.value);
|
||||
|
||||
function handleSelect(originalEvent: Event): void {
|
||||
if (isDisabled.value) return;
|
||||
const detail = { originalEvent, value };
|
||||
const custom = new CustomEvent('listbox.select', { bubbles: true, cancelable: true, detail });
|
||||
emit('select', custom);
|
||||
if (custom.defaultPrevented) return;
|
||||
ctx.onValueChange(value);
|
||||
if (currentElement.value) ctx.changeHighlight(currentElement.value);
|
||||
}
|
||||
|
||||
function onClick(event: MouseEvent): void {
|
||||
handleSelect(event);
|
||||
}
|
||||
function onKeyDown(event: KeyboardEvent): void {
|
||||
if (event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
handleSelect(event);
|
||||
}
|
||||
function onPointerMove(): void {
|
||||
if (!ctx.highlightOnHover.value) return;
|
||||
if (!currentElement.value || ctx.highlightedElement.value === currentElement.value) return;
|
||||
ctx.changeHighlight(currentElement.value, false, false);
|
||||
}
|
||||
|
||||
provideListboxItemContext({ isSelected });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CollectionItem :value="value">
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
:id="id"
|
||||
role="option"
|
||||
:tabindex="ctx.focusable.value ? (isHighlighted ? '0' : '-1') : '-1'"
|
||||
:aria-selected="isSelected"
|
||||
:data-state="isSelected ? 'checked' : 'unchecked'"
|
||||
:data-highlighted="isHighlighted ? '' : undefined"
|
||||
:data-disabled="isDisabled ? '' : undefined"
|
||||
:data-text-value="textValue || undefined"
|
||||
:disabled="isDisabled || undefined"
|
||||
@click="onClick"
|
||||
@keydown="onKeyDown"
|
||||
@pointermove="onPointerMove"
|
||||
>
|
||||
<slot :is-selected="isSelected" :is-highlighted="isHighlighted" />
|
||||
</Primitive>
|
||||
</CollectionItem>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* Renders its content only when the parent `ListboxItem` is selected. Use it
|
||||
* to show a checkmark or other selected-state marker; it is `aria-hidden`.
|
||||
*/
|
||||
export interface ListboxItemIndicatorProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { useListboxItemContext } from './context';
|
||||
|
||||
const { as = 'span' } = defineProps<ListboxItemIndicatorProps>();
|
||||
|
||||
const item = useListboxItemContext();
|
||||
const { forwardRef } = useForwardExpose();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
v-if="item.isSelected.value"
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,426 @@
|
||||
<script lang="ts" generic="T extends ListboxValue = ListboxValue">
|
||||
import type { ListboxDirection, ListboxOrientation, ListboxSelectionBehavior } from './context';
|
||||
import type { ListboxValue } from './utils';
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* A list of selectable options that supports single or multiple selection,
|
||||
* full keyboard navigation (arrows, Home/End, PageUp/PageDown, type-ahead),
|
||||
* Shift-range selection, and optional hover highlighting. Use it when you need
|
||||
* an always-visible selection list — picking from a set of values, building a
|
||||
* custom multi-select, or as the options surface inside a larger widget.
|
||||
*
|
||||
* The root owns selection state (controlled via `v-model` or uncontrolled
|
||||
* via `defaultValue`), the highlighted item, orientation/direction, optional
|
||||
* native-form integration (`name`/`required`), and provides context to every
|
||||
* descendant part.
|
||||
*/
|
||||
export interface ListboxRootProps<U extends ListboxValue = ListboxValue> extends PrimitiveProps {
|
||||
/** Uncontrolled initial value. */
|
||||
defaultValue?: U | U[];
|
||||
/** Allow multiple selection. */
|
||||
multiple?: boolean;
|
||||
/** Navigation orientation. @default 'vertical' */
|
||||
orientation?: ListboxOrientation;
|
||||
/** Reading direction. Falls back to `ConfigProvider`. */
|
||||
dir?: ListboxDirection;
|
||||
/** Disable the whole listbox. */
|
||||
disabled?: boolean;
|
||||
/** How selection behaves in `multiple` mode. @default 'toggle' */
|
||||
selectionBehavior?: ListboxSelectionBehavior;
|
||||
/** Highlight items on hover. */
|
||||
highlightOnHover?: boolean;
|
||||
/** Compare objects by key or custom comparator. */
|
||||
by?: string | ((a: U, b: U) => boolean);
|
||||
/** Native input name for form submission. When set, a hidden input mirrors the value. */
|
||||
name?: string;
|
||||
/** Mark as required for native form validation. */
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export interface ListboxRootEmits<U extends ListboxValue = ListboxValue> {
|
||||
highlight: [payload: { ref: HTMLElement; value: U } | undefined];
|
||||
entryFocus: [event: CustomEvent];
|
||||
leave: [event: Event];
|
||||
}
|
||||
|
||||
export interface ListboxRootSlots<U extends ListboxValue = ListboxValue> {
|
||||
default?: (props: { modelValue: U | U[] | undefined }) => unknown;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="T extends ListboxValue = ListboxValue">
|
||||
import { compareDeep, findValuesBetween, getNextMatch, includesDeep } from './utils';
|
||||
import { computed, nextTick, ref, shallowRef, toRef, watch } from 'vue';
|
||||
import { resolveNextIndex, rovingKeyToAction } from '../../internal/utils/roving-focus';
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import type { Ref } from 'vue';
|
||||
import { provideListboxRootContext } from './context';
|
||||
import { useCollectionProvider } from '../../utilities/collection';
|
||||
import { useConfig } from '../../utilities/config-provider';
|
||||
import { refAutoReset, useForwardExpose } from '@robonen/vue';
|
||||
import { VisuallyHiddenInput } from '../../utilities/visually-hidden';
|
||||
|
||||
const {
|
||||
as = 'div',
|
||||
defaultValue,
|
||||
multiple = false,
|
||||
orientation = 'vertical',
|
||||
dir,
|
||||
disabled = false,
|
||||
selectionBehavior = 'toggle',
|
||||
highlightOnHover = false,
|
||||
by,
|
||||
name,
|
||||
required = false,
|
||||
} = defineProps<ListboxRootProps<T>>();
|
||||
|
||||
const emit = defineEmits<ListboxRootEmits<T>>();
|
||||
defineSlots<ListboxRootSlots<T>>();
|
||||
|
||||
const model = defineModel<T | T[] | undefined>();
|
||||
|
||||
const config = useConfig();
|
||||
const direction = computed(() => dir ?? config.dir.value);
|
||||
|
||||
const initial = (model.value ?? defaultValue) as T | T[] | undefined;
|
||||
// shallowRef: value is always replaced on commit, never mutated in place.
|
||||
const localValue = shallowRef<T | T[] | undefined>(
|
||||
multiple
|
||||
? (Array.isArray(initial) ? initial.slice() : (initial === undefined ? [] : [initial]))
|
||||
: (Array.isArray(initial) ? initial[0] : initial),
|
||||
) as Ref<T | T[] | undefined>;
|
||||
|
||||
watch(model, (v) => {
|
||||
if (v === undefined) return;
|
||||
const cur = localValue.value;
|
||||
if (Array.isArray(v)) {
|
||||
if (Array.isArray(cur) && v.length === cur.length) {
|
||||
let equal = true;
|
||||
for (let i = 0; i < v.length; i++) {
|
||||
if (v[i] !== cur[i]) {
|
||||
equal = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (equal) return;
|
||||
}
|
||||
localValue.value = v.slice();
|
||||
}
|
||||
else if (v !== cur) {
|
||||
localValue.value = v as T | T[];
|
||||
}
|
||||
});
|
||||
|
||||
const highlightedElement = shallowRef<HTMLElement>();
|
||||
const previousElement = shallowRef<HTMLElement>();
|
||||
const focusable = ref(true);
|
||||
const isUserAction = ref(false);
|
||||
const isComposing = ref(false);
|
||||
// Anchor for Shift-range selection (multiple + replace).
|
||||
const firstValue = shallowRef<T>();
|
||||
|
||||
const { getItems } = useCollectionProvider();
|
||||
|
||||
// Inlined to avoid two intermediate array allocations (.map + .filter) and two closures
|
||||
// on every call. Called in type-ahead / navigation / enter, which are hot paths.
|
||||
function enabledEls(): HTMLElement[] {
|
||||
const items = getItems(true);
|
||||
const out: HTMLElement[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const el = items[i]!.ref;
|
||||
if (el.dataset.disabled !== '') out.push(el);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isSelected(value: T): boolean {
|
||||
return includesDeep(localValue.value, value, by);
|
||||
}
|
||||
|
||||
// Buffered multi-character type-ahead: accumulates keystrokes, resets ~1s after
|
||||
// the last one. Repeated single characters cycle through matches; longer
|
||||
// buffers match by prefix. An item may override its match text via
|
||||
// `data-text-value` (set by `ListboxItem`'s `textValue` prop).
|
||||
const searchBuffer = refAutoReset('', 1000);
|
||||
function textOf(el: HTMLElement): string {
|
||||
return el.dataset.textValue ?? el.textContent?.trim() ?? '';
|
||||
}
|
||||
function typeAheadMatch(key: string): HTMLElement | undefined {
|
||||
const els = enabledEls();
|
||||
if (els.length === 0) return undefined;
|
||||
searchBuffer.value += key;
|
||||
const values = els.map(textOf);
|
||||
const current = highlightedElement.value ? textOf(highlightedElement.value) : undefined;
|
||||
const next = getNextMatch(values, searchBuffer.value, current);
|
||||
if (next === undefined) return undefined;
|
||||
return els[values.indexOf(next)];
|
||||
}
|
||||
|
||||
function commit(next: T | T[] | undefined): void {
|
||||
localValue.value = next;
|
||||
model.value = next;
|
||||
}
|
||||
|
||||
function onValueChange(val: T): void {
|
||||
isUserAction.value = true;
|
||||
if (multiple) {
|
||||
const cur = Array.isArray(localValue.value) ? [...(localValue.value as T[])] : [];
|
||||
if (selectionBehavior === 'toggle') {
|
||||
const idx = cur.findIndex(i => compareDeep(i, val, by));
|
||||
if (idx === -1) cur.push(val);
|
||||
else cur.splice(idx, 1);
|
||||
commit(cur);
|
||||
}
|
||||
else {
|
||||
firstValue.value = val;
|
||||
commit([val]);
|
||||
}
|
||||
}
|
||||
else if (selectionBehavior === 'toggle') {
|
||||
commit(compareDeep(localValue.value as T | undefined, val, by) ? undefined : val);
|
||||
}
|
||||
else {
|
||||
commit(val);
|
||||
}
|
||||
// Reset after the commit-driven watcher flush rather than via a real timer
|
||||
// (avoids allocating a timer handle and the 1ms latency).
|
||||
nextTick(() => {
|
||||
isUserAction.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function changeHighlight(el: HTMLElement | undefined, scrollIntoView = true, focus?: boolean): void {
|
||||
if (!el) return;
|
||||
highlightedElement.value = el;
|
||||
if (focus ?? focusable.value) el.focus({ preventScroll: !scrollIntoView });
|
||||
if (scrollIntoView) el.scrollIntoView({ block: 'nearest' });
|
||||
const hit = getItems(true).find(i => i.ref === el);
|
||||
// getItems is untyped (CollectionItemData<unknown>); a matched item always
|
||||
// carries a value, so assert the emit payload shape rather than widen to any.
|
||||
emit('highlight', hit as { ref: HTMLElement; value: T } | undefined);
|
||||
}
|
||||
|
||||
function highlightItem(value: T): void {
|
||||
const item = getItems(true).find(i => compareDeep(i.value as T, value, by));
|
||||
if (item) changeHighlight(item.ref);
|
||||
}
|
||||
|
||||
function onKeydownEnter(event: KeyboardEvent): void {
|
||||
const el = highlightedElement.value;
|
||||
if (!el || !el.isConnected) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// Do not commit a selection while an IME composition is in progress.
|
||||
if (isComposing.value) return;
|
||||
el.click();
|
||||
}
|
||||
|
||||
// Apply contiguous range selection for multiple + replace mode on Shift+navigation.
|
||||
// `targetEl` is the element the highlight is about to move to.
|
||||
function handleMultipleReplace(event: KeyboardEvent, targetEl: HTMLElement | undefined): void {
|
||||
if (selectionBehavior !== 'replace' || !multiple || !Array.isArray(localValue.value)) return;
|
||||
const isMetaKey = event.altKey || event.ctrlKey || event.metaKey;
|
||||
if (isMetaKey && !event.shiftKey) return;
|
||||
if (!event.shiftKey) return;
|
||||
|
||||
const collection = enabledEls();
|
||||
const itemOf = (el: HTMLElement | undefined) => getItems(true).find(i => i.ref === el)?.value as T | undefined;
|
||||
|
||||
let lastValue = itemOf(targetEl);
|
||||
if (event.key === 'End' || event.key === 'PageDown') lastValue = itemOf(collection.at(-1));
|
||||
else if (event.key === 'Home' || event.key === 'PageUp') lastValue = itemOf(collection[0]);
|
||||
|
||||
if (lastValue === undefined || firstValue.value === undefined) return;
|
||||
|
||||
const allValues = getItems(true).map(i => i.value as T);
|
||||
const values = findValuesBetween(allValues, firstValue.value, lastValue, by);
|
||||
commit(values);
|
||||
}
|
||||
|
||||
function onKeydownNavigation(event: KeyboardEvent): void {
|
||||
const intent = rovingKeyToAction(event, { orientation, dir: direction.value, loop: false });
|
||||
// Page keys jump to first/last regardless of orientation.
|
||||
const isPageUp = event.key === 'PageUp';
|
||||
const isPageDown = event.key === 'PageDown';
|
||||
if (!intent && !isPageUp && !isPageDown) return;
|
||||
|
||||
const els = enabledEls();
|
||||
if (els.length === 0) return;
|
||||
|
||||
let target: HTMLElement | undefined;
|
||||
if (isPageUp || intent?.absolute === 'home') {
|
||||
target = els[0];
|
||||
}
|
||||
else if (isPageDown || intent?.absolute === 'end') {
|
||||
target = els[els.length - 1];
|
||||
}
|
||||
else {
|
||||
const current = highlightedElement.value;
|
||||
const idx = current ? els.indexOf(current) : -1;
|
||||
if (idx === -1) {
|
||||
target = intent!.delta < 0 ? els[els.length - 1] : els[0];
|
||||
}
|
||||
else {
|
||||
target = els[resolveNextIndex(idx, intent!.delta, els.length, false)];
|
||||
}
|
||||
}
|
||||
|
||||
handleMultipleReplace(event, target);
|
||||
changeHighlight(target);
|
||||
}
|
||||
|
||||
function onKeydownTypeAhead(event: KeyboardEvent): void {
|
||||
if (!focusable.value) return;
|
||||
isUserAction.value = true;
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) {
|
||||
if (event.key.toLowerCase() === 'a' && multiple) {
|
||||
const all = getItems(true).map(i => i.value) as T[];
|
||||
commit(all);
|
||||
event.preventDefault();
|
||||
const last = enabledEls().at(-1);
|
||||
if (last) changeHighlight(last);
|
||||
}
|
||||
nextTick(() => {
|
||||
isUserAction.value = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.key.length === 1) {
|
||||
const el = typeAheadMatch(event.key);
|
||||
if (el) changeHighlight(el);
|
||||
}
|
||||
nextTick(() => {
|
||||
isUserAction.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function highlightFirstItem(): void {
|
||||
nextTick(() => {
|
||||
const el = enabledEls()[0];
|
||||
if (el) changeHighlight(el);
|
||||
});
|
||||
}
|
||||
|
||||
function onCompositionStart(): void {
|
||||
isComposing.value = true;
|
||||
}
|
||||
function onCompositionEnd(): void {
|
||||
nextTick(() => {
|
||||
isComposing.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function onLeave(event: Event): void {
|
||||
const el = highlightedElement.value;
|
||||
if (el?.isConnected) previousElement.value = el;
|
||||
highlightedElement.value = undefined;
|
||||
emit('leave', event);
|
||||
}
|
||||
|
||||
function onEnter(event: Event): void {
|
||||
const entryFocusEvent = new CustomEvent('listbox.entryFocus', { bubbles: false, cancelable: true });
|
||||
(event.currentTarget as HTMLElement | null)?.dispatchEvent(entryFocusEvent);
|
||||
emit('entryFocus', entryFocusEvent);
|
||||
if (entryFocusEvent.defaultPrevented) return;
|
||||
if (previousElement.value?.isConnected) {
|
||||
changeHighlight(previousElement.value);
|
||||
return;
|
||||
}
|
||||
const els = enabledEls();
|
||||
for (let i = 0; i < els.length; i++) {
|
||||
if (els[i]!.dataset.state === 'checked') return changeHighlight(els[i]);
|
||||
}
|
||||
changeHighlight(els[0]);
|
||||
}
|
||||
|
||||
async function onFocusOut(event: FocusEvent): Promise<void> {
|
||||
const target = (event.relatedTarget || event.target) as HTMLElement | null;
|
||||
await nextTick();
|
||||
if (highlightedElement.value && currentElement.value && !currentElement.value.contains(target)) {
|
||||
onLeave(event);
|
||||
}
|
||||
}
|
||||
|
||||
function highlightSelected(): void {
|
||||
nextTick(() => {
|
||||
const els = enabledEls();
|
||||
for (let i = 0; i < els.length; i++) {
|
||||
if (els[i]!.dataset.state === 'checked') {
|
||||
changeHighlight(els[i], true, false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// localValue is always replaced on commit — no need for deep traversal.
|
||||
watch(localValue, () => {
|
||||
if (isUserAction.value) return;
|
||||
highlightSelected();
|
||||
}, { immediate: true });
|
||||
|
||||
provideListboxRootContext({
|
||||
modelValue: localValue,
|
||||
multiple: toRef(() => multiple),
|
||||
orientation: toRef(() => orientation),
|
||||
direction,
|
||||
disabled: toRef(() => disabled),
|
||||
highlightOnHover: toRef(() => highlightOnHover),
|
||||
selectionBehavior: toRef(() => selectionBehavior),
|
||||
highlightedElement,
|
||||
focusable,
|
||||
firstValue,
|
||||
by,
|
||||
onValueChange,
|
||||
isSelected,
|
||||
changeHighlight,
|
||||
onKeydownNavigation,
|
||||
onKeydownEnter,
|
||||
onKeydownTypeAhead,
|
||||
highlightFirstItem,
|
||||
onEnter,
|
||||
onLeave,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
});
|
||||
|
||||
// Expose the imperative API before `useForwardExpose` so the latter merges it
|
||||
// into the forwarded expose (it reads `instance.exposed`), keeping both the
|
||||
// element ref-forwarding and the imperative API without calling expose() twice.
|
||||
defineExpose({
|
||||
/** The currently highlighted DOM element (or `undefined`). */
|
||||
highlightedElement,
|
||||
/** Highlight the item whose value matches `value` (uses `by` comparator). */
|
||||
highlightItem,
|
||||
/** Highlight the first enabled item. */
|
||||
highlightFirstItem,
|
||||
/** Re-highlight the currently selected item (or the first one). */
|
||||
highlightSelected,
|
||||
/** Returns the collection items in DOM order. */
|
||||
getItems,
|
||||
});
|
||||
|
||||
const { forwardRef, currentElement } = useForwardExpose();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
:dir="direction"
|
||||
:data-disabled="disabled ? '' : undefined"
|
||||
@pointerleave="onLeave"
|
||||
@focusout="onFocusOut"
|
||||
>
|
||||
<slot :model-value="localValue" />
|
||||
|
||||
<VisuallyHiddenInput
|
||||
v-if="name"
|
||||
:name="name"
|
||||
:value="localValue"
|
||||
:disabled="disabled"
|
||||
:required="required"
|
||||
/>
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,421 @@
|
||||
import {
|
||||
ListboxContent,
|
||||
ListboxFilter,
|
||||
ListboxGroup,
|
||||
ListboxGroupLabel,
|
||||
ListboxItem,
|
||||
ListboxItemIndicator,
|
||||
ListboxRoot,
|
||||
|
||||
} from '../index';
|
||||
import type { ListboxValue } from '../index';
|
||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
function createListbox(
|
||||
rootProps: Record<string, unknown> = {},
|
||||
options: string[] = ['Apple', 'Banana', 'Cherry'],
|
||||
) {
|
||||
return mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
const value = ref(rootProps.modelValue ?? rootProps.defaultValue);
|
||||
return () => h(
|
||||
ListboxRoot,
|
||||
{
|
||||
modelValue: value.value as ListboxValue | ListboxValue[] | undefined,
|
||||
'onUpdate:modelValue': (v: unknown) => (value.value = v),
|
||||
...rootProps,
|
||||
},
|
||||
{
|
||||
default: () => h(ListboxContent, null, {
|
||||
default: () => options.map(opt =>
|
||||
h(ListboxItem, { key: opt, value: opt }, {
|
||||
default: () => [
|
||||
opt,
|
||||
h(ListboxItemIndicator, null, { default: () => '✓' }),
|
||||
],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
);
|
||||
},
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
}
|
||||
|
||||
function press(el: Element, key: string, init: KeyboardEventInit = {}) {
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }));
|
||||
}
|
||||
|
||||
describe('Listbox', () => {
|
||||
it('renders role=listbox with options', () => {
|
||||
const w = createListbox();
|
||||
const list = w.find('[role="listbox"]');
|
||||
expect(list.exists()).toBe(true);
|
||||
expect(w.findAll('[role="option"]')).toHaveLength(3);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('click selects an item (single)', async () => {
|
||||
const w = createListbox();
|
||||
const items = w.findAll('[role="option"]');
|
||||
await items[1]!.trigger('click');
|
||||
await nextTick();
|
||||
expect(items[1]!.attributes('aria-selected')).toBe('true');
|
||||
expect(items[1]!.attributes('data-state')).toBe('checked');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('toggle deselects on second click in single mode', async () => {
|
||||
const w = createListbox();
|
||||
const items = w.findAll('[role="option"]');
|
||||
await items[0]!.trigger('click');
|
||||
await items[0]!.trigger('click');
|
||||
await nextTick();
|
||||
expect(items[0]!.attributes('data-state')).toBe('unchecked');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('multiple selection accumulates toggles', async () => {
|
||||
const w = createListbox({ multiple: true });
|
||||
const items = w.findAll('[role="option"]');
|
||||
await items[0]!.trigger('click');
|
||||
await items[2]!.trigger('click');
|
||||
await nextTick();
|
||||
expect(items[0]!.attributes('data-state')).toBe('checked');
|
||||
expect(items[2]!.attributes('data-state')).toBe('checked');
|
||||
expect(items[1]!.attributes('data-state')).toBe('unchecked');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('multiple selectionBehavior=replace keeps a single value in the array', async () => {
|
||||
const w = createListbox({ multiple: true, selectionBehavior: 'replace' });
|
||||
const items = w.findAll('[role="option"]');
|
||||
await items[0]!.trigger('click');
|
||||
await items[1]!.trigger('click');
|
||||
await nextTick();
|
||||
expect(items[0]!.attributes('data-state')).toBe('unchecked');
|
||||
expect(items[1]!.attributes('data-state')).toBe('checked');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('ArrowDown on listbox highlights next enabled item', async () => {
|
||||
const w = createListbox();
|
||||
const list = w.find('[role="listbox"]').element as HTMLElement;
|
||||
list.focus();
|
||||
// entry focus highlights first item
|
||||
await nextTick();
|
||||
press(list, 'ArrowDown');
|
||||
await nextTick();
|
||||
const items = w.findAll('[role="option"]');
|
||||
expect(items[1]!.attributes('data-highlighted')).toBe('');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('Home/End jump to first/last item', async () => {
|
||||
const w = createListbox();
|
||||
const list = w.find('[role="listbox"]').element as HTMLElement;
|
||||
list.focus();
|
||||
await nextTick();
|
||||
press(list, 'End');
|
||||
await nextTick();
|
||||
const items = w.findAll('[role="option"]');
|
||||
expect(items[2]!.attributes('data-highlighted')).toBe('');
|
||||
press(list, 'Home');
|
||||
await nextTick();
|
||||
expect(items[0]!.attributes('data-highlighted')).toBe('');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('Enter activates the highlighted item', async () => {
|
||||
const w = createListbox();
|
||||
const list = w.find('[role="listbox"]').element as HTMLElement;
|
||||
list.focus();
|
||||
await nextTick();
|
||||
press(list, 'End');
|
||||
await nextTick();
|
||||
press(list, 'Enter');
|
||||
await nextTick();
|
||||
const items = w.findAll('[role="option"]');
|
||||
expect(items[2]!.attributes('data-state')).toBe('checked');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('typeahead jumps to first matching item by letter', async () => {
|
||||
const w = createListbox();
|
||||
const list = w.find('[role="listbox"]').element as HTMLElement;
|
||||
list.focus();
|
||||
await nextTick();
|
||||
press(list, 'c');
|
||||
await nextTick();
|
||||
const items = w.findAll('[role="option"]');
|
||||
expect(items[2]!.attributes('data-highlighted')).toBe('');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('ItemIndicator renders only for selected items', async () => {
|
||||
const w = createListbox({ defaultValue: 'Banana' });
|
||||
await nextTick();
|
||||
const items = w.findAll('[role="option"]');
|
||||
expect(items[0]!.text()).not.toContain('✓');
|
||||
expect(items[1]!.text()).toContain('✓');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('disabled root prevents selection', async () => {
|
||||
const w = createListbox({ disabled: true });
|
||||
const items = w.findAll('[role="option"]');
|
||||
await items[0]!.trigger('click');
|
||||
await nextTick();
|
||||
expect(items[0]!.attributes('data-state')).toBe('unchecked');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('group exposes role=group with aria-labelledby', () => {
|
||||
const w = mount(
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(ListboxRoot, null, {
|
||||
default: () => h(ListboxContent, null, {
|
||||
default: () => h(ListboxGroup, null, {
|
||||
default: () => [
|
||||
h(ListboxGroupLabel, null, { default: () => 'Fruits' }),
|
||||
h(ListboxItem, { value: 'x' }, { default: () => 'x' }),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
const g = w.find('[role="group"]');
|
||||
expect(g.exists()).toBe(true);
|
||||
const labelledBy = g.attributes('aria-labelledby')!;
|
||||
expect(w.find(`#${labelledBy}`).text()).toBe('Fruits');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('filter disables intrinsic focusability and mirrors highlight via aria-activedescendant', async () => {
|
||||
const w = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
const q = ref('');
|
||||
return () => h(ListboxRoot, null, {
|
||||
default: () => [
|
||||
h(ListboxFilter, {
|
||||
modelValue: q.value,
|
||||
'onUpdate:modelValue': (v: string) => (q.value = v),
|
||||
}),
|
||||
h(ListboxContent, null, {
|
||||
default: () => ['a', 'b'].map(v =>
|
||||
h(ListboxItem, { key: v, value: v }, { default: () => v }),
|
||||
),
|
||||
}),
|
||||
],
|
||||
});
|
||||
},
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
const input = w.find('input').element as HTMLInputElement;
|
||||
input.focus();
|
||||
press(input, 'ArrowDown');
|
||||
await nextTick();
|
||||
const firstItem = w.findAll('[role="option"]')[0]!;
|
||||
expect(input.getAttribute('aria-activedescendant')).toBe(firstItem.attributes('id'));
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('PageUp/PageDown jump to first/last item', async () => {
|
||||
const w = createListbox();
|
||||
const list = w.find('[role="listbox"]').element as HTMLElement;
|
||||
list.focus();
|
||||
await nextTick();
|
||||
press(list, 'PageDown');
|
||||
await nextTick();
|
||||
const items = w.findAll('[role="option"]');
|
||||
expect(items[2]!.attributes('data-highlighted')).toBe('');
|
||||
press(list, 'PageUp');
|
||||
await nextTick();
|
||||
expect(items[0]!.attributes('data-highlighted')).toBe('');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('Ctrl+A selects all in multiple mode', async () => {
|
||||
const w = createListbox({ multiple: true });
|
||||
const list = w.find('[role="listbox"]').element as HTMLElement;
|
||||
list.focus();
|
||||
await nextTick();
|
||||
press(list, 'a', { ctrlKey: true });
|
||||
await nextTick();
|
||||
const items = w.findAll('[role="option"]');
|
||||
expect(items[0]!.attributes('data-state')).toBe('checked');
|
||||
expect(items[1]!.attributes('data-state')).toBe('checked');
|
||||
expect(items[2]!.attributes('data-state')).toBe('checked');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('Shift+ArrowDown selects a contiguous range (multiple + replace)', async () => {
|
||||
const w = createListbox(
|
||||
{ multiple: true, selectionBehavior: 'replace' },
|
||||
['A', 'B', 'C', 'D'],
|
||||
);
|
||||
const items = w.findAll('[role="option"]');
|
||||
// Click sets the range anchor.
|
||||
await items[0]!.trigger('click');
|
||||
await nextTick();
|
||||
const list = w.find('[role="listbox"]').element as HTMLElement;
|
||||
press(list, 'ArrowDown', { shiftKey: true });
|
||||
await nextTick();
|
||||
expect(items[0]!.attributes('data-state')).toBe('checked');
|
||||
expect(items[1]!.attributes('data-state')).toBe('checked');
|
||||
expect(items[2]!.attributes('data-state')).toBe('unchecked');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('Shift+End selects a range to the last item (multiple + replace)', async () => {
|
||||
const w = createListbox(
|
||||
{ multiple: true, selectionBehavior: 'replace' },
|
||||
['A', 'B', 'C', 'D'],
|
||||
);
|
||||
const items = w.findAll('[role="option"]');
|
||||
await items[1]!.trigger('click');
|
||||
await nextTick();
|
||||
const list = w.find('[role="listbox"]').element as HTMLElement;
|
||||
press(list, 'End', { shiftKey: true });
|
||||
await nextTick();
|
||||
expect(items[0]!.attributes('data-state')).toBe('unchecked');
|
||||
expect(items[1]!.attributes('data-state')).toBe('checked');
|
||||
expect(items[2]!.attributes('data-state')).toBe('checked');
|
||||
expect(items[3]!.attributes('data-state')).toBe('checked');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('buffered multi-character typeahead matches by prefix', async () => {
|
||||
const w = createListbox({}, ['Apricot', 'Apple', 'Avocado']);
|
||||
const list = w.find('[role="listbox"]').element as HTMLElement;
|
||||
list.focus();
|
||||
await nextTick();
|
||||
press(list, 'a');
|
||||
press(list, 'p');
|
||||
press(list, 'p');
|
||||
await nextTick();
|
||||
const items = w.findAll('[role="option"]');
|
||||
// "app" should land on "Apple", not "Apricot".
|
||||
expect(items[1]!.attributes('data-highlighted')).toBe('');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('typeahead honours the textValue override', async () => {
|
||||
const w = mount(
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(ListboxRoot, null, {
|
||||
default: () => h(ListboxContent, null, {
|
||||
default: () => [
|
||||
h(ListboxItem, { value: 'a', textValue: 'Zebra' }, { default: () => '🦓 Animal' }),
|
||||
h(ListboxItem, { value: 'b', textValue: 'Apple' }, { default: () => '🍎 Fruit' }),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
const list = w.find('[role="listbox"]').element as HTMLElement;
|
||||
list.focus();
|
||||
await nextTick();
|
||||
press(list, 'z');
|
||||
await nextTick();
|
||||
const items = w.findAll('[role="option"]');
|
||||
expect(items[0]!.attributes('data-highlighted')).toBe('');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('selects structurally-equal object values without `by`', async () => {
|
||||
const options = [{ id: 1, label: 'One' }, { id: 2, label: 'Two' }];
|
||||
const w = mount(
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(ListboxRoot, { defaultValue: { id: 2, label: 'Two' } }, {
|
||||
default: () => h(ListboxContent, null, {
|
||||
default: () => options.map(o =>
|
||||
h(ListboxItem, { key: o.id, value: o }, { default: () => o.label }),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
await nextTick();
|
||||
const items = w.findAll('[role="option"]');
|
||||
// A different object instance with the same shape is still recognised.
|
||||
expect(items[1]!.attributes('data-state')).toBe('checked');
|
||||
expect(items[0]!.attributes('data-state')).toBe('unchecked');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('renders a hidden form input mirroring the value when name is set', async () => {
|
||||
const w = createListbox({ name: 'fruit', defaultValue: 'Banana' });
|
||||
await nextTick();
|
||||
const hidden = w.find('input[name="fruit"]');
|
||||
expect(hidden.exists()).toBe(true);
|
||||
expect((hidden.element as HTMLInputElement).value).toBe('Banana');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('does not render a hidden input without a name', () => {
|
||||
const w = createListbox({ defaultValue: 'Banana' });
|
||||
expect(w.find('input').exists()).toBe(false);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('exposes an imperative API on the root', async () => {
|
||||
const rootRef = ref<{ highlightItem: (v: ListboxValue) => void; getItems: () => unknown[]; highlightedElement: unknown } | null>(null);
|
||||
const w = mount(
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(ListboxRoot, { ref: rootRef }, {
|
||||
default: () => h(ListboxContent, null, {
|
||||
default: () => ['Apple', 'Banana', 'Cherry'].map(o =>
|
||||
h(ListboxItem, { key: o, value: o }, { default: () => o }),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
await nextTick();
|
||||
expect(typeof rootRef.value!.highlightItem).toBe('function');
|
||||
expect(rootRef.value!.getItems().length).toBe(3);
|
||||
rootRef.value!.highlightItem('Cherry');
|
||||
await nextTick();
|
||||
const items = w.findAll('[role="option"]');
|
||||
expect(items[2]!.attributes('data-highlighted')).toBe('');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('Enter mid IME composition does not commit a selection', async () => {
|
||||
const w = createListbox();
|
||||
const list = w.find('[role="listbox"]').element as HTMLElement;
|
||||
list.focus();
|
||||
await nextTick();
|
||||
press(list, 'End');
|
||||
await nextTick();
|
||||
list.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
|
||||
press(list, 'Enter');
|
||||
await nextTick();
|
||||
const items = w.findAll('[role="option"]');
|
||||
expect(items[2]!.attributes('data-state')).toBe('unchecked');
|
||||
// After composition ends, Enter commits again.
|
||||
list.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }));
|
||||
await nextTick();
|
||||
press(list, 'Enter');
|
||||
await nextTick();
|
||||
expect(items[2]!.attributes('data-state')).toBe('checked');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Ref, ShallowRef } from 'vue';
|
||||
import type { ListboxValue } from './utils';
|
||||
import { useContextFactory } from '@robonen/vue';
|
||||
|
||||
export type ListboxOrientation = 'vertical' | 'horizontal';
|
||||
export type ListboxDirection = 'ltr' | 'rtl';
|
||||
export type ListboxSelectionBehavior = 'toggle' | 'replace';
|
||||
|
||||
export interface ListboxRootContext<T extends ListboxValue = ListboxValue> {
|
||||
modelValue: Ref<T | T[] | undefined>;
|
||||
multiple: Ref<boolean>;
|
||||
orientation: Ref<ListboxOrientation>;
|
||||
direction: Ref<ListboxDirection>;
|
||||
disabled: Ref<boolean>;
|
||||
highlightOnHover: Ref<boolean>;
|
||||
selectionBehavior: Ref<ListboxSelectionBehavior>;
|
||||
highlightedElement: ShallowRef<HTMLElement | undefined>;
|
||||
focusable: Ref<boolean>;
|
||||
/** Anchor value for Shift-range selection in `multiple` + `replace` mode. */
|
||||
firstValue: ShallowRef<T | undefined>;
|
||||
by?: string | ((a: T, b: T) => boolean);
|
||||
|
||||
onValueChange: (value: T) => void;
|
||||
isSelected: (value: T) => boolean;
|
||||
changeHighlight: (el: HTMLElement | undefined, scrollIntoView?: boolean, focus?: boolean) => void;
|
||||
onKeydownNavigation: (event: KeyboardEvent) => void;
|
||||
onKeydownEnter: (event: KeyboardEvent) => void;
|
||||
onKeydownTypeAhead: (event: KeyboardEvent) => void;
|
||||
highlightFirstItem: () => void;
|
||||
onEnter: (event: Event) => void;
|
||||
onLeave: (event: Event) => void;
|
||||
/** IME composition guards so Enter mid-composition does not commit a selection. */
|
||||
onCompositionStart: () => void;
|
||||
onCompositionEnd: () => void;
|
||||
}
|
||||
|
||||
export interface ListboxItemContext {
|
||||
isSelected: Ref<boolean>;
|
||||
}
|
||||
|
||||
export interface ListboxGroupContext {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// `any` (not `ListboxValue`): the generic `ListboxRoot` provides a
|
||||
// `ListboxRootContext<T>` whose contravariant callbacks ((value: T) => void) are
|
||||
// not assignable to the concrete `ListboxValue` instantiation. `any` is the
|
||||
// type-erasure boundary letting the generic root provide and descendants inject.
|
||||
export const {
|
||||
inject: useListboxRootContext,
|
||||
provide: provideListboxRootContext,
|
||||
} = useContextFactory<ListboxRootContext<any>>('listbox');
|
||||
|
||||
export const {
|
||||
inject: useListboxItemContext,
|
||||
provide: provideListboxItemContext,
|
||||
} = useContextFactory<ListboxItemContext>('listbox-item');
|
||||
|
||||
export const {
|
||||
inject: useListboxGroupContext,
|
||||
provide: provideListboxGroupContext,
|
||||
} = useContextFactory<ListboxGroupContext>('listbox-group');
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import {
|
||||
ListboxContent,
|
||||
ListboxFilter,
|
||||
ListboxGroup,
|
||||
ListboxGroupLabel,
|
||||
ListboxItem,
|
||||
ListboxItemIndicator,
|
||||
ListboxRoot,
|
||||
} from '@robonen/primitives';
|
||||
|
||||
interface Fruit {
|
||||
value: string;
|
||||
label: string;
|
||||
group: 'Citrus' | 'Berries';
|
||||
}
|
||||
|
||||
const fruits: Fruit[] = [
|
||||
{ value: 'orange', label: 'Orange', group: 'Citrus' },
|
||||
{ value: 'lemon', label: 'Lemon', group: 'Citrus' },
|
||||
{ value: 'lime', label: 'Lime', group: 'Citrus' },
|
||||
{ value: 'strawberry', label: 'Strawberry', group: 'Berries' },
|
||||
{ value: 'blueberry', label: 'Blueberry', group: 'Berries' },
|
||||
{ value: 'raspberry', label: 'Raspberry', group: 'Berries' },
|
||||
];
|
||||
|
||||
const selected = ref<string[]>(['lemon']);
|
||||
const query = ref('');
|
||||
|
||||
const groups = ['Citrus', 'Berries'] as const;
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = query.value.trim().toLowerCase();
|
||||
return q ? fruits.filter(f => f.label.toLowerCase().includes(q)) : fruits;
|
||||
});
|
||||
|
||||
function itemsFor(group: (typeof groups)[number]) {
|
||||
return filtered.value.filter(f => f.group === group);
|
||||
}
|
||||
|
||||
function labelFor(value: string) {
|
||||
return fruits.find(f => f.value === value)?.label ?? value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full max-w-xs flex-col gap-3">
|
||||
<ListboxRoot
|
||||
v-model="selected"
|
||||
multiple
|
||||
highlight-on-hover
|
||||
class="overflow-hidden rounded-lg border border-border bg-bg-elevated"
|
||||
>
|
||||
<div class="border-b border-border p-2">
|
||||
<ListboxFilter
|
||||
v-model="query"
|
||||
placeholder="Filter fruit..."
|
||||
class="w-full rounded-md border border-border bg-bg-inset px-2.5 py-1.5 text-sm text-fg outline-none placeholder:text-fg-subtle focus:border-accent focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ListboxContent class="max-h-64 overflow-y-auto p-1 outline-none">
|
||||
<p
|
||||
v-if="filtered.length === 0"
|
||||
class="px-3 py-6 text-center text-sm text-fg-subtle"
|
||||
>
|
||||
No fruit found.
|
||||
</p>
|
||||
|
||||
<ListboxGroup
|
||||
v-for="group in groups"
|
||||
v-show="itemsFor(group).length"
|
||||
:key="group"
|
||||
class="mb-1 last:mb-0"
|
||||
>
|
||||
<ListboxGroupLabel
|
||||
class="demo-label px-2 py-1"
|
||||
>
|
||||
{{ group }}
|
||||
</ListboxGroupLabel>
|
||||
|
||||
<ListboxItem
|
||||
v-for="fruit in itemsFor(group)"
|
||||
:key="fruit.value"
|
||||
:value="fruit.value"
|
||||
class="flex cursor-pointer items-center justify-between rounded-md px-2 py-1.5 text-sm text-fg outline-none data-[highlighted]:bg-bg-subtle data-[state=checked]:text-accent data-[disabled]:opacity-50"
|
||||
>
|
||||
<span>{{ fruit.label }}</span>
|
||||
<ListboxItemIndicator class="text-accent">
|
||||
<span aria-hidden="true">✓</span>
|
||||
</ListboxItemIndicator>
|
||||
</ListboxItem>
|
||||
</ListboxGroup>
|
||||
</ListboxContent>
|
||||
</ListboxRoot>
|
||||
|
||||
<p class="text-sm text-fg-muted">
|
||||
Selected:
|
||||
<span class="font-medium text-fg">
|
||||
{{ selected.length ? selected.map(labelFor).join(', ') : 'none' }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
export { default as ListboxRoot } from './ListboxRoot.vue';
|
||||
export { default as ListboxContent } from './ListboxContent.vue';
|
||||
export { default as ListboxItem } from './ListboxItem.vue';
|
||||
export { default as ListboxItemIndicator } from './ListboxItemIndicator.vue';
|
||||
export { default as ListboxGroup } from './ListboxGroup.vue';
|
||||
export { default as ListboxGroupLabel } from './ListboxGroupLabel.vue';
|
||||
export { default as ListboxFilter } from './ListboxFilter.vue';
|
||||
|
||||
export {
|
||||
provideListboxRootContext,
|
||||
useListboxRootContext,
|
||||
provideListboxItemContext,
|
||||
useListboxItemContext,
|
||||
provideListboxGroupContext,
|
||||
useListboxGroupContext,
|
||||
type ListboxRootContext,
|
||||
type ListboxItemContext,
|
||||
type ListboxGroupContext,
|
||||
type ListboxOrientation,
|
||||
type ListboxDirection,
|
||||
type ListboxSelectionBehavior,
|
||||
} from './context';
|
||||
|
||||
export {
|
||||
compare as compareListboxValues,
|
||||
includes as includesListboxValue,
|
||||
compareDeep as compareListboxValuesDeep,
|
||||
includesDeep as includesListboxValueDeep,
|
||||
findValuesBetween as findListboxValuesBetween,
|
||||
type ListboxValue,
|
||||
} from './utils';
|
||||
|
||||
export type { ListboxRootProps, ListboxRootEmits, ListboxRootSlots } from './ListboxRoot.vue';
|
||||
export type { ListboxContentProps } from './ListboxContent.vue';
|
||||
export type { ListboxItemProps, ListboxItemEmits } from './ListboxItem.vue';
|
||||
export type { ListboxItemIndicatorProps } from './ListboxItemIndicator.vue';
|
||||
export type { ListboxGroupProps } from './ListboxGroup.vue';
|
||||
export type { ListboxGroupLabelProps } from './ListboxGroupLabel.vue';
|
||||
export type { ListboxFilterProps, ListboxFilterEmits, ListboxFilterSlots } from './ListboxFilter.vue';
|
||||
@@ -0,0 +1,123 @@
|
||||
export type ListboxValue = string | number | boolean | Record<string, unknown>;
|
||||
|
||||
// `compare`/`includes` are shared with the combobox primitive — see ../utils/compare-values.
|
||||
// Re-exported here so listbox/index.ts can surface them as compareListboxValues/includesListboxValue.
|
||||
export { compare, includes } from '../../internal/utils/compare-values';
|
||||
|
||||
/**
|
||||
* Structural deep equality for option values. Used as a fallback when no `by`
|
||||
* comparator/key is supplied and the values are non-primitive objects, so two
|
||||
* structurally-equal object values are recognised as the same selection
|
||||
* without forcing consumers to pass `by`. Kept local (no external dependency)
|
||||
* and intentionally small — option values are shallow plain objects/arrays.
|
||||
*/
|
||||
export function deepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false;
|
||||
|
||||
const aArray = Array.isArray(a);
|
||||
const bArray = Array.isArray(b);
|
||||
if (aArray !== bArray) return false;
|
||||
|
||||
if (aArray && bArray) {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (!deepEqual(a[i], b[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const aKeys = Object.keys(a as Record<string, unknown>);
|
||||
const bKeys = Object.keys(b as Record<string, unknown>);
|
||||
if (aKeys.length !== bKeys.length) return false;
|
||||
for (const key of aKeys) {
|
||||
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
|
||||
if (!deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link compare} from `../utils/compare-values`, but when `by` is omitted
|
||||
* and the values are objects it falls back to structural deep equality instead
|
||||
* of reference equality. Strings/numbers/booleans keep `===` semantics.
|
||||
*/
|
||||
export function compareDeep<T>(
|
||||
a: T | undefined,
|
||||
b: T | undefined,
|
||||
by?: string | ((x: T, y: T) => boolean),
|
||||
): boolean {
|
||||
if (a === undefined || b === undefined) return false;
|
||||
if (typeof by === 'function') return by(a, b);
|
||||
if (typeof by === 'string') return (a as Record<string, unknown>)?.[by] === (b as Record<string, unknown>)?.[by];
|
||||
if (typeof a === 'object' && a !== null && typeof b === 'object' && b !== null) {
|
||||
return deepEqual(a, b);
|
||||
}
|
||||
return a === b;
|
||||
}
|
||||
|
||||
/** Whether `current` is contained in `value`, using {@link compareDeep}. */
|
||||
export function includesDeep<T>(
|
||||
value: T | T[] | undefined,
|
||||
current: T,
|
||||
by?: string | ((x: T, y: T) => boolean),
|
||||
): boolean {
|
||||
if (value === undefined) return false;
|
||||
if (!Array.isArray(value)) return compareDeep(value, current, by);
|
||||
for (const v of value) {
|
||||
if (compareDeep(v, current, by)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the contiguous slice of `array` between the first occurrences of
|
||||
* `start` and `end` (inclusive), regardless of their order. Empty if either is
|
||||
* missing. Powers Shift+Arrow/Home/End contiguous range selection.
|
||||
*/
|
||||
export function findValuesBetween<T>(
|
||||
array: T[],
|
||||
start: T,
|
||||
end: T,
|
||||
by?: string | ((x: T, y: T) => boolean),
|
||||
): T[] {
|
||||
const startIndex = array.findIndex(i => compareDeep(i, start, by));
|
||||
const endIndex = array.findIndex(i => compareDeep(i, end, by));
|
||||
if (startIndex === -1 || endIndex === -1) return [];
|
||||
const min = Math.min(startIndex, endIndex);
|
||||
const max = Math.max(startIndex, endIndex);
|
||||
return array.slice(min, max + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates `array` so it starts at `startIndex`, wrapping around.
|
||||
* `wrapArray(['a','b','c','d'], 2) === ['c','d','a','b']`.
|
||||
*/
|
||||
export function wrapArray<T>(array: T[], startIndex: number): T[] {
|
||||
const len = array.length;
|
||||
const out: T[] = Array.from({ length: len });
|
||||
for (let i = 0; i < len; i++) out[i] = array[(startIndex + i) % len]!;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core type-ahead matcher. Given the list of item text values, the accumulated
|
||||
* `search` buffer, and the current match, returns the next item text to focus
|
||||
* (or `undefined`). Repeated single characters cycle through matches; longer
|
||||
* buffers match by prefix without excluding the current item.
|
||||
*/
|
||||
export function getNextMatch(
|
||||
values: string[],
|
||||
search: string,
|
||||
currentMatch?: string,
|
||||
): string | undefined {
|
||||
const isRepeated = search.length > 1 && Array.from(search).every(c => c === search[0]);
|
||||
const normalizedSearch = isRepeated ? search[0]! : search;
|
||||
const currentMatchIndex = currentMatch ? values.indexOf(currentMatch) : -1;
|
||||
let wrapped = wrapArray(values, Math.max(currentMatchIndex, 0));
|
||||
const excludeCurrentMatch = normalizedSearch.length === 1;
|
||||
if (excludeCurrentMatch) wrapped = wrapped.filter(v => v !== currentMatch);
|
||||
const lower = normalizedSearch.toLowerCase();
|
||||
const nextMatch = wrapped.find(v => v.toLowerCase().startsWith(lower));
|
||||
return nextMatch !== currentMatch ? nextMatch : undefined;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import type { PopperArrowProps } from '../../overlays/popper';
|
||||
|
||||
/**
|
||||
* An optional arrow that points from the content back to the trigger. Only
|
||||
* meaningful with `position="popper"`, since it relies on the popper placement;
|
||||
* render it inside `SelectContent`.
|
||||
*/
|
||||
export type SelectArrowProps = PopperArrowProps;
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { PopperArrow } from '../../overlays/popper';
|
||||
import { useSelectContentContext } from './context';
|
||||
|
||||
const props = defineProps<SelectArrowProps>();
|
||||
const { forwardRef } = useForwardExpose();
|
||||
|
||||
const contentCtx = useSelectContentContext();
|
||||
// The arrow only makes sense with `position="popper"`; it relies on popper
|
||||
// placement, so it is a no-op in `item-aligned` mode.
|
||||
const shouldRender = computed(() => contentCtx.position === 'popper');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopperArrow
|
||||
v-if="shouldRender"
|
||||
:ref="forwardRef"
|
||||
v-bind="props"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import type { AcceptableValue } from './utils';
|
||||
|
||||
/**
|
||||
* A real, visually-hidden native `<select>` mirrored from the custom control so
|
||||
* the value participates in native form submission, autofill, and `change`
|
||||
* bubbling. Renders an `<option>` per registered item, supports `multiple`, and
|
||||
* writes through the native value setter so frameworks that observe form
|
||||
* controls (and the browser's autofill) see the change exactly as for a real
|
||||
* `<select>`. Internal — rendered by `SelectRoot` when a `name` is set inside a
|
||||
* form.
|
||||
*/
|
||||
export interface SelectBubbleSelectProps {
|
||||
autocomplete?: string;
|
||||
disabled?: boolean;
|
||||
multiple?: boolean;
|
||||
name?: string;
|
||||
required?: boolean;
|
||||
/** Registered option values, rendered as native `<option>`s. */
|
||||
options: AcceptableValue[];
|
||||
/** Current model value(s). */
|
||||
value?: AcceptableValue | AcceptableValue[];
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue';
|
||||
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { VisuallyHidden } from '../../utilities/visually-hidden';
|
||||
|
||||
const props = defineProps<SelectBubbleSelectProps>();
|
||||
const emit = defineEmits<{ change: [value: string] }>();
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const { forwardRef, currentElement } = useForwardExpose();
|
||||
|
||||
// Serialise an option value to the string a native <option> can carry.
|
||||
function serialize(value: AcceptableValue | undefined): string {
|
||||
if (value === undefined || value === null) return '';
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
// Bubble a native `change` event to the surrounding form when the value
|
||||
// changes programmatically, using the native value setter so synthetic-event
|
||||
// systems observe it.
|
||||
watch(() => props.value, (cur, prev) => {
|
||||
if (cur === prev) return;
|
||||
if (globalThis.window === undefined) return;
|
||||
const el = currentElement.value as HTMLSelectElement | undefined;
|
||||
if (!el) return;
|
||||
|
||||
const descriptor = Object.getOwnPropertyDescriptor(globalThis.HTMLSelectElement.prototype, 'value');
|
||||
const setValue = descriptor?.set;
|
||||
if (!setValue) return;
|
||||
|
||||
const next = serialize(Array.isArray(cur) ? cur[0] : cur);
|
||||
setValue.call(el, next);
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
|
||||
// Autofill triggers an `input` event on the native <select>; mirror it back.
|
||||
function handleInput(event: Event) {
|
||||
emit('change', (event.target as HTMLSelectElement).value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisuallyHidden as="template" feature="hidden">
|
||||
<select
|
||||
:ref="forwardRef"
|
||||
:name="name"
|
||||
:required="required || undefined"
|
||||
:disabled="disabled || undefined"
|
||||
:multiple="multiple || undefined"
|
||||
:autocomplete="autocomplete"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
v-bind="$attrs"
|
||||
@input="handleInput"
|
||||
>
|
||||
<option v-for="opt in options" :key="serialize(opt)" :value="serialize(opt)">{{ serialize(opt) }}</option>
|
||||
</select>
|
||||
</VisuallyHidden>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import type { SelectContentImplEmits, SelectContentImplProps } from './SelectContentImpl.vue';
|
||||
|
||||
/**
|
||||
* The floating panel that holds the options. While open it mounts
|
||||
* `SelectContentImpl` behind `Presence` (so it can animate in and out); while
|
||||
* closed it still renders the options into a detached `DocumentFragment` so each
|
||||
* `SelectItem` registers its value/label and `SelectValue` shows the
|
||||
* initially-selected label before the dropdown is ever opened. Usually placed
|
||||
* inside a `SelectPortal` and contains a `SelectViewport` of `SelectItem`s.
|
||||
*/
|
||||
export interface SelectContentProps extends SelectContentImplProps {
|
||||
/**
|
||||
* Force mounting (keeps the panel in the DOM) for externally-controlled
|
||||
* animation libraries.
|
||||
*/
|
||||
forceMount?: boolean;
|
||||
}
|
||||
|
||||
export type SelectContentEmits = SelectContentImplEmits;
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, shallowRef, watch } from 'vue';
|
||||
|
||||
import { Presence } from '../../utilities/presence';
|
||||
import { useSelectRootContext } from './context';
|
||||
import SelectContentImpl from './SelectContentImpl.vue';
|
||||
import SelectProvider from './SelectProvider.vue';
|
||||
|
||||
const props = defineProps<SelectContentProps>();
|
||||
const emit = defineEmits<SelectContentEmits>();
|
||||
const rootCtx = useSelectRootContext();
|
||||
|
||||
const present = computed(() => props.forceMount || rootCtx.open.value);
|
||||
|
||||
// Delay toggling render-presence so children re-render with the latest state
|
||||
// before the exit transition runs (state-based `data-state=closed` animations).
|
||||
const renderPresence = ref(present.value);
|
||||
function syncRenderPresence() {
|
||||
renderPresence.value = present.value;
|
||||
}
|
||||
watch(present, () => {
|
||||
setTimeout(syncRenderPresence);
|
||||
});
|
||||
|
||||
// Detached fragment that keeps options mounted (and registered) while closed.
|
||||
const fragment = shallowRef<DocumentFragment | undefined>(undefined);
|
||||
onMounted(() => {
|
||||
fragment.value = typeof DocumentFragment !== 'undefined' ? new DocumentFragment() : undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Presence
|
||||
v-if="present || renderPresence"
|
||||
:present="present"
|
||||
>
|
||||
<SelectContentImpl
|
||||
v-bind="props"
|
||||
@close-auto-focus="emit('closeAutoFocus', $event)"
|
||||
@escape-key-down="emit('escapeKeyDown', $event)"
|
||||
@pointer-down-outside="emit('pointerDownOutside', $event)"
|
||||
>
|
||||
<slot />
|
||||
</SelectContentImpl>
|
||||
</Presence>
|
||||
|
||||
<Teleport v-else-if="fragment" :to="fragment">
|
||||
<SelectProvider :context="rootCtx">
|
||||
<slot />
|
||||
</SelectProvider>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,241 @@
|
||||
<script lang="ts">
|
||||
import type { DismissableLayerEmits } from '../../utilities/dismissable-layer';
|
||||
import type { FocusScopeEmits } from '../../utilities/focus-scope';
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* The mounted body of the content panel: it traps focus, dismisses on outside
|
||||
* pointer/escape, locks body scroll, hides sibling content from assistive tech,
|
||||
* installs focus guards, focuses the selected option on open, and handles
|
||||
* keyboard navigation and cycling type-ahead. Rendered by `SelectContent` once
|
||||
* open — prefer using `SelectContent` rather than this part directly.
|
||||
*/
|
||||
export interface SelectContentImplProps extends PrimitiveProps {
|
||||
/** Position mode. @default 'item-aligned' */
|
||||
position?: 'item-aligned' | 'popper';
|
||||
/** Block outside pointer events. @default true */
|
||||
disableOutsidePointerEvents?: boolean;
|
||||
/** Lock body scroll while open. @default true */
|
||||
bodyLock?: boolean;
|
||||
}
|
||||
|
||||
export interface SelectContentImplEmits {
|
||||
closeAutoFocus: FocusScopeEmits['unmountAutoFocus'];
|
||||
escapeKeyDown: DismissableLayerEmits['escapeKeyDown'];
|
||||
pointerDownOutside: DismissableLayerEmits['pointerDownOutside'];
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, shallowRef, watch } from 'vue';
|
||||
|
||||
import { refAutoReset, useBodyScrollLock, useFocusGuard, useForwardExpose } from '@robonen/vue';
|
||||
import { getActiveElement } from '@robonen/platform/browsers';
|
||||
import { DismissableLayer } from '../../utilities/dismissable-layer';
|
||||
import { FocusScope } from '../../utilities/focus-scope';
|
||||
import { useHideOthers } from '../../internal/utils/useHideOthers';
|
||||
import { provideSelectContentContext, useSelectRootContext } from './context';
|
||||
import SelectItemAlignedPosition from './SelectItemAlignedPosition.vue';
|
||||
import SelectPopperPosition from './SelectPopperPosition.vue';
|
||||
import { getNextMatch } from './utils';
|
||||
|
||||
const {
|
||||
as = 'div',
|
||||
position = 'item-aligned',
|
||||
disableOutsidePointerEvents = true,
|
||||
bodyLock = true,
|
||||
} = defineProps<SelectContentImplProps>();
|
||||
|
||||
const emit = defineEmits<SelectContentImplEmits>();
|
||||
|
||||
const { forwardRef } = useForwardExpose();
|
||||
const rootCtx = useSelectRootContext();
|
||||
|
||||
if (bodyLock) useBodyScrollLock();
|
||||
useFocusGuard();
|
||||
|
||||
const isPositioned = ref(false);
|
||||
const search = refAutoReset('', 1000);
|
||||
const viewportRef = shallowRef<HTMLElement | undefined>(undefined);
|
||||
const contentRef = shallowRef<HTMLElement | undefined>(undefined);
|
||||
const selectedItemRef = rootCtx.selectedItemRef;
|
||||
const selectedItemTextRef = rootCtx.selectedItemTextRef;
|
||||
|
||||
const firstValidItemFoundRef = ref(false);
|
||||
|
||||
// Recompute the selected/first-valid item afresh for this open cycle.
|
||||
selectedItemRef.value = undefined;
|
||||
|
||||
// Resolve the actual listbox content element. The item-aligned strategy renders
|
||||
// a positioning wrapper whose first child is the listbox; the popper strategy
|
||||
// renders a wrapper marked `data-primitives-popper-content-wrapper`.
|
||||
function setContentRef(vnode: unknown) {
|
||||
forwardRef(vnode as never);
|
||||
const el = (vnode as { $el?: HTMLElement } | null)?.$el ?? (vnode as HTMLElement | null);
|
||||
if (!el) {
|
||||
contentRef.value = undefined;
|
||||
return;
|
||||
}
|
||||
if (el.hasAttribute?.('data-primitives-select-content-wrapper')
|
||||
|| el.hasAttribute?.('data-popper-content-wrapper')) {
|
||||
contentRef.value = (el.firstElementChild as HTMLElement | null) ?? el;
|
||||
}
|
||||
else {
|
||||
contentRef.value = el;
|
||||
}
|
||||
}
|
||||
|
||||
useHideOthers(contentRef);
|
||||
|
||||
function focusFirst(candidates: Array<HTMLElement | undefined>) {
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue;
|
||||
const prev = getActiveElement();
|
||||
candidate.focus({ preventScroll: true });
|
||||
if (getActiveElement() !== prev) return;
|
||||
}
|
||||
}
|
||||
|
||||
function focusSelectedItem() {
|
||||
focusFirst([selectedItemRef.value, contentRef.value]);
|
||||
}
|
||||
|
||||
// Focus the selected option (or content) once positioning completes.
|
||||
watch(isPositioned, (positioned) => {
|
||||
if (positioned) focusSelectedItem();
|
||||
});
|
||||
|
||||
function getItems(): HTMLElement[] {
|
||||
const viewport = viewportRef.value ?? contentRef.value;
|
||||
if (!viewport) return [];
|
||||
return Array.from(
|
||||
viewport.querySelectorAll<HTMLElement>('[data-primitives-select-item]:not([data-disabled])'),
|
||||
);
|
||||
}
|
||||
|
||||
function textOf(el: HTMLElement): string {
|
||||
return el.dataset['textValue'] ?? el.textContent?.trim() ?? '';
|
||||
}
|
||||
|
||||
function handleTypeahead(key: string) {
|
||||
search.value += key;
|
||||
const items = getItems();
|
||||
if (items.length === 0) return;
|
||||
const values = items.map(textOf);
|
||||
const active = getActiveElement() as HTMLElement | null;
|
||||
const currentMatch = active && items.includes(active) ? textOf(active) : undefined;
|
||||
const next = getNextMatch(values, search.value, currentMatch);
|
||||
if (next === undefined) return;
|
||||
const matched = items[values.indexOf(next)];
|
||||
matched?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const isModifierKey = event.ctrlKey || event.altKey || event.metaKey;
|
||||
|
||||
// The listbox should not be Tab-navigable.
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isModifierKey && event.key.length === 1) {
|
||||
handleTypeahead(event.key);
|
||||
}
|
||||
|
||||
const items = getItems();
|
||||
if (items.length === 0) return;
|
||||
let candidates = [...items];
|
||||
|
||||
if (['ArrowUp', 'End'].includes(event.key)) {
|
||||
candidates = candidates.slice().reverse();
|
||||
}
|
||||
|
||||
if (['ArrowUp', 'ArrowDown'].includes(event.key)) {
|
||||
const current = getActiveElement() as HTMLElement;
|
||||
const currentIndex = candidates.indexOf(current);
|
||||
candidates = candidates.slice(currentIndex + 1);
|
||||
}
|
||||
|
||||
if (['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
setTimeout(() => focusFirst(candidates));
|
||||
}
|
||||
}
|
||||
|
||||
function itemRefCallback(el: HTMLElement | undefined, value: unknown, disabled: boolean) {
|
||||
const isFirstValidItem = !firstValidItemFoundRef.value && !disabled;
|
||||
rootCtx.itemRefCallback(el, value as never, disabled);
|
||||
if (isFirstValidItem && !selectedItemRef.value) {
|
||||
selectedItemRef.value = el;
|
||||
firstValidItemFoundRef.value = true;
|
||||
}
|
||||
else if (isFirstValidItem) {
|
||||
firstValidItemFoundRef.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
provideSelectContentContext({
|
||||
viewportRef,
|
||||
onViewportChange: (el) => { viewportRef.value = el; },
|
||||
contentRef,
|
||||
selectedItemRef,
|
||||
selectedItemTextRef,
|
||||
onItemLeave: () => { contentRef.value?.focus(); },
|
||||
focusSelectedItem,
|
||||
itemRefCallback,
|
||||
itemTextRefCallback: rootCtx.itemTextRefCallback,
|
||||
isPositioned,
|
||||
searchRef: search,
|
||||
position,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FocusScope
|
||||
as="template"
|
||||
:loop="true"
|
||||
:trapped="true"
|
||||
@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)"
|
||||
@dismiss="rootCtx.onOpenChange(false)"
|
||||
>
|
||||
<SelectItemAlignedPosition
|
||||
v-if="position === 'item-aligned'"
|
||||
:ref="setContentRef"
|
||||
:as="as"
|
||||
:id="rootCtx.contentId.value"
|
||||
role="listbox"
|
||||
:data-state="rootCtx.open.value ? 'open' : 'closed'"
|
||||
:dir="rootCtx.dir.value"
|
||||
style="display: flex; flex-direction: column; outline: none"
|
||||
@contextmenu.prevent
|
||||
@placed="isPositioned = true"
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<slot />
|
||||
</SelectItemAlignedPosition>
|
||||
|
||||
<SelectPopperPosition
|
||||
v-else
|
||||
:ref="setContentRef"
|
||||
:as="as"
|
||||
:id="rootCtx.contentId.value"
|
||||
role="listbox"
|
||||
:data-state="rootCtx.open.value ? 'open' : 'closed'"
|
||||
:dir="rootCtx.dir.value"
|
||||
style="display: flex; flex-direction: column; outline: none"
|
||||
@contextmenu.prevent
|
||||
@placed="isPositioned = true"
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<slot />
|
||||
</SelectPopperPosition>
|
||||
</DismissableLayer>
|
||||
</FocusScope>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* Groups a set of related items under a shared label. Renders as a
|
||||
* `role="group"` and provides an id so a child `SelectLabel` can label the
|
||||
* group for assistive technology.
|
||||
*/
|
||||
export interface SelectGroupProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
|
||||
import { useId } from '../../utilities/config-provider';
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { provideSelectGroupContext } from './context';
|
||||
|
||||
const { as = 'div' } = defineProps<SelectGroupProps>();
|
||||
const { forwardRef } = useForwardExpose();
|
||||
|
||||
const groupId = useId(undefined, 'select-group');
|
||||
|
||||
provideSelectGroupContext({ id: groupId });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
role="group"
|
||||
:aria-labelledby="groupId"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* The decorative icon shown in the trigger (a chevron by default). Marked
|
||||
* `aria-hidden`; override the default glyph by passing slot content.
|
||||
*/
|
||||
export interface SelectIconProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
|
||||
const { as = 'span' } = defineProps<SelectIconProps>();
|
||||
const { forwardRef } = useForwardExpose();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<slot>▼</slot>
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
import type { AcceptableValue } from './utils';
|
||||
|
||||
/**
|
||||
* A single selectable option. Renders as a `role="option"`, registers its value
|
||||
* and text with the root, becomes (or toggles, when `multiple`) the selected
|
||||
* value on click/Enter/Space, and exposes `data-state`/`data-disabled`/
|
||||
* `data-highlighted` for styling. Holds a `SelectItemText` and, optionally, a
|
||||
* `SelectItemIndicator`. The value may be any {@link AcceptableValue}.
|
||||
*/
|
||||
export interface SelectItemProps<T extends AcceptableValue = AcceptableValue> extends PrimitiveProps {
|
||||
/** The option value. Must not be an empty string. */
|
||||
value: T;
|
||||
/** Disable this item. */
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Optional text used for typeahead. Defaults to the `SelectItemText` content;
|
||||
* set it when the item content is complex or non-textual.
|
||||
*/
|
||||
textValue?: string;
|
||||
}
|
||||
|
||||
export type SelectItemSelectEvent<T = AcceptableValue> = CustomEvent<{
|
||||
originalEvent: PointerEvent | KeyboardEvent;
|
||||
value: T;
|
||||
}>;
|
||||
|
||||
export interface SelectItemEmits<T extends AcceptableValue = AcceptableValue> {
|
||||
/** Called when the item is selected. Call `event.preventDefault()` to block. */
|
||||
select: [event: SelectItemSelectEvent<T>];
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef } from 'vue';
|
||||
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { getActiveElement } from '@robonen/platform/browsers';
|
||||
import { useId } from '../../utilities/config-provider';
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { provideSelectItemContext, useSelectContentContext, useSelectRootContext } from './context';
|
||||
import { SELECTION_KEYS, valueComparator } from './utils';
|
||||
|
||||
const { as = 'div', value, disabled = false, textValue } = defineProps<SelectItemProps<T>>();
|
||||
const emit = defineEmits<SelectItemEmits<T>>();
|
||||
|
||||
const { forwardRef, currentElement } = useForwardExpose();
|
||||
const rootCtx = useSelectRootContext();
|
||||
const contentCtx = useSelectContentContext();
|
||||
|
||||
if (value === ('' as T)) {
|
||||
throw new Error(
|
||||
'A <SelectItem /> must have a value prop that is not an empty string. This is because the Select value can be set to an empty string to clear the selection and show the placeholder.',
|
||||
);
|
||||
}
|
||||
|
||||
const textId = useId(undefined, 'select-item-text');
|
||||
const isFocused = ref(false);
|
||||
const isSelected = computed(() => valueComparator(rootCtx.value.value, value, rootCtx.by));
|
||||
const isDisabled = computed(() => rootCtx.disabled.value || disabled);
|
||||
|
||||
const itemTextElement = shallowRef<HTMLElement | undefined>(undefined);
|
||||
|
||||
function onItemTextChange(el: HTMLElement | undefined) {
|
||||
itemTextElement.value = el;
|
||||
contentCtx.itemTextRefCallback(el, value);
|
||||
const text = textValue ?? el?.textContent?.trim() ?? '';
|
||||
if (el) rootCtx.onOptionAdd({ value, disabled, textContent: text });
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
contentCtx.itemRefCallback(currentElement.value, value, isDisabled.value);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
rootCtx.onOptionRemove({ value, disabled, textContent: textValue ?? itemTextElement.value?.textContent?.trim() ?? '' });
|
||||
contentCtx.itemRefCallback(undefined, value, isDisabled.value);
|
||||
});
|
||||
|
||||
async function handleSelect(event: PointerEvent | KeyboardEvent) {
|
||||
if (event.defaultPrevented) return;
|
||||
|
||||
const detail = new CustomEvent('select', {
|
||||
bubbles: false,
|
||||
cancelable: true,
|
||||
detail: { originalEvent: event, value: value as T },
|
||||
}) as SelectItemSelectEvent<T>;
|
||||
|
||||
await nextTick();
|
||||
emit('select', detail);
|
||||
if (detail.defaultPrevented) return;
|
||||
|
||||
if (!isDisabled.value) {
|
||||
rootCtx.onValueChange(value);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick(event: MouseEvent) {
|
||||
handleSelect(event as unknown as PointerEvent);
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
(event.currentTarget as HTMLElement | null)?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
async function handlePointerMove(event: PointerEvent) {
|
||||
await nextTick();
|
||||
if (event.defaultPrevented) return;
|
||||
if (isDisabled.value) {
|
||||
contentCtx.onItemLeave();
|
||||
}
|
||||
else {
|
||||
(event.currentTarget as HTMLElement | null)?.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePointerLeave(event: PointerEvent) {
|
||||
await nextTick();
|
||||
if (event.defaultPrevented) return;
|
||||
if (event.currentTarget === getActiveElement()) {
|
||||
contentCtx.onItemLeave();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const isTypingAhead = contentCtx.searchRef.value !== '';
|
||||
if (isTypingAhead && event.key === ' ') return;
|
||||
if (SELECTION_KEYS.includes(event.key)) {
|
||||
handleSelect(event);
|
||||
}
|
||||
// prevent page scroll on space
|
||||
if (event.key === ' ') event.preventDefault();
|
||||
}
|
||||
|
||||
provideSelectItemContext({
|
||||
value,
|
||||
isSelected,
|
||||
isDisabled,
|
||||
isFocused,
|
||||
textId,
|
||||
onItemTextChange,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
role="option"
|
||||
:aria-labelledby="textId"
|
||||
:aria-selected="isSelected"
|
||||
:aria-disabled="isDisabled || undefined"
|
||||
:data-state="isSelected ? 'checked' : 'unchecked'"
|
||||
:data-highlighted="isFocused ? '' : undefined"
|
||||
:data-disabled="isDisabled ? '' : undefined"
|
||||
:tabindex="isDisabled ? undefined : -1"
|
||||
data-primitives-select-item
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
@click="handleClick"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerleave="handlePointerLeave"
|
||||
@touchend.prevent.stop
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,199 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* The `'item-aligned'` positioning strategy for the content panel: positions the
|
||||
* panel like a native MacOS menu by vertically centring the selected option
|
||||
* against the trigger, horizontally aligning the selected item's text with the
|
||||
* trigger value, clamping to the viewport (collision margin), computing
|
||||
* min/max height, supporting `dir="rtl"`, growing on scroll, and repositioning
|
||||
* on trigger resize. Chosen internally by `SelectContentImpl` when `position`
|
||||
* is `'item-aligned'`.
|
||||
*/
|
||||
export interface SelectItemAlignedPositionProps extends PrimitiveProps {
|
||||
/** Reading direction, forwarded from the root. */
|
||||
dir?: string;
|
||||
}
|
||||
|
||||
export interface SelectItemAlignedPositionEmits {
|
||||
placed: [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, ref, shallowRef } from 'vue';
|
||||
|
||||
import { useForwardExpose, useResizeObserver } from '@robonen/vue';
|
||||
import { clamp } from '@robonen/stdlib';
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import {
|
||||
provideSelectItemAlignedPositionContext,
|
||||
useSelectContentContext,
|
||||
useSelectRootContext,
|
||||
} from './context';
|
||||
import { CONTENT_MARGIN } from './utils';
|
||||
|
||||
const { as = 'div' } = defineProps<SelectItemAlignedPositionProps>();
|
||||
const emit = defineEmits<SelectItemAlignedPositionEmits>();
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const { forwardRef, currentElement: contentElement } = useForwardExpose();
|
||||
const rootCtx = useSelectRootContext();
|
||||
const contentCtx = useSelectContentContext();
|
||||
|
||||
const contentWrapper = shallowRef<HTMLElement | undefined>(undefined);
|
||||
const shouldExpandOnScrollRef = ref(false);
|
||||
const shouldRepositionRef = ref(true);
|
||||
const contentZIndex = ref('');
|
||||
|
||||
function position() {
|
||||
const trigger = rootCtx.triggerElement.value;
|
||||
const valueNode = rootCtx.valueElement.value;
|
||||
const wrapper = contentWrapper.value;
|
||||
const content = contentElement.value;
|
||||
const viewport = contentCtx.viewportRef.value;
|
||||
const selectedItem = contentCtx.selectedItemRef.value;
|
||||
const selectedItemText = contentCtx.selectedItemTextRef.value;
|
||||
|
||||
if (!trigger || !valueNode || !wrapper || !content || !viewport || !selectedItem || !selectedItemText) {
|
||||
emit('placed');
|
||||
return;
|
||||
}
|
||||
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
|
||||
// --- Horizontal positioning ---
|
||||
const contentRect = content.getBoundingClientRect();
|
||||
const valueNodeRect = valueNode.getBoundingClientRect();
|
||||
const itemTextRect = selectedItemText.getBoundingClientRect();
|
||||
|
||||
if (rootCtx.dir.value !== 'rtl') {
|
||||
const itemTextOffset = itemTextRect.left - contentRect.left;
|
||||
const left = valueNodeRect.left - itemTextOffset;
|
||||
const leftDelta = triggerRect.left - left;
|
||||
const minContentWidth = triggerRect.width + leftDelta;
|
||||
const contentWidth = Math.max(minContentWidth, contentRect.width);
|
||||
const rightEdge = window.innerWidth - CONTENT_MARGIN;
|
||||
const clampedLeft = clamp(left, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, rightEdge - contentWidth));
|
||||
|
||||
wrapper.style.minWidth = `${minContentWidth}px`;
|
||||
wrapper.style.left = `${clampedLeft}px`;
|
||||
}
|
||||
else {
|
||||
const itemTextOffset = contentRect.right - itemTextRect.right;
|
||||
const right = window.innerWidth - valueNodeRect.right - itemTextOffset;
|
||||
const rightDelta = window.innerWidth - triggerRect.right - right;
|
||||
const minContentWidth = triggerRect.width + rightDelta;
|
||||
const contentWidth = Math.max(minContentWidth, contentRect.width);
|
||||
const leftEdge = window.innerWidth - CONTENT_MARGIN;
|
||||
const clampedRight = clamp(right, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, leftEdge - contentWidth));
|
||||
|
||||
wrapper.style.minWidth = `${minContentWidth}px`;
|
||||
wrapper.style.right = `${clampedRight}px`;
|
||||
}
|
||||
|
||||
// --- Vertical positioning ---
|
||||
const items = Array.from(
|
||||
viewport.querySelectorAll<HTMLElement>('[data-primitives-select-item]'),
|
||||
);
|
||||
const availableHeight = window.innerHeight - CONTENT_MARGIN * 2;
|
||||
const itemsHeight = viewport.scrollHeight;
|
||||
|
||||
const contentStyles = globalThis.getComputedStyle(content);
|
||||
const contentBorderTopWidth = Number.parseInt(contentStyles.borderTopWidth, 10) || 0;
|
||||
const contentPaddingTop = Number.parseInt(contentStyles.paddingTop, 10) || 0;
|
||||
const contentBorderBottomWidth = Number.parseInt(contentStyles.borderBottomWidth, 10) || 0;
|
||||
const contentPaddingBottom = Number.parseInt(contentStyles.paddingBottom, 10) || 0;
|
||||
const fullContentHeight = contentBorderTopWidth + contentPaddingTop + itemsHeight + contentPaddingBottom + contentBorderBottomWidth;
|
||||
const minContentHeight = Math.min(selectedItem.offsetHeight * 5, fullContentHeight);
|
||||
|
||||
const viewportStyles = globalThis.getComputedStyle(viewport);
|
||||
const viewportPaddingTop = Number.parseInt(viewportStyles.paddingTop, 10) || 0;
|
||||
const viewportPaddingBottom = Number.parseInt(viewportStyles.paddingBottom, 10) || 0;
|
||||
|
||||
const topEdgeToTriggerMiddle = triggerRect.top + triggerRect.height / 2 - CONTENT_MARGIN;
|
||||
const triggerMiddleToBottomEdge = availableHeight - topEdgeToTriggerMiddle;
|
||||
|
||||
const selectedItemHalfHeight = selectedItem.offsetHeight / 2;
|
||||
const itemOffsetMiddle = selectedItem.offsetTop + selectedItemHalfHeight;
|
||||
const contentTopToItemMiddle = contentBorderTopWidth + contentPaddingTop + itemOffsetMiddle;
|
||||
const itemMiddleToContentBottom = fullContentHeight - contentTopToItemMiddle;
|
||||
|
||||
const willAlignWithoutTopOverflow = contentTopToItemMiddle <= topEdgeToTriggerMiddle;
|
||||
|
||||
if (willAlignWithoutTopOverflow) {
|
||||
const isLastItem = selectedItem === items.at(-1);
|
||||
wrapper.style.bottom = '0px';
|
||||
const viewportOffsetBottom = content.clientHeight - viewport.offsetTop - viewport.offsetHeight;
|
||||
const clampedTriggerMiddleToBottomEdge = Math.max(
|
||||
triggerMiddleToBottomEdge,
|
||||
selectedItemHalfHeight + (isLastItem ? viewportPaddingBottom : 0) + viewportOffsetBottom + contentBorderBottomWidth,
|
||||
);
|
||||
const height = contentTopToItemMiddle + clampedTriggerMiddleToBottomEdge;
|
||||
wrapper.style.height = `${height}px`;
|
||||
}
|
||||
else {
|
||||
const isFirstItem = selectedItem === items[0];
|
||||
wrapper.style.top = '0px';
|
||||
const clampedTopEdgeToTriggerMiddle = Math.max(
|
||||
topEdgeToTriggerMiddle,
|
||||
contentBorderTopWidth + viewport.offsetTop + (isFirstItem ? viewportPaddingTop : 0) + selectedItemHalfHeight,
|
||||
);
|
||||
const height = clampedTopEdgeToTriggerMiddle + itemMiddleToContentBottom;
|
||||
wrapper.style.height = `${height}px`;
|
||||
viewport.scrollTop = contentTopToItemMiddle - topEdgeToTriggerMiddle + viewport.offsetTop;
|
||||
}
|
||||
|
||||
wrapper.style.margin = `${CONTENT_MARGIN}px 0`;
|
||||
wrapper.style.minHeight = `${minContentHeight}px`;
|
||||
wrapper.style.maxHeight = `${availableHeight}px`;
|
||||
|
||||
emit('placed');
|
||||
requestAnimationFrame(() => (shouldExpandOnScrollRef.value = true));
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
position();
|
||||
if (contentElement.value) {
|
||||
contentZIndex.value = globalThis.getComputedStyle(contentElement.value).zIndex;
|
||||
}
|
||||
});
|
||||
|
||||
// When the scroll-up button mounts (because the viewport became scrollable at
|
||||
// the top) it pushes the viewport down, throwing the alignment off; re-run once.
|
||||
function handleScrollButtonChange(node: HTMLElement | undefined) {
|
||||
if (node && shouldRepositionRef.value) {
|
||||
position();
|
||||
contentCtx.focusSelectedItem();
|
||||
shouldRepositionRef.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
useResizeObserver(rootCtx.triggerElement, () => position());
|
||||
|
||||
provideSelectItemAlignedPositionContext({
|
||||
contentWrapper,
|
||||
shouldExpandOnScrollRef,
|
||||
onScrollButtonChange: handleScrollButtonChange,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="contentWrapper"
|
||||
data-primitives-select-content-wrapper
|
||||
:style="{ display: 'flex', flexDirection: 'column', position: 'fixed', zIndex: contentZIndex }"
|
||||
>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
data-primitives-select-content
|
||||
:style="{ boxSizing: 'border-box', maxHeight: '100%' }"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* A marker (typically a checkmark) rendered only while its `SelectItem` is the
|
||||
* selected option. Decorative and `aria-hidden`; use inside a `SelectItem`.
|
||||
*/
|
||||
export interface SelectItemIndicatorProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { useSelectItemContext } from './context';
|
||||
|
||||
const { as = 'span' } = defineProps<SelectItemIndicatorProps>();
|
||||
const { forwardRef } = useForwardExpose();
|
||||
const itemCtx = useSelectItemContext();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
v-if="itemCtx.isSelected.value"
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* The visible label of a `SelectItem`. Its text is what the root captures to
|
||||
* show in `SelectValue` once the item is chosen, so each item should contain
|
||||
* exactly one; use inside a `SelectItem`.
|
||||
*/
|
||||
export interface SelectItemTextProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watchPostEffect } from 'vue';
|
||||
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { useSelectItemContext } from './context';
|
||||
|
||||
const { as = 'span' } = defineProps<SelectItemTextProps>();
|
||||
|
||||
const { forwardRef, currentElement } = useForwardExpose();
|
||||
const itemCtx = useSelectItemContext();
|
||||
|
||||
watchPostEffect(() => {
|
||||
itemCtx.onItemTextChange(currentElement.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
:id="itemCtx.textId.value"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* A non-selectable heading for a `SelectGroup`. Renders the text that labels the
|
||||
* group and wires its id to the group's `aria-labelledby`; must be used inside a
|
||||
* `SelectGroup`.
|
||||
*/
|
||||
export interface SelectLabelProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { useSelectGroupContext } from './context';
|
||||
|
||||
const { as = 'div' } = defineProps<SelectLabelProps>();
|
||||
const { forwardRef } = useForwardExpose();
|
||||
const groupCtx = useSelectGroupContext();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
:id="groupCtx.id.value"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import type { PopperContentEmits, PopperContentProps } from '../../overlays/popper';
|
||||
|
||||
/**
|
||||
* The `'popper'` positioning strategy for the content panel: builds on
|
||||
* `PopperContent` for collision-aware floating placement with `side`, `align`,
|
||||
* and offset controls. Chosen internally by `SelectContentImpl` when `position`
|
||||
* is `'popper'`.
|
||||
*/
|
||||
export type SelectPopperPositionProps = PopperContentProps;
|
||||
export type SelectPopperPositionEmits = PopperContentEmits;
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
|
||||
import { PopperContent } from '../../overlays/popper';
|
||||
|
||||
const props = defineProps<SelectPopperPositionProps>();
|
||||
const emit = defineEmits<SelectPopperPositionEmits>();
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const { forwardRef } = useForwardExpose();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopperContent
|
||||
:ref="forwardRef"
|
||||
v-bind="{ ...props, ...$attrs }"
|
||||
:side="props.side ?? 'bottom'"
|
||||
:side-offset="props.sideOffset ?? 4"
|
||||
:align="props.align ?? 'start'"
|
||||
:style="{
|
||||
'--primitives-select-content-available-width': 'var(--popper-available-width)',
|
||||
'--primitives-select-content-available-height': 'var(--popper-available-height)',
|
||||
'--primitives-select-content-transform-origin': 'var(--popper-transform-origin)',
|
||||
}"
|
||||
@placed="emit('placed')"
|
||||
>
|
||||
<slot />
|
||||
</PopperContent>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { PortalProps } from '../../utilities/teleport';
|
||||
|
||||
/**
|
||||
* Teleports the `SelectContent` into a different part of the DOM (the document
|
||||
* body by default) so it escapes overflow and stacking-context clipping.
|
||||
*/
|
||||
export interface SelectPortalProps extends PortalProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Portal } from '../../utilities/teleport';
|
||||
|
||||
const { to, defer, disabled } = defineProps<SelectPortalProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Portal :to="to" :defer="defer" :disabled="disabled">
|
||||
<slot />
|
||||
</Portal>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import type { SelectContentContext, SelectRootContext } from './context';
|
||||
|
||||
/**
|
||||
* Re-provides the root context and a no-op content context so that
|
||||
* `SelectItem`/`SelectItemText` can mount inside a detached `DocumentFragment`
|
||||
* while the listbox is closed. This lets every option register its value and
|
||||
* label up-front, so `SelectValue` shows the initially-selected label before
|
||||
* the dropdown is ever opened. Internal — rendered by `SelectContent`.
|
||||
*/
|
||||
export interface SelectProviderProps {
|
||||
context: SelectRootContext;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { shallowRef } from 'vue';
|
||||
|
||||
import { provideSelectContentContext, provideSelectRootContext } from './context';
|
||||
|
||||
const { context } = defineProps<SelectProviderProps>();
|
||||
|
||||
provideSelectRootContext(context);
|
||||
|
||||
const noopEl = shallowRef<HTMLElement | undefined>(undefined);
|
||||
const defaultContentContext: SelectContentContext = {
|
||||
viewportRef: noopEl,
|
||||
onViewportChange: () => {},
|
||||
contentRef: noopEl,
|
||||
selectedItemRef: context.selectedItemRef,
|
||||
selectedItemTextRef: context.selectedItemTextRef,
|
||||
onItemLeave: () => {},
|
||||
focusSelectedItem: () => {},
|
||||
itemRefCallback: context.itemRefCallback,
|
||||
itemTextRefCallback: context.itemTextRefCallback,
|
||||
isPositioned: shallowRef(false),
|
||||
searchRef: shallowRef(''),
|
||||
position: 'item-aligned',
|
||||
};
|
||||
|
||||
provideSelectContentContext(defaultContentContext);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot />
|
||||
</template>
|
||||
@@ -0,0 +1,257 @@
|
||||
<script lang="ts">
|
||||
import type { Direction } from '../../utilities/config-provider';
|
||||
import type { AcceptableValue } from './utils';
|
||||
|
||||
/**
|
||||
* A custom, fully stylable replacement for the native `<select>` element: a
|
||||
* trigger button that opens a floating listbox of options, with full keyboard
|
||||
* support (arrow keys, Home/End, type-ahead search), focus trapping, and an
|
||||
* optional hidden native `<select>` for native form submission.
|
||||
*
|
||||
* Use it when you need a single- or multi-choice dropdown whose menu and options
|
||||
* must be styled beyond what a native control allows. The root owns the selected
|
||||
* value and open state and provides context to every part; bind `v-model` for
|
||||
* the value and `v-model:open` (or listen to `update:modelValue` / `update:open`)
|
||||
* to control or observe it. Values may be strings, numbers, booleans, or objects
|
||||
* (compared via `by`). Compose it from a `SelectTrigger` (with
|
||||
* `SelectValue`/`SelectIcon`) plus a portalled `SelectContent` of `SelectItem`s.
|
||||
*/
|
||||
export interface SelectRootProps<T extends AcceptableValue = AcceptableValue> {
|
||||
/** Reading direction. Falls back to ConfigProvider. */
|
||||
dir?: Direction;
|
||||
/** Disable the whole select. */
|
||||
disabled?: boolean;
|
||||
/** Mark field as required for native form validation. */
|
||||
required?: boolean;
|
||||
/** Native input name for form submission. */
|
||||
name?: string;
|
||||
/** Uncontrolled default value. */
|
||||
defaultValue?: T | T[];
|
||||
/** Uncontrolled default open state. */
|
||||
defaultOpen?: boolean;
|
||||
/** Allow selecting multiple options; the model becomes an array. */
|
||||
multiple?: boolean;
|
||||
/**
|
||||
* Compare object values by a property key or a custom comparator. Omitted →
|
||||
* `===` for primitives / structural deep-equality for objects.
|
||||
*/
|
||||
by?: string | ((a: T, b: T) => boolean);
|
||||
/** Native autocomplete attribute forwarded to the hidden native select. */
|
||||
autocomplete?: string;
|
||||
}
|
||||
|
||||
export interface SelectRootEmits<T extends AcceptableValue = AcceptableValue> {
|
||||
'update:modelValue': [value: T | T[] | undefined];
|
||||
'update:open': [open: boolean];
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue">
|
||||
import type { Ref } from 'vue';
|
||||
import { computed, ref, shallowRef, toRef, watch } from 'vue';
|
||||
|
||||
import { useId } from '../../utilities/config-provider';
|
||||
import { PopperRoot } from '../../overlays/popper';
|
||||
import { provideSelectRootContext } from './context';
|
||||
import type { SelectOption } from './context';
|
||||
import SelectBubbleSelect from './SelectBubbleSelect.vue';
|
||||
import { compare, shouldShowPlaceholder } from './utils';
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const {
|
||||
dir,
|
||||
disabled = false,
|
||||
required = false,
|
||||
name,
|
||||
defaultValue,
|
||||
defaultOpen = false,
|
||||
multiple = false,
|
||||
by,
|
||||
autocomplete,
|
||||
} = defineProps<SelectRootProps<T>>();
|
||||
|
||||
defineSlots<{
|
||||
default?: (props: {
|
||||
modelValue: T | T[] | undefined;
|
||||
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;
|
||||
},
|
||||
});
|
||||
|
||||
const localValue = ref<T | T[] | undefined>(defaultValue ?? (multiple ? ([] as T[]) : undefined)) as Ref<T | T[] | undefined>;
|
||||
const value = defineModel<T | T[] | undefined>('modelValue', {
|
||||
default: undefined,
|
||||
get: v => (v ?? localValue.value),
|
||||
set: (v) => {
|
||||
localValue.value = v;
|
||||
return v;
|
||||
},
|
||||
});
|
||||
|
||||
const contentId = useId(undefined, 'select-content');
|
||||
const dirRef = toRef(() => dir);
|
||||
const disabledRef = toRef(() => disabled);
|
||||
const requiredRef = toRef(() => required);
|
||||
const multipleRef = toRef(() => multiple);
|
||||
const nameRef = toRef(() => name);
|
||||
|
||||
const triggerElement = shallowRef<HTMLElement | undefined>(undefined);
|
||||
const valueElement = shallowRef<HTMLElement | undefined>(undefined);
|
||||
const triggerPointerDownPosRef = ref<{ x: number; y: number } | null>(null);
|
||||
const selectedItemRef = shallowRef<HTMLElement | undefined>(undefined);
|
||||
const selectedItemTextRef = shallowRef<HTMLElement | undefined>(undefined);
|
||||
const displayValue = ref<string | undefined>(undefined);
|
||||
|
||||
// Raw (non-reactive) source of truth for option membership; `optionsSet` is the
|
||||
// published snapshot consumers read. Mutating the raw set then re-publishing a
|
||||
// fresh snapshot avoids tracking the ref inside the registration effect (which
|
||||
// would otherwise recurse: read optionsSet -> write optionsSet -> re-run).
|
||||
const rawOptions = new Set<SelectOption>();
|
||||
const optionsSet = shallowRef(new Set<SelectOption>());
|
||||
|
||||
const isEmptyModelValue = computed(() => shouldShowPlaceholder(value.value));
|
||||
|
||||
function getOptionFrom(source: Iterable<SelectOption>, v: AcceptableValue): SelectOption | undefined {
|
||||
for (const option of source) {
|
||||
if (compare(option.value as T, v as T, by as never)) return option;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function onOptionAdd(option: SelectOption) {
|
||||
const existing = getOptionFrom(rawOptions, option.value);
|
||||
if (existing) rawOptions.delete(existing);
|
||||
rawOptions.add(option);
|
||||
optionsSet.value = new Set(rawOptions);
|
||||
}
|
||||
|
||||
function onOptionRemove(option: SelectOption) {
|
||||
const existing = getOptionFrom(rawOptions, option.value);
|
||||
if (!existing) return;
|
||||
rawOptions.delete(existing);
|
||||
optionsSet.value = new Set(rawOptions);
|
||||
}
|
||||
|
||||
// Persist a single-value label for the legacy `displayValue` slot path.
|
||||
watch([optionsSet, value], () => {
|
||||
const current = value.value;
|
||||
if (current === undefined || Array.isArray(current)) return;
|
||||
const text = getOptionFrom(optionsSet.value, current)?.textContent;
|
||||
if (text !== undefined) displayValue.value = text;
|
||||
}, { immediate: true });
|
||||
|
||||
function handleValueChange(newValue: AcceptableValue) {
|
||||
if (multiple) {
|
||||
const array = Array.isArray(value.value) ? [...value.value] : [];
|
||||
const index = array.findIndex(v => compare(v as T, newValue as T, by as never));
|
||||
if (index === -1) array.push(newValue as T);
|
||||
else array.splice(index, 1);
|
||||
value.value = [...array] as T[];
|
||||
}
|
||||
else {
|
||||
value.value = newValue as T;
|
||||
displayValue.value = getOptionFrom(rawOptions, newValue)?.textContent;
|
||||
open.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function isSelectedValue(itemValue: AcceptableValue): boolean {
|
||||
const current = value.value;
|
||||
if (current === undefined) return false;
|
||||
if (Array.isArray(current)) {
|
||||
for (const v of current) {
|
||||
if (compare(itemValue as T, v as T, by as never)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return compare(itemValue as T, current as T, by as never);
|
||||
}
|
||||
|
||||
function itemRefCallback(el: HTMLElement | undefined, itemValue: AcceptableValue, isDisabled: boolean) {
|
||||
if (!isDisabled && isSelectedValue(itemValue)) {
|
||||
selectedItemRef.value = el;
|
||||
}
|
||||
}
|
||||
|
||||
function itemTextRefCallback(el: HTMLElement | undefined, itemValue: AcceptableValue) {
|
||||
if (isSelectedValue(itemValue)) {
|
||||
selectedItemTextRef.value = el;
|
||||
}
|
||||
}
|
||||
|
||||
const nativeOptions = computed(() => Array.from(optionsSet.value, o => o.value));
|
||||
|
||||
const isFormControl = computed(() => {
|
||||
const el = triggerElement.value;
|
||||
return !!el && !!el.closest('form');
|
||||
});
|
||||
|
||||
provideSelectRootContext({
|
||||
value,
|
||||
onValueChange: handleValueChange,
|
||||
open,
|
||||
onOpenChange: (v) => { open.value = v; },
|
||||
dir: dirRef,
|
||||
disabled: disabledRef,
|
||||
required: requiredRef,
|
||||
multiple: multipleRef,
|
||||
by: by as never,
|
||||
name: nameRef,
|
||||
triggerElement,
|
||||
onTriggerChange: (el) => { triggerElement.value = el; },
|
||||
valueElement,
|
||||
onValueElementChange: (el) => { valueElement.value = el; },
|
||||
triggerPointerDownPosRef,
|
||||
contentId,
|
||||
isEmptyModelValue,
|
||||
displayValue,
|
||||
optionsSet,
|
||||
onOptionAdd,
|
||||
onOptionRemove,
|
||||
itemRefCallback,
|
||||
itemTextRefCallback,
|
||||
selectedItemRef,
|
||||
selectedItemTextRef,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopperRoot>
|
||||
<slot :model-value="value" :open="open" />
|
||||
|
||||
<SelectBubbleSelect
|
||||
v-if="isFormControl && name"
|
||||
:name="name"
|
||||
:autocomplete="autocomplete"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:multiple="multiple"
|
||||
:options="nativeOptions"
|
||||
:value="value"
|
||||
@change="handleValueChange"
|
||||
/>
|
||||
|
||||
<input
|
||||
v-else-if="name"
|
||||
type="hidden"
|
||||
:name="name"
|
||||
:value="Array.isArray(value) ? '' : (value ?? '')"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:autocomplete="autocomplete"
|
||||
aria-hidden="true"
|
||||
style="display: none"
|
||||
tabindex="-1"
|
||||
/>
|
||||
</PopperRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* Shared implementation behind the up/down scroll buttons: auto-scrolls the
|
||||
* viewport in `direction` while the pointer hovers it. Internal — use
|
||||
* `SelectScrollUpButton` / `SelectScrollDownButton` instead.
|
||||
*/
|
||||
export interface SelectScrollButtonImplProps extends PrimitiveProps {
|
||||
/** Scroll direction: `-1` scrolls up, `1` scrolls down. */
|
||||
direction: 1 | -1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount } from 'vue';
|
||||
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { useSelectContentContext } from './context';
|
||||
|
||||
const { as = 'div', direction } = defineProps<SelectScrollButtonImplProps>();
|
||||
|
||||
const { forwardRef } = useForwardExpose();
|
||||
const contentCtx = useSelectContentContext();
|
||||
|
||||
let rafId: number | null = null;
|
||||
|
||||
function startAutoScroll() {
|
||||
const viewport = contentCtx.viewportRef.value;
|
||||
if (!viewport) return;
|
||||
|
||||
function scroll() {
|
||||
viewport!.scrollTop += direction * 8;
|
||||
rafId = requestAnimationFrame(scroll);
|
||||
}
|
||||
rafId = requestAnimationFrame(scroll);
|
||||
}
|
||||
|
||||
function stopAutoScroll() {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(stopAutoScroll);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
aria-hidden="true"
|
||||
style="cursor: default; flex-shrink: 0; display: flex; align-items: center; justify-content: center"
|
||||
@pointerenter="startAutoScroll"
|
||||
@pointerleave="stopAutoScroll"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import type { SelectScrollButtonImplProps } from './SelectScrollButtonImpl.vue';
|
||||
|
||||
/**
|
||||
* An auto-scroll affordance shown at the bottom of the viewport when there is
|
||||
* content scrolled out of view below. Scrolls the viewport down while hovered
|
||||
* and hides itself when already at the bottom.
|
||||
*/
|
||||
export type SelectScrollDownButtonProps = Omit<SelectScrollButtonImplProps, 'direction'>;
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watchEffect } from 'vue';
|
||||
|
||||
import { useEventListener, useForwardExpose } from '@robonen/vue';
|
||||
import { useSelectContentContext } from './context';
|
||||
import SelectScrollButtonImpl from './SelectScrollButtonImpl.vue';
|
||||
|
||||
const props = defineProps<SelectScrollDownButtonProps>();
|
||||
|
||||
const { forwardRef } = useForwardExpose();
|
||||
const contentCtx = useSelectContentContext();
|
||||
|
||||
const canScrollDown = ref(false);
|
||||
function update() {
|
||||
const viewport = contentCtx.viewportRef.value;
|
||||
canScrollDown.value = viewport ? viewport.scrollHeight - viewport.scrollTop > viewport.clientHeight + 1 : false;
|
||||
}
|
||||
|
||||
// Re-attaches when the viewport element changes; `passive` preserved, SSR-safe.
|
||||
useEventListener(contentCtx.viewportRef, 'scroll', update, { passive: true });
|
||||
// Seed/refresh the initial state whenever the viewport appears or changes.
|
||||
watchEffect(() => {
|
||||
if (contentCtx.viewportRef.value) update();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectScrollButtonImpl
|
||||
v-if="canScrollDown"
|
||||
v-bind="props"
|
||||
:ref="forwardRef"
|
||||
:direction="1"
|
||||
>
|
||||
<slot />
|
||||
</SelectScrollButtonImpl>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import type { SelectScrollButtonImplProps } from './SelectScrollButtonImpl.vue';
|
||||
|
||||
/**
|
||||
* An auto-scroll affordance shown at the top of the viewport when there is
|
||||
* content scrolled out of view above. Scrolls the viewport up while hovered and
|
||||
* hides itself when already at the top.
|
||||
*/
|
||||
export type SelectScrollUpButtonProps = Omit<SelectScrollButtonImplProps, 'direction'>;
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, watchEffect } from 'vue';
|
||||
|
||||
import { useEventListener, useForwardExpose } from '@robonen/vue';
|
||||
import { useSelectContentContext, useSelectItemAlignedPositionContext } from './context';
|
||||
import SelectScrollButtonImpl from './SelectScrollButtonImpl.vue';
|
||||
|
||||
const props = defineProps<SelectScrollUpButtonProps>();
|
||||
|
||||
const { forwardRef, currentElement } = useForwardExpose();
|
||||
const contentCtx = useSelectContentContext();
|
||||
const alignedCtx = contentCtx.position === 'item-aligned'
|
||||
? useSelectItemAlignedPositionContext(null as never)
|
||||
: undefined;
|
||||
|
||||
const canScrollUp = ref(false);
|
||||
|
||||
// Notify the item-aligned positioner that the scroll-up button mounted so it
|
||||
// can re-run alignment (the button pushes the viewport down).
|
||||
watch(currentElement, (el) => {
|
||||
if (el) alignedCtx?.onScrollButtonChange(el);
|
||||
});
|
||||
function update() {
|
||||
const viewport = contentCtx.viewportRef.value;
|
||||
canScrollUp.value = viewport ? viewport.scrollTop > 0 : false;
|
||||
}
|
||||
|
||||
// Re-attaches when the viewport element changes; `passive` preserved, SSR-safe.
|
||||
useEventListener(contentCtx.viewportRef, 'scroll', update, { passive: true });
|
||||
// Seed/refresh the initial state whenever the viewport appears or changes.
|
||||
watchEffect(() => {
|
||||
if (contentCtx.viewportRef.value) update();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectScrollButtonImpl
|
||||
v-if="canScrollUp"
|
||||
v-bind="props"
|
||||
:ref="forwardRef"
|
||||
:direction="-1"
|
||||
>
|
||||
<slot />
|
||||
</SelectScrollButtonImpl>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* A visual divider between groups or items in the content. Renders as a
|
||||
* horizontal `role="separator"` and is purely decorative.
|
||||
*/
|
||||
export interface SelectSeparatorProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
|
||||
const { as = 'div' } = defineProps<SelectSeparatorProps>();
|
||||
const { forwardRef } = useForwardExpose();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,145 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* The button that toggles the select open and anchors the floating content.
|
||||
* Renders as a `role="combobox"` control wired with the appropriate ARIA and
|
||||
* `data-state`/`data-placeholder` attributes; place a `SelectValue` and
|
||||
* `SelectIcon` inside it. Supports type-to-select while closed and touch-device
|
||||
* pointer hardening.
|
||||
*/
|
||||
export interface SelectTriggerProps extends PrimitiveProps {
|
||||
/** Disable this trigger independently from the root. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, watchEffect } from 'vue';
|
||||
|
||||
import { refAutoReset, useForwardExpose } from '@robonen/vue';
|
||||
import { PopperAnchor } from '../../overlays/popper';
|
||||
import { useSelectRootContext } from './context';
|
||||
import { OPEN_KEYS, getNextMatch, getOpenState, shouldShowPlaceholder } from './utils';
|
||||
|
||||
const { as = 'button', disabled = false } = defineProps<SelectTriggerProps>();
|
||||
|
||||
const { forwardRef, currentElement } = useForwardExpose();
|
||||
const rootCtx = useSelectRootContext();
|
||||
|
||||
const isDisabled = computed(() => rootCtx.disabled.value || disabled);
|
||||
|
||||
watchEffect(() => rootCtx.onTriggerChange(currentElement.value));
|
||||
onBeforeUnmount(() => rootCtx.onTriggerChange(undefined));
|
||||
|
||||
// Type-to-select on the closed trigger: cycle through the registered options by
|
||||
// label prefix and select the match without opening the listbox.
|
||||
const search = refAutoReset('', 1000);
|
||||
|
||||
function handleOpen() {
|
||||
if (isDisabled.value) return;
|
||||
rootCtx.onOpenChange(true);
|
||||
search.value = '';
|
||||
}
|
||||
|
||||
function handlePointerOpen(event: PointerEvent) {
|
||||
handleOpen();
|
||||
rootCtx.triggerPointerDownPosRef.value = {
|
||||
x: Math.round(event.pageX),
|
||||
y: Math.round(event.pageY),
|
||||
};
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (isDisabled.value) return;
|
||||
|
||||
// Prevent opening on touch down — open on touch pointerup instead.
|
||||
if (event.pointerType === 'touch') {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Release implicit pointer capture so subsequent pointer events target items.
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.hasPointerCapture?.(event.pointerId)) {
|
||||
target.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
if (event.button === 0 && !event.ctrlKey) {
|
||||
handlePointerOpen(event);
|
||||
// Prevent the trigger from stealing focus from the active item after open.
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerUp(event: PointerEvent) {
|
||||
event.preventDefault();
|
||||
if (event.pointerType === 'touch') {
|
||||
handlePointerOpen(event);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick(event: MouseEvent) {
|
||||
// Safari focuses the trigger on label clicks but skips pointerdown; force it.
|
||||
(event.currentTarget as HTMLElement | null)?.focus();
|
||||
}
|
||||
|
||||
function typeaheadSelect(key: string) {
|
||||
search.value += key;
|
||||
const options = Array.from(rootCtx.optionsSet.value);
|
||||
if (options.length === 0) return;
|
||||
|
||||
const labels = options.map(o => o.textContent);
|
||||
const current = Array.isArray(rootCtx.value.value) ? rootCtx.value.value[0] : rootCtx.value.value;
|
||||
const currentLabel = options.find(o => o.value === current)?.textContent;
|
||||
const next = getNextMatch(labels, search.value, currentLabel);
|
||||
if (next === undefined) return;
|
||||
|
||||
const matched = options[labels.indexOf(next)];
|
||||
if (matched) rootCtx.onValueChange(matched.value);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (isDisabled.value) return;
|
||||
const isModifierKey = event.ctrlKey || event.altKey || event.metaKey;
|
||||
const isTypingAhead = search.value !== '';
|
||||
|
||||
if (!isModifierKey && event.key.length === 1) {
|
||||
if (isTypingAhead && event.key === ' ') return;
|
||||
typeaheadSelect(event.key);
|
||||
}
|
||||
|
||||
if (OPEN_KEYS.includes(event.key)) {
|
||||
handleOpen();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- PopperAnchor IS the trigger button: the role/aria/handlers AND the
|
||||
consumer's class + children must live on the SAME element. A nested
|
||||
Primitive split them — the styled box landed on this anchor wrapper while
|
||||
the real button stayed an unstyled inline element with stacked content. -->
|
||||
<PopperAnchor
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
role="combobox"
|
||||
:type="as === 'button' ? 'button' : undefined"
|
||||
aria-autocomplete="none"
|
||||
:aria-controls="rootCtx.contentId.value"
|
||||
:aria-expanded="rootCtx.open.value"
|
||||
:aria-required="rootCtx.required.value || undefined"
|
||||
:dir="rootCtx.dir.value"
|
||||
:data-state="getOpenState(rootCtx.open.value)"
|
||||
:disabled="isDisabled || undefined"
|
||||
:data-disabled="isDisabled ? '' : undefined"
|
||||
:data-placeholder="shouldShowPlaceholder(rootCtx.value.value) ? '' : undefined"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointerup="handlePointerUp"
|
||||
@click="handleClick"
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<slot />
|
||||
</PopperAnchor>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* Displays the label(s) of the currently selected option(s) inside the trigger,
|
||||
* or the `placeholder` when nothing is selected. Renders into a non-interactive
|
||||
* span so pointer events fall through to the trigger. Exposes the resolved
|
||||
* `selectedLabel` array and raw `modelValue` to its default slot for custom
|
||||
* rendering (e.g. multi-value chips), and reflects a `data-placeholder`
|
||||
* attribute while empty.
|
||||
*/
|
||||
export interface SelectValueProps extends PrimitiveProps {
|
||||
/** Text shown when no option is selected. */
|
||||
placeholder?: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watchPostEffect } from 'vue';
|
||||
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { useSelectRootContext } from './context';
|
||||
import { valueComparator } from './utils';
|
||||
|
||||
const { as = 'span', placeholder = '' } = defineProps<SelectValueProps>();
|
||||
|
||||
const { forwardRef, currentElement } = useForwardExpose();
|
||||
const rootCtx = useSelectRootContext();
|
||||
|
||||
watchPostEffect(() => rootCtx.onValueElementChange(currentElement.value));
|
||||
|
||||
const selectedLabel = computed<string[]>(() => {
|
||||
const options = Array.from(rootCtx.optionsSet.value);
|
||||
const labelFor = (v: unknown) =>
|
||||
options.find(option => valueComparator(v as never, option.value as never, rootCtx.by as never))?.textContent ?? '';
|
||||
|
||||
const current = rootCtx.value.value;
|
||||
if (Array.isArray(current)) {
|
||||
return current.map(labelFor).filter(Boolean);
|
||||
}
|
||||
// Fall back to the persisted single-value label so a freshly-selected value
|
||||
// keeps showing after the listbox (and its items) unmount.
|
||||
const fromOptions = current === undefined ? '' : labelFor(current);
|
||||
const resolved = fromOptions || (rootCtx.displayValue.value ?? '');
|
||||
return resolved ? [resolved] : [];
|
||||
});
|
||||
|
||||
const slotText = computed(() => (selectedLabel.value.length ? selectedLabel.value.join(', ') : placeholder));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
style="pointer-events: none"
|
||||
:data-placeholder="selectedLabel.length ? undefined : placeholder"
|
||||
>
|
||||
<slot :selected-label="selectedLabel" :model-value="rootCtx.value.value">
|
||||
{{ slotText }}
|
||||
</slot>
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* The scrollable region inside the content that wraps the options. Marked
|
||||
* `role="presentation"` (the listbox role lives on the content element), caps
|
||||
* its height to the available space, and scrolls when the list overflows. In
|
||||
* `item-aligned` mode it grows the panel as you scroll (MacOS-style). Pair it
|
||||
* with the scroll buttons for an item-aligned menu.
|
||||
*/
|
||||
export interface SelectViewportProps extends PrimitiveProps {
|
||||
/**
|
||||
* CSP `nonce` for the injected scrollbar-hiding `<style>` tag. Falls back to
|
||||
* the active `ConfigProvider` nonce.
|
||||
*/
|
||||
nonce?: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, toRef, watchPostEffect } from 'vue';
|
||||
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { useNonce } from '../../utilities/config-provider';
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { useSelectContentContext, useSelectItemAlignedPositionContext } from './context';
|
||||
import { CONTENT_MARGIN } from './utils';
|
||||
|
||||
const { as = 'div', nonce: propNonce } = defineProps<SelectViewportProps>();
|
||||
|
||||
const { forwardRef, currentElement } = useForwardExpose();
|
||||
const contentCtx = useSelectContentContext();
|
||||
const nonce = useNonce(toRef(() => propNonce));
|
||||
|
||||
const alignedCtx = contentCtx.position === 'item-aligned'
|
||||
? useSelectItemAlignedPositionContext(null as never)
|
||||
: undefined;
|
||||
|
||||
watchPostEffect(() => contentCtx.onViewportChange(currentElement.value));
|
||||
|
||||
const prevScrollTopRef = ref(0);
|
||||
|
||||
// Expand-on-scroll for item-aligned mode: grow the wrapper as the user scrolls
|
||||
// so more options become visible, matching the native MacOS menu behaviour.
|
||||
function handleScroll(event: Event) {
|
||||
const viewport = event.currentTarget as HTMLElement;
|
||||
const shouldExpand = alignedCtx?.shouldExpandOnScrollRef;
|
||||
const contentWrapper = alignedCtx?.contentWrapper;
|
||||
if (shouldExpand?.value && contentWrapper?.value) {
|
||||
const scrolledBy = Math.abs(prevScrollTopRef.value - viewport.scrollTop);
|
||||
if (scrolledBy > 0) {
|
||||
const availableHeight = window.innerHeight - CONTENT_MARGIN * 2;
|
||||
const cssMinHeight = Number.parseFloat(contentWrapper.value.style.minHeight);
|
||||
const cssHeight = Number.parseFloat(contentWrapper.value.style.height);
|
||||
const prevHeight = Math.max(cssMinHeight || 0, cssHeight || 0);
|
||||
|
||||
if (prevHeight < availableHeight) {
|
||||
const nextHeight = prevHeight + scrolledBy;
|
||||
const clampedNextHeight = Math.min(availableHeight, nextHeight);
|
||||
const heightDiff = nextHeight - clampedNextHeight;
|
||||
|
||||
contentWrapper.value.style.height = `${clampedNextHeight}px`;
|
||||
if (contentWrapper.value.style.bottom === '0px') {
|
||||
viewport.scrollTop = heightDiff > 0 ? heightDiff : 0;
|
||||
contentWrapper.value.style.justifyContent = 'flex-end';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
prevScrollTopRef.value = viewport.scrollTop;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
role="presentation"
|
||||
data-primitives-select-viewport
|
||||
style="position: relative; flex: 1; overflow: hidden auto; max-height: var(--primitives-select-content-available-height, 300px)"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
<Primitive as="style" :nonce="nonce">
|
||||
[data-primitives-select-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}
|
||||
[data-primitives-select-viewport]::-webkit-scrollbar{display:none;}
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,387 @@
|
||||
import {
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
SelectPortal,
|
||||
SelectRoot,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectViewport,
|
||||
} from '../index';
|
||||
import type { SelectItemSelectEvent } from '../index';
|
||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
const mounted: Array<{ unmount: () => void }> = [];
|
||||
function track<T extends { unmount: () => void }>(w: T): T {
|
||||
mounted.push(w);
|
||||
return w;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (mounted.length) mounted.pop()!.unmount();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
async function flush() {
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
interface Opt { value: unknown; label: string; disabled?: boolean }
|
||||
|
||||
const valueRef = ref<unknown>(undefined);
|
||||
|
||||
function createSelect(
|
||||
rootProps: Record<string, unknown> = {},
|
||||
options: Opt[] = [
|
||||
{ value: 'apple', label: 'Apple' },
|
||||
{ value: 'banana', label: 'Banana' },
|
||||
{ value: 'cherry', label: 'Cherry' },
|
||||
],
|
||||
itemHandlers: Record<string, unknown> = {},
|
||||
) {
|
||||
const value = ref(rootProps.modelValue ?? rootProps.defaultValue);
|
||||
valueRef.value = value.value;
|
||||
const w = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => h(
|
||||
SelectRoot,
|
||||
{
|
||||
modelValue: value.value as never,
|
||||
'onUpdate:modelValue': (v: unknown) => {
|
||||
value.value = v as never;
|
||||
valueRef.value = v;
|
||||
},
|
||||
...rootProps,
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
h(SelectTrigger, null, {
|
||||
default: () => h(SelectValue, { placeholder: 'Pick one' }),
|
||||
}),
|
||||
h(SelectPortal, null, {
|
||||
default: () => h(SelectContent, null, {
|
||||
default: () => h(SelectViewport, null, {
|
||||
default: () => options.map(opt =>
|
||||
h(SelectItem, { key: String(opt.value), value: opt.value as never, disabled: opt.disabled, ...itemHandlers }, {
|
||||
default: () => [
|
||||
h(SelectItemText, null, { default: () => opt.label }),
|
||||
h(SelectItemIndicator, null, { default: () => '✓' }),
|
||||
],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
);
|
||||
},
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
return track(w);
|
||||
}
|
||||
|
||||
function press(el: Element, key: string, init: KeyboardEventInit = {}) {
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }));
|
||||
}
|
||||
|
||||
function getTrigger(): HTMLElement {
|
||||
return document.querySelector('[role="combobox"]') as HTMLElement;
|
||||
}
|
||||
|
||||
function getContent(): HTMLElement | null {
|
||||
return document.querySelector('[role="listbox"]');
|
||||
}
|
||||
|
||||
function getItems(): HTMLElement[] {
|
||||
return Array.from(document.querySelectorAll<HTMLElement>('[role="option"]'));
|
||||
}
|
||||
|
||||
async function openByClick(trigger: HTMLElement) {
|
||||
trigger.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerType: 'mouse' }));
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
describe('Select — ARIA skeleton', () => {
|
||||
it('renders trigger as role=combobox with aria-controls and aria-expanded', () => {
|
||||
const w = createSelect();
|
||||
const trigger = getTrigger();
|
||||
expect(trigger).toBeTruthy();
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(trigger.getAttribute('aria-controls')).toBeTruthy();
|
||||
expect(trigger.getAttribute('aria-autocomplete')).toBe('none');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('shows placeholder + data-placeholder when empty', () => {
|
||||
const w = createSelect();
|
||||
const trigger = getTrigger();
|
||||
expect(trigger.getAttribute('data-placeholder')).toBe('');
|
||||
expect(trigger.textContent).toContain('Pick one');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('content carries role=listbox and matches trigger aria-controls when open', async () => {
|
||||
const w = createSelect();
|
||||
const trigger = getTrigger();
|
||||
await openByClick(trigger);
|
||||
const content = getContent();
|
||||
expect(content).toBeTruthy();
|
||||
expect(content!.id).toBe(trigger.getAttribute('aria-controls'));
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('true');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('items expose role=option, aria-labelledby, aria-selected, data-state', async () => {
|
||||
const w = createSelect({ defaultValue: 'banana' });
|
||||
await openByClick(getTrigger());
|
||||
const items = getItems();
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items[0]!.getAttribute('aria-labelledby')).toBeTruthy();
|
||||
const banana = items.find(i => i.textContent?.includes('Banana'))!;
|
||||
expect(banana.getAttribute('aria-selected')).toBe('true');
|
||||
expect(banana.getAttribute('data-state')).toBe('checked');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Select — initial label without opening', () => {
|
||||
it('SelectValue shows the selected label before the dropdown is ever opened', async () => {
|
||||
const w = createSelect({ defaultValue: 'cherry' });
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
const trigger = getTrigger();
|
||||
expect(trigger.textContent).toContain('Cherry');
|
||||
expect(trigger.getAttribute('data-placeholder')).toBeNull();
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Select — selection (controlled + uncontrolled)', () => {
|
||||
it('clicking an item selects its value and closes (single)', async () => {
|
||||
createSelect();
|
||||
const trigger = getTrigger();
|
||||
await openByClick(trigger);
|
||||
const banana = getItems().find(i => i.textContent?.includes('Banana'))!;
|
||||
banana.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await flush();
|
||||
expect(valueRef.value).toBe('banana');
|
||||
expect(getContent()).toBeNull();
|
||||
expect(getTrigger().textContent).toContain('Banana');
|
||||
});
|
||||
|
||||
it('uncontrolled defaultValue drives initial selection', async () => {
|
||||
const w = createSelect({ defaultValue: 'apple', modelValue: undefined });
|
||||
await nextTick();
|
||||
expect(getTrigger().textContent).toContain('Apple');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Select — keyboard', () => {
|
||||
it('opens on ArrowDown / Enter / Space', async () => {
|
||||
const w = createSelect();
|
||||
const trigger = getTrigger();
|
||||
press(trigger, 'ArrowDown');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(getContent()).toBeTruthy();
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('Enter on an item selects it', async () => {
|
||||
const w = createSelect();
|
||||
await openByClick(getTrigger());
|
||||
const cherry = getItems().find(i => i.textContent?.includes('Cherry'))!;
|
||||
press(cherry, 'Enter');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(getTrigger().textContent).toContain('Cherry');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Select — disabled', () => {
|
||||
it('root disabled prevents opening', async () => {
|
||||
const w = createSelect({ disabled: true });
|
||||
const trigger = getTrigger();
|
||||
await openByClick(trigger);
|
||||
expect(getContent()).toBeNull();
|
||||
expect(trigger.getAttribute('data-disabled')).toBe('');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('disabled item does not select on click', async () => {
|
||||
const w = createSelect({}, [
|
||||
{ value: 'a', label: 'A' },
|
||||
{ value: 'b', label: 'B', disabled: true },
|
||||
]);
|
||||
await openByClick(getTrigger());
|
||||
const b = getItems().find(i => i.textContent?.includes('B'))!;
|
||||
expect(b.getAttribute('data-disabled')).toBe('');
|
||||
b.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(getTrigger().textContent).not.toContain('B');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Select — multiple', () => {
|
||||
it('toggles array membership and stays open', async () => {
|
||||
createSelect({ multiple: true, modelValue: undefined, defaultValue: [] as string[] });
|
||||
const trigger = getTrigger();
|
||||
await openByClick(trigger);
|
||||
getItems().find(i => i.textContent?.includes('Apple'))!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await flush();
|
||||
expect(getContent()).toBeTruthy(); // stays open
|
||||
getItems().find(i => i.textContent?.includes('Cherry'))!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await flush();
|
||||
expect(valueRef.value).toEqual(['apple', 'cherry']);
|
||||
});
|
||||
|
||||
it('reselecting a value removes it from the array', async () => {
|
||||
createSelect({ multiple: true, modelValue: undefined, defaultValue: ['apple', 'banana'] as string[] });
|
||||
await openByClick(getTrigger());
|
||||
getItems().find(i => i.textContent?.includes('Apple'))!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await flush();
|
||||
expect(valueRef.value).toEqual(['banana']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Select — object values with `by`', () => {
|
||||
it('compares object values by key', async () => {
|
||||
const objs: Opt[] = [
|
||||
{ value: { id: 1 }, label: 'One' },
|
||||
{ value: { id: 2 }, label: 'Two' },
|
||||
];
|
||||
const w = createSelect({ by: 'id', defaultValue: { id: 2 } }, objs);
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(getTrigger().textContent).toContain('Two');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Select — select event is cancelable', () => {
|
||||
it('preventDefault on the select event blocks the value change', async () => {
|
||||
const onSelect = vi.fn((e: SelectItemSelectEvent) => e.preventDefault());
|
||||
const w = createSelect({}, undefined, { onSelect });
|
||||
await openByClick(getTrigger());
|
||||
const apple = getItems().find(i => i.textContent?.includes('Apple'))!;
|
||||
apple.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(onSelect).toHaveBeenCalled();
|
||||
expect(getTrigger().textContent).not.toContain('Apple');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Select — empty-string value guard', () => {
|
||||
it('throws if an item has an empty-string value', async () => {
|
||||
const errors: unknown[] = [];
|
||||
const original = globalThis.onerror;
|
||||
const onRejection = (e: PromiseRejectionEvent) => {
|
||||
errors.push(e.reason);
|
||||
e.preventDefault();
|
||||
};
|
||||
globalThis.addEventListener('unhandledrejection', onRejection);
|
||||
globalThis.onerror = (_m, _s, _l, _c, err) => {
|
||||
errors.push(err);
|
||||
return true;
|
||||
};
|
||||
|
||||
const w = mount(
|
||||
defineComponent({
|
||||
errorCaptured(err) {
|
||||
errors.push(err);
|
||||
return false;
|
||||
},
|
||||
setup() {
|
||||
return () => h(SelectRoot, { defaultOpen: true }, {
|
||||
default: () => h(SelectPortal, null, {
|
||||
default: () => h(SelectContent, null, {
|
||||
default: () => h(SelectViewport, null, {
|
||||
default: () => h(SelectItem, { value: '' }, {
|
||||
default: () => h(SelectItemText, null, { default: () => 'Empty' }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
track(w);
|
||||
await flush();
|
||||
globalThis.removeEventListener('unhandledrejection', onRejection);
|
||||
globalThis.onerror = original;
|
||||
|
||||
expect(errors.some(e => (e as Error)?.message?.includes('empty string'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Select — typeahead on closed trigger', () => {
|
||||
it('typing selects a matching option without opening', async () => {
|
||||
const w = createSelect();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
const trigger = getTrigger();
|
||||
press(trigger, 'c');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(getContent()).toBeNull();
|
||||
expect(getTrigger().textContent).toContain('Cherry');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Select — native form submission', () => {
|
||||
it('renders a hidden native select inside a form with options', async () => {
|
||||
const w = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
const value = ref('banana');
|
||||
return () => h('form', null, [
|
||||
h(
|
||||
SelectRoot,
|
||||
{ name: 'fruit', modelValue: value.value, 'onUpdate:modelValue': (v: never) => (value.value = v) },
|
||||
{
|
||||
default: () => [
|
||||
h(SelectTrigger, null, { default: () => h(SelectValue) }),
|
||||
h(SelectPortal, null, {
|
||||
default: () => h(SelectContent, null, {
|
||||
default: () => h(SelectViewport, null, {
|
||||
default: () => [
|
||||
h(SelectItem, { value: 'apple' }, { default: () => h(SelectItemText, null, { default: () => 'Apple' }) }),
|
||||
h(SelectItem, { value: 'banana' }, { default: () => h(SelectItemText, null, { default: () => 'Banana' }) }),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
),
|
||||
]);
|
||||
},
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
const nativeSelect = document.querySelector('form select[name="fruit"]') as HTMLSelectElement | null;
|
||||
expect(nativeSelect).toBeTruthy();
|
||||
expect(nativeSelect!.querySelectorAll('option').length).toBeGreaterThanOrEqual(2);
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
@@ -0,0 +1,122 @@
|
||||
import type { ComputedRef, Ref, ShallowRef } from 'vue';
|
||||
import type { Direction } from '../../utilities/config-provider';
|
||||
import type { AcceptableValue } from './utils';
|
||||
|
||||
import { useContextFactory } from '@robonen/vue';
|
||||
|
||||
/**
|
||||
* @deprecated Kept for backward compatibility. The select now accepts any
|
||||
* {@link AcceptableValue} (string/number/boolean/object). `SelectValue` remains
|
||||
* a string alias so existing `string`-typed consumers keep compiling.
|
||||
*/
|
||||
export type SelectValue = string;
|
||||
|
||||
export interface SelectOption {
|
||||
value: AcceptableValue;
|
||||
disabled?: boolean;
|
||||
textContent: string;
|
||||
}
|
||||
|
||||
export interface SelectRootContext {
|
||||
value: Ref<AcceptableValue | AcceptableValue[] | undefined>;
|
||||
onValueChange: (value: AcceptableValue) => void;
|
||||
open: Ref<boolean>;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
dir: Ref<Direction | undefined>;
|
||||
disabled: Ref<boolean>;
|
||||
required: Ref<boolean>;
|
||||
multiple: Ref<boolean>;
|
||||
by?: string | ((a: AcceptableValue, b: AcceptableValue) => boolean);
|
||||
name: Ref<string | undefined>;
|
||||
triggerElement: ShallowRef<HTMLElement | undefined>;
|
||||
onTriggerChange: (el: HTMLElement | undefined) => void;
|
||||
valueElement: ShallowRef<HTMLElement | undefined>;
|
||||
onValueElementChange: (el: HTMLElement | undefined) => void;
|
||||
triggerPointerDownPosRef: Ref<{ x: number; y: number } | null>;
|
||||
contentId: Ref<string>;
|
||||
isEmptyModelValue: ComputedRef<boolean>;
|
||||
/**
|
||||
* @deprecated Superseded by `optionsSet`. The persisted single-value label
|
||||
* still drives the legacy `displayValue` slot path.
|
||||
*/
|
||||
displayValue: Ref<string | undefined>;
|
||||
optionsSet: ShallowRef<Set<SelectOption>>;
|
||||
onOptionAdd: (option: SelectOption) => void;
|
||||
onOptionRemove: (option: SelectOption) => void;
|
||||
itemRefCallback: (el: HTMLElement | undefined, value: AcceptableValue, disabled: boolean) => void;
|
||||
itemTextRefCallback: (el: HTMLElement | undefined, value: AcceptableValue) => void;
|
||||
selectedItemRef: ShallowRef<HTMLElement | undefined>;
|
||||
selectedItemTextRef: ShallowRef<HTMLElement | undefined>;
|
||||
}
|
||||
|
||||
export interface SelectContentContext {
|
||||
viewportRef: ShallowRef<HTMLElement | undefined>;
|
||||
onViewportChange: (el: HTMLElement | undefined) => void;
|
||||
contentRef: ShallowRef<HTMLElement | undefined>;
|
||||
selectedItemRef: ShallowRef<HTMLElement | undefined>;
|
||||
selectedItemTextRef: ShallowRef<HTMLElement | undefined>;
|
||||
onItemLeave: () => void;
|
||||
focusSelectedItem: () => void;
|
||||
itemRefCallback: SelectRootContext['itemRefCallback'];
|
||||
itemTextRefCallback: SelectRootContext['itemTextRefCallback'];
|
||||
isPositioned: Ref<boolean>;
|
||||
searchRef: Ref<string>;
|
||||
position: 'item-aligned' | 'popper';
|
||||
}
|
||||
|
||||
export interface SelectItemAlignedPositionContext {
|
||||
contentWrapper: ShallowRef<HTMLElement | undefined>;
|
||||
shouldExpandOnScrollRef: Ref<boolean>;
|
||||
onScrollButtonChange: (el: HTMLElement | undefined) => void;
|
||||
}
|
||||
|
||||
export interface SelectGroupContext {
|
||||
id: Ref<string>;
|
||||
}
|
||||
|
||||
export interface SelectItemContext {
|
||||
value: AcceptableValue;
|
||||
isSelected: Ref<boolean>;
|
||||
isDisabled: Ref<boolean>;
|
||||
isFocused: Ref<boolean>;
|
||||
textId: Ref<string>;
|
||||
onItemTextChange: (el: HTMLElement | undefined) => void;
|
||||
}
|
||||
|
||||
const {
|
||||
inject: useSelectRootContext,
|
||||
provide: provideSelectRootContext,
|
||||
} = useContextFactory<SelectRootContext>('SelectRootContext');
|
||||
|
||||
const {
|
||||
inject: useSelectContentContext,
|
||||
provide: provideSelectContentContext,
|
||||
} = useContextFactory<SelectContentContext>('SelectContentContext');
|
||||
|
||||
const {
|
||||
inject: useSelectItemAlignedPositionContext,
|
||||
provide: provideSelectItemAlignedPositionContext,
|
||||
} = useContextFactory<SelectItemAlignedPositionContext>('SelectItemAlignedPositionContext');
|
||||
|
||||
const {
|
||||
inject: useSelectGroupContext,
|
||||
provide: provideSelectGroupContext,
|
||||
} = useContextFactory<SelectGroupContext>('SelectGroupContext');
|
||||
|
||||
const {
|
||||
inject: useSelectItemContext,
|
||||
provide: provideSelectItemContext,
|
||||
} = useContextFactory<SelectItemContext>('SelectItemContext');
|
||||
|
||||
export {
|
||||
useSelectRootContext,
|
||||
provideSelectRootContext,
|
||||
useSelectContentContext,
|
||||
provideSelectContentContext,
|
||||
useSelectItemAlignedPositionContext,
|
||||
provideSelectItemAlignedPositionContext,
|
||||
useSelectGroupContext,
|
||||
provideSelectGroupContext,
|
||||
useSelectItemContext,
|
||||
provideSelectItemContext,
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectIcon,
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
SelectLabel,
|
||||
SelectPortal,
|
||||
SelectRoot,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectViewport,
|
||||
} from '@robonen/primitives';
|
||||
|
||||
const value = ref<string | undefined>('cat');
|
||||
|
||||
const animals = {
|
||||
Pets: [
|
||||
{ value: 'cat', label: 'Cat' },
|
||||
{ value: 'dog', label: 'Dog' },
|
||||
{ value: 'rabbit', label: 'Rabbit' },
|
||||
{ value: 'hamster', label: 'Hamster', disabled: true },
|
||||
],
|
||||
Wild: [
|
||||
{ value: 'lion', label: 'Lion' },
|
||||
{ value: 'tiger', label: 'Tiger' },
|
||||
{ value: 'bear', label: 'Bear' },
|
||||
{ value: 'wolf', label: 'Wolf' },
|
||||
{ value: 'fox', label: 'Fox' },
|
||||
],
|
||||
};
|
||||
|
||||
const itemClass
|
||||
= 'relative flex cursor-default select-none items-center rounded-md py-1.5 pl-7 pr-2 text-sm text-(--fg) outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-(--accent) focus:text-(--accent-fg)';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-start gap-3 text-(--fg)">
|
||||
<SelectRoot v-model="value" name="animal">
|
||||
<SelectTrigger
|
||||
class="inline-flex w-56 items-center justify-between gap-2 rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) transition-colors hover:bg-(--bg-subtle) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--ring) data-[placeholder]:text-(--fg-subtle)"
|
||||
>
|
||||
<SelectValue placeholder="Pick an animal…" />
|
||||
<SelectIcon class="text-(--fg-muted)">
|
||||
<span class="i-carbon-chevron-down block" aria-hidden="true" />
|
||||
</SelectIcon>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectPortal>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
:side-offset="6"
|
||||
class="z-50 w-[var(--popper-anchor-width)] min-w-56 overflow-hidden rounded-lg border border-(--border) bg-(--bg-elevated) shadow-lg"
|
||||
>
|
||||
<SelectScrollUpButton class="flex h-6 items-center justify-center bg-(--bg-elevated) text-(--fg-muted)">
|
||||
<span class="i-carbon-chevron-up block" aria-hidden="true" />
|
||||
</SelectScrollUpButton>
|
||||
|
||||
<SelectViewport class="max-h-60 p-1">
|
||||
<template v-for="(items, group, gi) in animals" :key="group">
|
||||
<SelectSeparator v-if="gi > 0" class="my-1 h-px bg-(--border)" />
|
||||
<SelectGroup>
|
||||
<SelectLabel class="px-2 py-1.5 text-xs font-medium text-(--fg-subtle)">
|
||||
{{ group }}
|
||||
</SelectLabel>
|
||||
<SelectItem
|
||||
v-for="item in items"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
:disabled="item.disabled"
|
||||
:class="itemClass"
|
||||
>
|
||||
<SelectItemIndicator class="absolute left-2 inline-flex items-center">
|
||||
<span class="i-carbon-checkmark block text-(--accent)" aria-hidden="true" />
|
||||
</SelectItemIndicator>
|
||||
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</template>
|
||||
</SelectViewport>
|
||||
|
||||
<SelectScrollDownButton class="flex h-6 items-center justify-center bg-(--bg-elevated) text-(--fg-muted)">
|
||||
<span class="i-carbon-chevron-down block" aria-hidden="true" />
|
||||
</SelectScrollDownButton>
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</SelectRoot>
|
||||
|
||||
<p class="text-xs text-(--fg-muted)">
|
||||
Selected:
|
||||
<span class="font-medium text-(--fg)">{{ value ?? 'none' }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
export { default as SelectRoot } from './SelectRoot.vue';
|
||||
export { default as SelectTrigger } from './SelectTrigger.vue';
|
||||
export { default as SelectValue } from './SelectValue.vue';
|
||||
export { default as SelectIcon } from './SelectIcon.vue';
|
||||
export { default as SelectPortal } from './SelectPortal.vue';
|
||||
export { default as SelectContent } from './SelectContent.vue';
|
||||
export { default as SelectContentImpl } from './SelectContentImpl.vue';
|
||||
export { default as SelectItemAlignedPosition } from './SelectItemAlignedPosition.vue';
|
||||
export { default as SelectPopperPosition } from './SelectPopperPosition.vue';
|
||||
export { default as SelectViewport } from './SelectViewport.vue';
|
||||
export { default as SelectScrollUpButton } from './SelectScrollUpButton.vue';
|
||||
export { default as SelectScrollDownButton } from './SelectScrollDownButton.vue';
|
||||
export { default as SelectGroup } from './SelectGroup.vue';
|
||||
export { default as SelectLabel } from './SelectLabel.vue';
|
||||
export { default as SelectItem } from './SelectItem.vue';
|
||||
export { default as SelectItemText } from './SelectItemText.vue';
|
||||
export { default as SelectItemIndicator } from './SelectItemIndicator.vue';
|
||||
export { default as SelectSeparator } from './SelectSeparator.vue';
|
||||
export { default as SelectArrow } from './SelectArrow.vue';
|
||||
export { default as SelectProvider } from './SelectProvider.vue';
|
||||
export { default as SelectBubbleSelect } from './SelectBubbleSelect.vue';
|
||||
|
||||
export {
|
||||
useSelectRootContext,
|
||||
useSelectContentContext,
|
||||
useSelectItemAlignedPositionContext,
|
||||
useSelectGroupContext,
|
||||
useSelectItemContext,
|
||||
} from './context';
|
||||
|
||||
export type {
|
||||
SelectValue,
|
||||
SelectOption,
|
||||
SelectRootContext,
|
||||
SelectContentContext,
|
||||
SelectItemAlignedPositionContext,
|
||||
SelectGroupContext,
|
||||
SelectItemContext,
|
||||
} from './context';
|
||||
export type { AcceptableValue as SelectAcceptableValue } from './utils';
|
||||
export type { SelectRootProps, SelectRootEmits } from './SelectRoot.vue';
|
||||
export type { SelectTriggerProps } from './SelectTrigger.vue';
|
||||
export type { SelectValueProps } from './SelectValue.vue';
|
||||
export type { SelectIconProps } from './SelectIcon.vue';
|
||||
export type { SelectPortalProps } from './SelectPortal.vue';
|
||||
export type { SelectContentProps, SelectContentEmits } from './SelectContent.vue';
|
||||
export type { SelectContentImplProps, SelectContentImplEmits } from './SelectContentImpl.vue';
|
||||
export type { SelectItemAlignedPositionProps, SelectItemAlignedPositionEmits } from './SelectItemAlignedPosition.vue';
|
||||
export type { SelectPopperPositionProps, SelectPopperPositionEmits } from './SelectPopperPosition.vue';
|
||||
export type { SelectViewportProps } from './SelectViewport.vue';
|
||||
export type { SelectScrollUpButtonProps } from './SelectScrollUpButton.vue';
|
||||
export type { SelectScrollDownButtonProps } from './SelectScrollDownButton.vue';
|
||||
export type { SelectGroupProps } from './SelectGroup.vue';
|
||||
export type { SelectLabelProps } from './SelectLabel.vue';
|
||||
export type { SelectItemProps, SelectItemEmits, SelectItemSelectEvent } from './SelectItem.vue';
|
||||
export type { SelectItemTextProps } from './SelectItemText.vue';
|
||||
export type { SelectItemIndicatorProps } from './SelectItemIndicator.vue';
|
||||
export type { SelectSeparatorProps } from './SelectSeparator.vue';
|
||||
export type { SelectArrowProps } from './SelectArrow.vue';
|
||||
export type { SelectProviderProps } from './SelectProvider.vue';
|
||||
export type { SelectBubbleSelectProps } from './SelectBubbleSelect.vue';
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Any serialisable value a select option can carry — not just strings. Mirrors
|
||||
* the listbox/combobox value model so a select can hold numbers, booleans, or
|
||||
* plain objects (compared via `by`).
|
||||
*/
|
||||
export type AcceptableValue = string | number | boolean | Record<string, unknown>;
|
||||
|
||||
export const OPEN_KEYS = [' ', 'Enter', 'ArrowUp', 'ArrowDown'];
|
||||
export const SELECTION_KEYS = [' ', 'Enter'];
|
||||
export const CONTENT_MARGIN = 10;
|
||||
|
||||
export function getOpenState(open: boolean): 'open' | 'closed' {
|
||||
return open ? 'open' : 'closed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two option values. `by` selects the strategy: omitted → strict `===`
|
||||
* for primitives / structural deep-equality for objects; a function → custom
|
||||
* comparator; a string → compare that property key. Either side `undefined`
|
||||
* never matches.
|
||||
*/
|
||||
export function compare<T>(
|
||||
a: T | undefined,
|
||||
b: T | undefined,
|
||||
by?: string | ((a: T, b: T) => boolean),
|
||||
): boolean {
|
||||
if (a === undefined || b === undefined) return false;
|
||||
if (typeof by === 'function') return by(a, b);
|
||||
if (typeof by === 'string') return (a as Record<string, unknown>)?.[by] === (b as Record<string, unknown>)?.[by];
|
||||
if (typeof a === 'string') return a === b;
|
||||
if (typeof a === 'object' && a !== null && typeof b === 'object' && b !== null) {
|
||||
return deepEqual(a, b);
|
||||
}
|
||||
return a === b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `current` is contained in `value` (a single value or array), using
|
||||
* {@link compare} for each element.
|
||||
*/
|
||||
export function valueComparator<T>(
|
||||
value: T | T[] | undefined,
|
||||
current: T,
|
||||
by?: string | ((a: T, b: T) => boolean),
|
||||
): boolean {
|
||||
if (value === undefined) return false;
|
||||
if (!Array.isArray(value)) return compare(value, current, by);
|
||||
for (const v of value) {
|
||||
if (compare(v, current, by)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural deep equality fallback for object values when `by` is omitted, so
|
||||
* two structurally-equal plain objects are recognised as the same selection.
|
||||
* Kept local (no external dependency) — option values are shallow plain
|
||||
* objects/arrays.
|
||||
*/
|
||||
function deepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false;
|
||||
|
||||
const aArray = Array.isArray(a);
|
||||
const bArray = Array.isArray(b);
|
||||
if (aArray !== bArray) return false;
|
||||
|
||||
if (aArray && bArray) {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (!deepEqual(a[i], b[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const aKeys = Object.keys(a as Record<string, unknown>);
|
||||
const bKeys = Object.keys(b as Record<string, unknown>);
|
||||
if (aKeys.length !== bKeys.length) return false;
|
||||
for (const key of aKeys) {
|
||||
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
|
||||
if (!deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function shouldShowPlaceholder(value?: AcceptableValue | AcceptableValue[]): boolean {
|
||||
return value === undefined
|
||||
|| value === null
|
||||
|| value === ''
|
||||
|| (Array.isArray(value) && value.length === 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates `array` so it starts at `startIndex`, wrapping around.
|
||||
* `wrapArray(['a','b','c','d'], 2) === ['c','d','a','b']`.
|
||||
*/
|
||||
export function wrapArray<T>(array: T[], startIndex: number): T[] {
|
||||
const len = array.length;
|
||||
const out: T[] = Array.from({ length: len });
|
||||
for (let i = 0; i < len; i++) out[i] = array[(startIndex + i) % len]!;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core type-ahead matcher. Given the list of item text values, the accumulated
|
||||
* `search` buffer, and the current match text, returns the next item text to
|
||||
* focus (or `undefined`). Repeated single characters cycle through matches;
|
||||
* longer buffers match by prefix without excluding the current item.
|
||||
*/
|
||||
export function getNextMatch(
|
||||
values: string[],
|
||||
search: string,
|
||||
currentMatch?: string,
|
||||
): string | undefined {
|
||||
const isRepeated = search.length > 1 && Array.from(search).every(c => c === search[0]);
|
||||
const normalizedSearch = isRepeated ? search[0]! : search;
|
||||
const currentMatchIndex = currentMatch ? values.indexOf(currentMatch) : -1;
|
||||
let wrapped = wrapArray(values, Math.max(currentMatchIndex, 0));
|
||||
const excludeCurrentMatch = normalizedSearch.length === 1;
|
||||
if (excludeCurrentMatch) wrapped = wrapped.filter(v => v !== currentMatch);
|
||||
const lower = normalizedSearch.toLowerCase();
|
||||
const nextMatch = wrapped.find(v => v.toLowerCase().startsWith(lower));
|
||||
return nextMatch !== currentMatch ? nextMatch : undefined;
|
||||
}
|
||||