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

Reorganize components into category folders (forms/canvas/overlays/etc.); add the
media-editor headless family (timeline, curve-editor, waveform, crop, color
picker, etc.); apply perf fixes (O(1) collection lookups, plain-object drag
state, gesture-leak teardown, shallowRef color state, rect caching) and replace
source `any` with proper types.
This commit is contained in:
2026-06-15 16:54:29 +07:00
parent 661a55719e
commit eefd7abf83
1029 changed files with 65815 additions and 9449 deletions
@@ -0,0 +1,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();
});
});
@@ -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;
}