1 Commits

Author SHA1 Message Date
Renovate Bot 3bc142af14 chore(deps): update peerdependency eslint to v10
CI / @robonen/crdt (pull_request) Successful in 1m15s
CI / @robonen/docs (pull_request) Successful in 5m4s
CI / @robonen/encoding (pull_request) Successful in 1m14s
CI / @robonen/eslint (pull_request) Successful in 1m27s
CI / @robonen/fetch (pull_request) Successful in 1m13s
CI / @robonen/platform (pull_request) Successful in 1m47s
CI / @robonen/primitives (pull_request) Successful in 4m34s
CI / @robonen/primitives-playground (pull_request) Successful in 1m53s
CI / @robonen/renovate (pull_request) Successful in 58s
CI / @robonen/stdlib (pull_request) Successful in 1m54s
CI / @robonen/stories (pull_request) Successful in 2m35s
CI / @robonen/tsconfig (pull_request) Successful in 52s
CI / @robonen/tsdown (pull_request) Successful in 49s
CI / @robonen/vue (pull_request) Successful in 3m20s
CI / @robonen/writekit (pull_request) Successful in 3m45s
CI / @robonen/writekit-playground (pull_request) Successful in 2m4s
CI / CI (pull_request) Successful in 5s
2026-08-08 00:03:44 +00:00
27 changed files with 124 additions and 433 deletions
+1 -1
View File
@@ -67,7 +67,7 @@
"tsdown": "catalog:"
},
"peerDependencies": {
"eslint": ">=9.39.4"
"eslint": ">=10.8.1"
},
"publishConfig": {
"access": "public"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@robonen/tsconfig",
"version": "0.1.1",
"version": "0.1.0",
"license": "Apache-2.0",
"description": "Base typescript configuration for projects",
"keywords": [
-1
View File
@@ -8,7 +8,6 @@
"vueCompilerOptions": {
"strictTemplates": true,
"fallthroughAttributes": true,
"htmlAttributes": ["aria-*", "data-*"],
"inferTemplateDollarAttrs": true,
"inferTemplateDollarEl": true,
"inferTemplateDollarRefs": true
+1 -1
View File
@@ -2,6 +2,6 @@
"$schema": "https://jsr.io/schema/config-file.v1.json",
"name": "@robonen/primitives",
"license": "Apache-2.0",
"version": "0.0.5",
"version": "0.0.2",
"exports": "./src/index.ts"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@robonen/primitives",
"version": "0.0.5",
"version": "0.0.2",
"license": "Apache-2.0",
"description": "Collection of UI primitives",
"keywords": [
@@ -14,9 +14,6 @@ import type { RovingDirection } from '../../internal/utils/roving-focus';
export type AccordionType = 'single' | 'multiple';
export interface AccordionRootProps extends PrimitiveProps {
/** Controlled open value(s). Bind with `v-model`. */
modelValue?: string | string[];
/** Initial value(s) for uncontrolled mode. */
defaultValue?: string | string[];
@@ -54,10 +51,6 @@ export interface AccordionRootProps extends PrimitiveProps {
/**
* Emit contract for `AccordionRoot`. The payload narrows with `Type`: a single
* accordion emits `string | undefined`, a multiple accordion emits `string[]`.
*
* The event itself is declared by `defineModel`: passing a model key through
* `defineEmits` as well erases its payload type from the generated
* declarations, leaving consumers with `unknown`.
*/
export interface AccordionRootEmits<Type extends AccordionType = AccordionType> {
'update:modelValue': [value: (Type extends 'single' ? string : string[]) | undefined];
@@ -86,6 +79,8 @@ const {
as = 'div',
} = defineProps<AccordionRootProps>();
defineEmits<AccordionRootEmits>();
defineSlots<{
default?: (props: {
/** Current open value(s): a `string | undefined` in single mode, `string[]` in multiple. */
+15 -25
View File
@@ -13,11 +13,11 @@ import type { TabsValue } from './context';
* via `defaultValue`), orientation, keyboard roving focus across triggers, and
* provides context to every `TabsList`, `TabsTrigger`, and `TabsContent`.
*/
export interface TabsRootProps<Value extends TabsValue = TabsValue> extends PrimitiveProps {
export interface TabsRootProps extends PrimitiveProps {
/** Controlled selected value. Bind with `v-model`. */
modelValue?: Value;
modelValue?: TabsValue;
/** Uncontrolled initial value. */
defaultValue?: Value;
defaultValue?: TabsValue;
/** Orientation of the tab list. @default 'horizontal' */
orientation?: 'horizontal' | 'vertical';
/**
@@ -40,14 +40,13 @@ export interface TabsRootProps<Value extends TabsValue = TabsValue> extends Prim
unmountOnHide?: boolean;
}
export interface TabsRootEmits<Value extends TabsValue = TabsValue> {
export interface TabsRootEmits {
/** Fired when the selected value changes. */
'update:modelValue': [value: Value];
'update:modelValue': [value: TabsValue | undefined];
}
</script>
<script setup lang="ts" generic="Value extends TabsValue = TabsValue">
import type { Ref } from 'vue';
<script setup lang="ts">
import { computed, ref, shallowRef, toRef } from 'vue';
import { resolveNextIndex, rovingKeyToAction } from '../../internal/utils/roving-focus';
import { useCollectionProvider } from '../../utilities/collection';
@@ -64,16 +63,15 @@ const {
activationMode = 'automatic',
unmountOnHide = true,
defaultValue,
modelValue,
as = 'div',
} = defineProps<TabsRootProps<Value>>();
} = defineProps<TabsRootProps>();
const emit = defineEmits<TabsRootEmits<Value>>();
defineEmits<TabsRootEmits>();
defineSlots<{
default?: (props: {
/** Current selected value. */
value: Value | undefined;
value: TabsValue | undefined;
}) => unknown;
}>();
@@ -81,24 +79,16 @@ const { forwardRef } = useForwardExpose();
const direction = useDirection(() => dir);
// `defineModel` would type `update:modelValue` as `TabsValue | undefined`,
// forcing every consumer's `v-model` target to accept `undefined` even though
// a tab is never deselected. The prop and the emit are declared separately so
// the emitted payload stays exactly `TabsValue` (see AGENTS §3.2.3).
const localValue = ref<Value | undefined>(defaultValue) as Ref<Value | undefined>;
const localValue = ref<TabsValue | undefined>(defaultValue);
const value = computed<Value | undefined>({
get: () => modelValue ?? localValue.value,
const value = defineModel<TabsValue | undefined>({
get: v => v ?? localValue.value,
set: (v) => {
localValue.value = v;
if (v !== undefined) emit('update:modelValue', v);
return v;
},
});
// The tab parts read and write plain `TabsValue`s through the context; the
// narrowed `Value` only exists to keep the consumer's `v-model` typed.
const contextValue = value as unknown as Ref<TabsValue | undefined>;
const baseId = useId(undefined, 'tabs');
const tabsListElement = shallowRef<HTMLElement>();
@@ -126,7 +116,7 @@ function unregisterContent(v: TabsValue): void {
function select(v: TabsValue): void {
if (disabled) return;
contextValue.value = v;
value.value = v;
}
// DOM-order tabs via Collection primitive — survives `v-for` reorders and
@@ -171,7 +161,7 @@ function onTriggerKeyDown(event: KeyboardEvent, el: HTMLElement): void {
}
provideTabsContext({
value: contextValue,
value,
// Identity passthroughs via `toRef` — reactive without `computed`'s effect/cache.
orientation: toRef(() => orientation),
direction,
@@ -66,11 +66,6 @@ export interface CalendarRootProps extends PrimitiveProps {
dateAdapter?: DateAdapter<Date>;
}
/**
* Emit contract for `CalendarRoot`. The model events are declared by `defineModel`:
* passing a model key through `defineEmits` as well erases its payload type
* from the generated declarations, leaving consumers with `unknown`.
*/
export interface CalendarRootEmits {
'update:modelValue': [date: Date | Date[] | undefined];
'update:placeholder': [date: Date];
@@ -111,6 +106,8 @@ const {
dateAdapter,
} = defineProps<CalendarRootProps>();
defineEmits<CalendarRootEmits>();
defineSlots<{
default?: (props: {
date: Date;
@@ -40,11 +40,6 @@ export interface DatePickerRootProps extends PrimitiveProps,
hourCycle?: HourCycle;
}
/**
* Emit contract for `DatePickerRoot`. The model events are declared by `defineModel`:
* passing a model key through `defineEmits` as well erases its payload type
* from the generated declarations, leaving consumers with `unknown`.
*/
export interface DatePickerRootEmits {
'update:modelValue': [date: Date | undefined];
'update:placeholder': [date: Date];
@@ -100,6 +95,8 @@ const {
dateAdapter,
} = defineProps<DatePickerRootProps>();
defineEmits<DatePickerRootEmits>();
const { forwardRef, currentElement: parentElement } = useForwardExpose();
// Resolve the effective date backend: per-instance prop wins over the global
@@ -37,11 +37,6 @@ export interface ProgressRootProps extends PrimitiveProps {
accessibleLabel?: string | ((value: number | null, max: number) => string | undefined);
}
/**
* Emit contract for `ProgressRoot`. The model events are declared by `defineModel`:
* passing a model key through `defineEmits` as well erases its payload type
* from the generated declarations, leaving consumers with `unknown`.
*/
export interface ProgressRootEmits {
/** Emitted when the value changes (after validation/clamping). */
'update:modelValue': [value: number | null];
@@ -64,6 +59,8 @@ const {
as = 'div',
} = defineProps<ProgressRootProps>();
defineEmits<ProgressRootEmits>();
const { forwardRef } = useForwardExpose();
const localValue = ref<number | null>(null);
+2 -5
View File
@@ -43,11 +43,6 @@ export interface SwitchProps<T = boolean> extends PrimitiveProps {
value?: string;
}
/**
* Emit contract for `Switch`. The model events are declared by `defineModel`:
* passing a model key through `defineEmits` as well erases its payload type
* from the generated declarations, leaving consumers with `unknown`.
*/
export interface SwitchEmits<T = boolean> {
/** Emitted whenever the value changes (also drives `v-model`). */
'update:modelValue': [value: T];
@@ -76,6 +71,8 @@ const {
as = 'button',
} = defineProps<SwitchProps<T>>();
defineEmits<SwitchEmits<T>>();
const { forwardRef, currentElement } = useForwardExpose();
const local = ref<T>((defaultValue ?? falsy) as T) as Ref<T>;
+3 -5
View File
@@ -4,11 +4,7 @@ import type { PrimitiveProps } from '../../internal/primitive';
/** Canonical `data-state` value reflected on the host element. */
export type ToggleState = 'on' | 'off';
/**
* Emit contract for `Toggle`. The model events are declared by `defineModel`:
* passing a model key through `defineEmits` as well erases its payload type
* from the generated declarations, leaving consumers with `unknown`.
*/
/** Events emitted by `Toggle`. */
export interface ToggleEmits {
/** Fired when the pressed state changes. Backs `v-model:pressed`. */
'update:pressed': [pressed: boolean];
@@ -62,6 +58,8 @@ const {
value = 'on',
} = defineProps<ToggleProps>();
defineEmits<ToggleEmits>();
const { forwardRef, currentElement } = useForwardExpose();
// A standalone Toggle nested inside a ToggleGroup must not also submit its own
@@ -4,35 +4,7 @@ import { renderSlotChild } from './Slot';
type FunctionalComponentContext = Omit<SetupContext, 'expose'>;
type Booleanish = boolean | 'true' | 'false';
/**
* Global DOM attributes any part accepts and forwards, through `$attrs`, to the
* element it renders. They are deliberately kept out of the runtime props (the
* `@vue-ignore` marker on the heritage clause below stops the SFC compiler from
* lifting them out of `$attrs`), so this only teaches `strictTemplates` that
* they are valid — the runtime behaviour is unchanged.
*/
export interface PrimitiveAttributes {
id?: string;
role?: string;
title?: string;
tabindex?: number | string;
lang?: string;
dir?: string;
hidden?: Booleanish | 'until-found' | '';
inert?: Booleanish;
autofocus?: Booleanish;
draggable?: Booleanish;
spellcheck?: Booleanish;
translate?: 'yes' | 'no';
nonce?: string;
part?: string;
slot?: string;
[key: `data-${string}` | `aria-${string}`]: unknown;
}
export interface PrimitiveProps extends /* @vue-ignore */ PrimitiveAttributes {
export interface PrimitiveProps {
as?: keyof IntrinsicElementAttributes | Component;
}
@@ -1,2 +1,2 @@
export { Primitive, type PrimitiveAttributes, type PrimitiveProps } from './Primitive';
export { Primitive, type PrimitiveProps } from './Primitive';
export { Slot } from './Slot';
@@ -38,11 +38,6 @@ export interface NavigationMenuRootProps extends PrimitiveProps {
unmountOnHide?: boolean;
}
/**
* Emit contract for `NavigationMenuRoot`. The model events are declared by `defineModel`:
* passing a model key through `defineEmits` as well erases its payload type
* from the generated declarations, leaving consumers with `unknown`.
*/
export interface NavigationMenuRootEmits {
'update:modelValue': [value: string];
}
@@ -75,6 +70,8 @@ const {
as = 'nav',
} = defineProps<NavigationMenuRootProps>();
defineEmits<NavigationMenuRootEmits>();
defineSlots<{
default?: (props: { modelValue: string }) => unknown;
}>();
@@ -15,11 +15,6 @@ export interface NavigationMenuSubProps extends PrimitiveProps {
orientation?: Orientation;
}
/**
* Emit contract for `NavigationMenuSub`. The model events are declared by `defineModel`:
* passing a model key through `defineEmits` as well erases its payload type
* from the generated declarations, leaving consumers with `unknown`.
*/
export interface NavigationMenuSubEmits {
'update:modelValue': [value: string];
}
@@ -40,6 +35,8 @@ defineOptions({ inheritAttrs: false });
const { defaultValue, orientation = 'horizontal', as = 'div' } = defineProps<NavigationMenuSubProps>();
defineEmits<NavigationMenuSubEmits>();
defineSlots<{
default?: (props: { modelValue: string }) => unknown;
}>();
@@ -44,14 +44,6 @@ export interface ToolbarRootEmits {
/** Backs `v-model:currentTabStopId`. */
'update:currentTabStopId': [value: string | null | undefined];
}
/**
* The subset `defineEmits` declares. `update:currentTabStopId` comes from
* `defineModel`; passing a model key through `defineEmits` as well erases its
* payload type from the generated declarations, leaving consumers with
* `unknown`.
*/
type ToolbarRootOwnEmits = Omit<ToolbarRootEmits, 'update:currentTabStopId'>;
</script>
<script setup lang="ts">
@@ -72,7 +64,7 @@ const {
as = 'div',
} = defineProps<ToolbarRootProps>();
const emit = defineEmits<ToolbarRootOwnEmits>();
const emit = defineEmits<ToolbarRootEmits>();
const { forwardRef } = useForwardExpose();
@@ -28,11 +28,6 @@ import { useSelectRootContext } from './context';
import SelectContentImpl from './SelectContentImpl.vue';
import SelectProvider from './SelectProvider.vue';
// Neither branch below is a single element root (`Presence` wraps the panel,
// the closed branch is a `Teleport`), so Vue cannot inherit `class`/`style` or
// any other attribute automatically they are forwarded onto the panel itself.
defineOptions({ inheritAttrs: false });
const props = defineProps<SelectContentProps>();
const emit = defineEmits<SelectContentEmits>();
const rootCtx = useSelectRootContext();
@@ -62,7 +57,7 @@ onMounted(() => {
:present="present"
>
<SelectContentImpl
v-bind="{ ...props, ...$attrs }"
v-bind="props"
@close-auto-focus="emit('closeAutoFocus', $event)"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@@ -63,11 +63,8 @@ const selectedItemTextRef = rootCtx.selectedItemTextRef;
const firstValidItemFoundRef = ref(false);
// Recompute the selected/first-valid item afresh for this open cycle. The text
// node is reset alongside it: the item-aligned positioner reads the two as a
// pair, so a stale text node would pair with a fresh item and skew placement.
// Recompute the selected/first-valid item afresh for this open cycle.
selectedItemRef.value = undefined;
selectedItemTextRef.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
@@ -47,46 +47,6 @@ const shouldExpandOnScrollRef = ref(false);
const shouldRepositionRef = ref(true);
const contentZIndex = ref('');
// When nothing is selected the content adopts the first valid item as the
// alignment anchor, but only that item is registered its text node registers
// solely for the *selected* value. Recover it from the item's own label
// association instead of demanding a second registration, which would mean
// writing to the anchor refs from inside the item's own tracking effect.
function itemTextOf(item: HTMLElement | undefined): HTMLElement | undefined {
const id = item?.getAttribute('aria-labelledby');
return id ? item?.ownerDocument.getElementById(id) ?? undefined : undefined;
}
/**
* Inline styles the wrapper is positioned with. Written as one object and
* committed in a single pass: every geometry read below happens before the
* first write, so the browser performs one layout for the whole placement
* instead of one per interleaved read.
*
* Both edges of each axis are always present. A resize can flip the vertical
* branch, and leaving the previous edge behind would over-constrain the box.
*/
interface WrapperPlacement {
minWidth: string;
left: string;
right: string;
top: string;
bottom: string;
height: string;
minHeight: string;
maxHeight: string;
margin: string;
}
const EMPTY_PLACEMENT: WrapperPlacement = {
minWidth: '', left: '', right: '', top: '', bottom: '',
height: '', minHeight: '', maxHeight: '', margin: '',
};
function commit(wrapper: HTMLElement, placement: Partial<WrapperPlacement>) {
Object.assign(wrapper.style, EMPTY_PLACEMENT, placement);
}
function position() {
const trigger = rootCtx.triggerElement.value;
const valueNode = rootCtx.valueElement.value;
@@ -94,61 +54,20 @@ function position() {
const content = contentElement.value;
const viewport = contentCtx.viewportRef.value;
const selectedItem = contentCtx.selectedItemRef.value;
const selectedItemText = contentCtx.selectedItemTextRef.value ?? itemTextOf(selectedItem);
const selectedItemText = contentCtx.selectedItemTextRef.value;
if (!trigger || !wrapper || !content || !viewport) {
if (!trigger || !valueNode || !wrapper || !content || !viewport || !selectedItem || !selectedItemText) {
emit('placed');
return;
}
// Item-aligned placement centres the panel on the selected item, so without
// one there is nothing to align to an empty option list, or items that have
// not registered yet. Drop the panel under the trigger instead of returning:
// the wrapper is `position: fixed`, so leaving it unplaced pins it to the
// viewport origin, where it reads as "the dropdown does not open".
if (!valueNode || !selectedItem || !selectedItemText) {
const rect = trigger.getBoundingClientRect();
const rightEdge = window.innerWidth - CONTENT_MARGIN;
commit(wrapper, {
minWidth: `${rect.width}px`,
left: `${clamp(rect.left, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, rightEdge - rect.width))}px`,
top: `${rect.bottom}px`,
maxHeight: `${Math.max(0, window.innerHeight - rect.bottom - CONTENT_MARGIN)}px`,
});
emit('placed');
return;
}
// --- Measure: every layout read lives here, before the first write ---
const triggerRect = trigger.getBoundingClientRect();
// --- Horizontal positioning ---
const contentRect = content.getBoundingClientRect();
const valueNodeRect = valueNode.getBoundingClientRect();
const itemTextRect = selectedItemText.getBoundingClientRect();
const items = Array.from(
viewport.querySelectorAll<HTMLElement>('[data-primitives-select-item]'),
);
const itemsHeight = viewport.scrollHeight;
const viewportOffsetTop = viewport.offsetTop;
const viewportOffsetHeight = viewport.offsetHeight;
const contentClientHeight = content.clientHeight;
const selectedItemHeight = selectedItem.offsetHeight;
const selectedItemOffsetTop = selectedItem.offsetTop;
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 viewportStyles = globalThis.getComputedStyle(viewport);
const viewportPaddingTop = Number.parseInt(viewportStyles.paddingTop, 10) || 0;
const viewportPaddingBottom = Number.parseInt(viewportStyles.paddingBottom, 10) || 0;
// --- Compute ---
const placement: Partial<WrapperPlacement> = {};
const availableHeight = window.innerHeight - CONTENT_MARGIN * 2;
if (rootCtx.dir.value !== 'rtl') {
const itemTextOffset = itemTextRect.left - contentRect.left;
const left = valueNodeRect.left - itemTextOffset;
@@ -156,9 +75,10 @@ function position() {
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));
placement.minWidth = `${minContentWidth}px`;
placement.left = `${clamp(left, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, rightEdge - contentWidth))}px`;
wrapper.style.minWidth = `${minContentWidth}px`;
wrapper.style.left = `${clampedLeft}px`;
}
else {
const itemTextOffset = contentRect.right - itemTextRect.right;
@@ -167,52 +87,67 @@ function position() {
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));
placement.minWidth = `${minContentWidth}px`;
placement.right = `${clamp(right, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, leftEdge - contentWidth))}px`;
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 = selectedItemHeight / 2;
const itemOffsetMiddle = selectedItemOffsetTop + selectedItemHalfHeight;
const selectedItemHalfHeight = selectedItem.offsetHeight / 2;
const itemOffsetMiddle = selectedItem.offsetTop + selectedItemHalfHeight;
const contentTopToItemMiddle = contentBorderTopWidth + contentPaddingTop + itemOffsetMiddle;
const itemMiddleToContentBottom = fullContentHeight - contentTopToItemMiddle;
let scrollTop: number | undefined;
const willAlignWithoutTopOverflow = contentTopToItemMiddle <= topEdgeToTriggerMiddle;
if (contentTopToItemMiddle <= topEdgeToTriggerMiddle) {
if (willAlignWithoutTopOverflow) {
const isLastItem = selectedItem === items.at(-1);
const viewportOffsetBottom = contentClientHeight - viewportOffsetTop - viewportOffsetHeight;
wrapper.style.bottom = '0px';
const viewportOffsetBottom = content.clientHeight - viewport.offsetTop - viewport.offsetHeight;
const clampedTriggerMiddleToBottomEdge = Math.max(
triggerMiddleToBottomEdge,
selectedItemHalfHeight + (isLastItem ? viewportPaddingBottom : 0) + viewportOffsetBottom + contentBorderBottomWidth,
);
placement.bottom = '0px';
placement.height = `${contentTopToItemMiddle + clampedTriggerMiddleToBottomEdge}px`;
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 + viewportOffsetTop + (isFirstItem ? viewportPaddingTop : 0) + selectedItemHalfHeight,
contentBorderTopWidth + viewport.offsetTop + (isFirstItem ? viewportPaddingTop : 0) + selectedItemHalfHeight,
);
placement.top = '0px';
placement.height = `${clampedTopEdgeToTriggerMiddle + itemMiddleToContentBottom}px`;
scrollTop = contentTopToItemMiddle - topEdgeToTriggerMiddle + viewportOffsetTop;
const height = clampedTopEdgeToTriggerMiddle + itemMiddleToContentBottom;
wrapper.style.height = `${height}px`;
viewport.scrollTop = contentTopToItemMiddle - topEdgeToTriggerMiddle + viewport.offsetTop;
}
placement.margin = `${CONTENT_MARGIN}px 0`;
placement.minHeight = `${Math.min(selectedItemHeight * 5, fullContentHeight)}px`;
placement.maxHeight = `${availableHeight}px`;
// --- Commit ---
commit(wrapper, placement);
if (scrollTop !== undefined) viewport.scrollTop = scrollTop;
wrapper.style.margin = `${CONTENT_MARGIN}px 0`;
wrapper.style.minHeight = `${minContentHeight}px`;
wrapper.style.maxHeight = `${availableHeight}px`;
emit('placed');
requestAnimationFrame(() => (shouldExpandOnScrollRef.value = true));
@@ -2,12 +2,6 @@
import type { Direction } from '../../utilities/config-provider';
import type { AcceptableValue } from './utils';
/**
* Shape of the select's model value: an array of `T` in multiple mode, a bare
* `T` otherwise. Keeps `v-model` narrow on both sides of the binding.
*/
export type SelectModelValue<T extends AcceptableValue, Multiple extends boolean> = Multiple extends true ? T[] : T;
/**
* A custom, fully stylable replacement for the native `<select>` element: a
* trigger button that opens a floating listbox of options, with full keyboard
@@ -22,9 +16,7 @@ export type SelectModelValue<T extends AcceptableValue, Multiple extends boolean
* (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, Multiple extends boolean = false> {
/** Controlled value. Bind with `v-model`. */
modelValue?: SelectModelValue<T, Multiple>;
export interface SelectRootProps<T extends AcceptableValue = AcceptableValue> {
/** Reading direction. Falls back to ConfigProvider. */
dir?: Direction;
/** Disable the whole select. */
@@ -34,11 +26,11 @@ export interface SelectRootProps<T extends AcceptableValue = AcceptableValue, Mu
/** Native input name for form submission. */
name?: string;
/** Uncontrolled default value. */
defaultValue?: SelectModelValue<T, Multiple>;
defaultValue?: T | T[];
/** Uncontrolled default open state. */
defaultOpen?: boolean;
/** Allow selecting multiple options; the model becomes an array. */
multiple?: Multiple;
multiple?: boolean;
/**
* Compare object values by a property key or a custom comparator. Omitted
* `===` for primitives / structural deep-equality for objects.
@@ -48,20 +40,13 @@ export interface SelectRootProps<T extends AcceptableValue = AcceptableValue, Mu
autocomplete?: string;
}
export interface SelectRootEmits<T extends AcceptableValue = AcceptableValue, Multiple extends boolean = false> {
'update:modelValue': [value: SelectModelValue<T, Multiple>];
export interface SelectRootEmits<T extends AcceptableValue = AcceptableValue> {
'update:modelValue': [value: T | T[] | undefined];
'update:open': [open: boolean];
}
/**
* The subset `defineEmits` declares. `update:open` comes from `defineModel`;
* passing a model key through `defineEmits` as well erases its payload type
* from the generated declarations, leaving consumers with `unknown`.
*/
type SelectRootOwnEmits<T extends AcceptableValue, Multiple extends boolean> = Omit<SelectRootEmits<T, Multiple>, 'update:open'>;
</script>
<script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue, Multiple extends boolean = false">
<script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue">
import type { Ref } from 'vue';
import { computed, ref, shallowRef, toRef, watch } from 'vue';
@@ -75,7 +60,6 @@ import { compare, shouldShowPlaceholder } from './utils';
defineOptions({ inheritAttrs: false });
const {
modelValue,
dir,
disabled = false,
required = false,
@@ -85,13 +69,11 @@ const {
multiple = false,
by,
autocomplete,
} = defineProps<SelectRootProps<T, Multiple>>();
const emit = defineEmits<SelectRootOwnEmits<T, Multiple>>();
} = defineProps<SelectRootProps<T>>();
defineSlots<{
default?: (props: {
modelValue: SelectModelValue<T, Multiple> | undefined;
modelValue: T | T[] | undefined;
open: boolean;
}) => unknown;
}>();
@@ -106,26 +88,16 @@ const open = defineModel<boolean>('open', {
},
});
type ModelValue = SelectModelValue<T, Multiple>;
// `defineModel` would type `update:modelValue` as `ModelValue | undefined`,
// forcing every consumer's `v-model` target to accept `undefined` even though
// a selection is never cleared. The prop and the emit are declared separately
// so the emitted payload stays exactly `ModelValue` (see AGENTS §3.2.3).
const localValue = ref(defaultValue ?? (multiple ? [] : undefined)) as Ref<ModelValue | undefined>;
const value = computed<ModelValue | undefined>({
get: () => modelValue ?? localValue.value,
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;
emit('update:modelValue', v as ModelValue);
return v;
},
});
// The public model type is conditional on `Multiple`, which TypeScript cannot
// narrow inside the component; the internal logic reads and writes the union
// through this widened alias instead.
const model = value as unknown as Ref<T | T[] | undefined>;
const contentId = useId(undefined, 'select-content');
const dirRef = toRef(() => dir);
const disabledRef = toRef(() => disabled);
@@ -147,7 +119,7 @@ const displayValue = ref<string | undefined>(undefined);
const rawOptions = new Set<SelectOption>();
const optionsSet = shallowRef(new Set<SelectOption>());
const isEmptyModelValue = computed(() => shouldShowPlaceholder(model.value));
const isEmptyModelValue = computed(() => shouldShowPlaceholder(value.value));
function getOptionFrom(source: Iterable<SelectOption>, v: AcceptableValue): SelectOption | undefined {
for (const option of source) {
@@ -171,8 +143,8 @@ function onOptionRemove(option: SelectOption) {
}
// Persist a single-value label for the legacy `displayValue` slot path.
watch([optionsSet, model], () => {
const current = model.value;
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;
@@ -180,21 +152,21 @@ watch([optionsSet, model], () => {
function handleValueChange(newValue: AcceptableValue) {
if (multiple) {
const array = Array.isArray(model.value) ? [...model.value] : [];
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);
model.value = [...array] as T[];
value.value = [...array] as T[];
}
else {
model.value = newValue as T;
value.value = newValue as T;
displayValue.value = getOptionFrom(rawOptions, newValue)?.textContent;
open.value = false;
}
}
function isSelectedValue(itemValue: AcceptableValue): boolean {
const current = model.value;
const current = value.value;
if (current === undefined) return false;
if (Array.isArray(current)) {
for (const v of current) {
@@ -225,7 +197,7 @@ const isFormControl = computed(() => {
});
provideSelectRootContext({
value: model,
value,
onValueChange: handleValueChange,
open,
onOpenChange: (v) => { open.value = v; },
@@ -265,7 +237,7 @@ provideSelectRootContext({
:disabled="disabled"
:multiple="multiple"
:options="nativeOptions"
:value="model"
:value="value"
@change="handleValueChange"
/>
@@ -273,7 +245,7 @@ provideSelectRootContext({
v-else-if="name"
type="hidden"
:name="name"
:value="Array.isArray(model) ? '' : (model ?? '')"
:value="Array.isArray(value) ? '' : (value ?? '')"
:required="required"
:disabled="disabled"
:autocomplete="autocomplete"
@@ -20,11 +20,11 @@ export interface SelectViewportProps extends PrimitiveProps {
<script setup lang="ts">
import { ref, toRef, watchPostEffect } from 'vue';
import { useForwardExpose, useStyleTag } from '@robonen/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, VIEWPORT_SCROLLBAR_CSS } from './utils';
import { CONTENT_MARGIN } from './utils';
const { as = 'div', nonce: propNonce } = defineProps<SelectViewportProps>();
@@ -32,11 +32,6 @@ const { forwardRef, currentElement } = useForwardExpose();
const contentCtx = useSelectContentContext();
const nonce = useNonce(toRef(() => propNonce));
// Injected into `<head>` (one reference-counted tag per document) rather than
// rendered as a sibling `<style>`: a second root node would turn this component
// into a fragment, and Vue cannot inherit a consumer's `class` onto a fragment.
useStyleTag(VIEWPORT_SCROLLBAR_CSS, { id: 'primitives-select-viewport', nonce: nonce.value });
const alignedCtx = contentCtx.position === 'item-aligned'
? useSelectItemAlignedPositionContext(null as never)
: undefined;
@@ -87,4 +82,8 @@ function handleScroll(event: Event) {
>
<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>
@@ -385,133 +385,3 @@ describe('Select — native form submission', () => {
w.unmount();
});
});
describe('Select — attribute forwarding on the panel', () => {
function mountStyled() {
return track(mount(
defineComponent({
setup() {
return () => h(
SelectRoot,
{ defaultOpen: true },
{
default: () => [
h(SelectTrigger, { id: 'styled-trigger', 'aria-label': 'Fruit' }, {
default: () => h(SelectValue, { placeholder: 'Pick one' }),
}),
h(SelectPortal, null, {
default: () => h(SelectContent, { class: 'panel', 'data-panel': 'yes' }, {
default: () => h(SelectViewport, { class: 'viewport' }, {
default: () => h(SelectItem, { value: 'apple' }, {
default: () => h(SelectItemText, null, { default: () => 'Apple' }),
}),
}),
}),
}),
],
},
);
},
}),
{ attachTo: document.body },
));
}
it('forwards class and data attributes from SelectContent to the panel element', async () => {
const w = mountStyled();
await flush();
const panel = document.querySelector('[data-primitives-select-content]') as HTMLElement | null;
expect(panel).toBeTruthy();
expect(panel!.classList.contains('panel')).toBe(true);
expect(panel!.getAttribute('data-panel')).toBe('yes');
w.unmount();
});
it('forwards class from SelectViewport to the viewport element', async () => {
const w = mountStyled();
await flush();
const viewport = document.querySelector('[data-primitives-select-viewport]') as HTMLElement | null;
expect(viewport).toBeTruthy();
expect(viewport!.classList.contains('viewport')).toBe(true);
w.unmount();
});
it('keeps the trigger a single root that accepts native attributes', async () => {
const w = mountStyled();
await flush();
const trigger = getTrigger();
expect(trigger.id).toBe('styled-trigger');
expect(trigger.getAttribute('aria-label')).toBe('Fruit');
w.unmount();
});
it('injects the scrollbar-hiding stylesheet into head instead of a sibling style node', async () => {
const w = mountStyled();
await flush();
const injected = document.head.querySelector('#primitives-select-viewport');
expect(injected).toBeTruthy();
expect(injected!.textContent).toContain('[data-primitives-select-viewport]');
const panel = document.querySelector('[data-primitives-select-content]') as HTMLElement;
expect(panel.querySelector('style')).toBeNull();
w.unmount();
});
});
describe('Select — panel placement without a selection', () => {
function mountUnmatched(options: Opt[]) {
return track(mount(
defineComponent({
setup() {
// A model value that matches no option — a stale id, a deleted user,
// a directory that has not loaded yet.
return () => h(
SelectRoot,
{ defaultOpen: true, modelValue: 'gone' as never },
{
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 }, {
default: () => h(SelectItemText, null, { default: () => opt.label }),
}),
),
}),
}),
}),
],
},
);
},
}),
{ attachTo: document.body },
));
}
it('aligns on the first valid item when the model matches nothing', async () => {
const w = mountUnmatched([{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }]);
await flush();
const wrapper = document.querySelector('[data-primitives-select-content-wrapper]') as HTMLElement | null;
expect(wrapper).toBeTruthy();
// Item-aligned placement sets all three; bailing out leaves them empty and
// the fixed wrapper pinned to the viewport origin.
expect(wrapper!.style.minWidth).not.toBe('');
expect(wrapper!.style.height).not.toBe('');
expect(wrapper!.style.left || wrapper!.style.right).not.toBe('');
w.unmount();
});
it('places the panel instead of leaving it pinned to the viewport origin', async () => {
const w = mountUnmatched([]);
await flush();
const wrapper = document.querySelector('[data-primitives-select-content-wrapper]') as HTMLElement | null;
expect(wrapper).toBeTruthy();
// With no items at all there is nothing to align to; the fallback still has
// to give the wrapper explicit coordinates.
expect(wrapper!.style.top).not.toBe('');
expect(wrapper!.style.left).not.toBe('');
w.unmount();
});
});
@@ -4,6 +4,13 @@ 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;
+2 -1
View File
@@ -29,6 +29,7 @@ export {
} from './context';
export type {
SelectValue,
SelectOption,
SelectRootContext,
SelectContentContext,
@@ -37,7 +38,7 @@ export type {
SelectItemContext,
} from './context';
export type { AcceptableValue as SelectAcceptableValue } from './utils';
export type { SelectModelValue, SelectRootProps, SelectRootEmits } from './SelectRoot.vue';
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';
@@ -9,11 +9,6 @@ export const OPEN_KEYS = [' ', 'Enter', 'ArrowUp', 'ArrowDown'];
export const SELECTION_KEYS = [' ', 'Enter'];
export const CONTENT_MARGIN = 10;
/** Hides the viewport's scrollbar across engines while keeping it scrollable. */
export const VIEWPORT_SCROLLBAR_CSS
= '[data-primitives-select-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}'
+ '[data-primitives-select-viewport]::-webkit-scrollbar{display:none;}';
export function getOpenState(open: boolean): 'open' | 'closed' {
return open ? 'open' : 'closed';
}
@@ -45,14 +45,6 @@ export interface RovingFocusGroupEmits {
'update:currentTabStopId': [value: string | null | undefined];
}
/**
* The subset `defineEmits` declares. `update:currentTabStopId` comes from
* `defineModel`; passing a model key through `defineEmits` as well erases its
* payload type from the generated declarations, leaving consumers with
* `unknown`.
*/
type RovingFocusGroupOwnEmits = Omit<RovingFocusGroupEmits, 'update:currentTabStopId'>;
export interface RovingFocusGroupContext {
orientation: Ref<Orientation | undefined>;
dir: Ref<Direction>;
@@ -85,7 +77,7 @@ const {
as = 'div',
} = defineProps<RovingFocusGroupProps>();
const emit = defineEmits<RovingFocusGroupOwnEmits>();
const emit = defineEmits<RovingFocusGroupEmits>();
const config = useConfig();
// `dir` falls back to the provider's configured direction when not given as prop.