3 Commits

Author SHA1 Message Date
robonen ea96d720f2 perf(primitives): place the select panel in one layout pass
Publish to NPM / Check version changes and publish (push) Successful in 9m38s
`position()` interleaved geometry reads with inline-style writes: the horizontal
branch wrote `minWidth`/`left` and the vertical branch then read `scrollHeight`,
`getComputedStyle`, `offsetHeight` and `offsetTop`, and a second write of
`bottom` was followed by a read of `clientHeight`. Each read after a write
forces a synchronous layout, so a single placement cost at least two — and
placement runs on mount, on resize and while scrolling an expanding panel.

Split it into measure, compute and commit: every read now happens before the
first write, and the result is applied as one object rather than nine separate
property assignments.

The commit also always writes both edges of each axis. A resize can flip the
vertical branch from `bottom` to `top`, and the previous code left the old edge
in place, over-constraining the box.

No behavioural change — the arithmetic is untouched, and the placement tests
pass unchanged.
2026-08-10 04:37:43 +07:00
robonen 6da5ecaa83 fix(primitives): place the select panel when nothing is selected
Publish to NPM / Check version changes and publish (push) Has been cancelled
Item-aligned placement centres the panel on the selected item, and `position()`
returned early unless both the item and its text node were known. The content
already adopts the first valid item as the anchor when the model matches no
option, but only the item is registered — the text node registers solely for the
selected value, so the pair was never complete and the early return fired. The
wrapper is `position: fixed`, so it stayed at the viewport origin: to a user the
dropdown simply does not open.

This is not an edge case. A stale id, a deleted record or a directory that has
not finished loading all leave the model unmatched, and the whole select then
looks broken rather than merely unlabelled.

Recover the text node from the item's own `aria-labelledby` instead of adding a
second registration — writing the anchor refs from inside the item's tracking
effect closes a reactive cycle ("Maximum recursive updates exceeded"). Reset the
text ref alongside the item ref per open cycle so a stale node cannot pair with
a fresh item. Finally, keep the guard from ever stranding the panel again: with
no anchors at all — an empty option list — fall back to a plain trigger-aligned
drop instead of returning with the wrapper unplaced.

Both paths are covered by browser tests that fail on the previous code.
2026-08-10 04:30:24 +07:00
robonen 1d2130f279 fix(primitives): make v-model, native attributes and panel styling usable under strict TS
Publish to NPM / Check version changes and publish (push) Successful in 11m33s
Consuming the package under `verbatimModuleSyntax` + `strictTemplates` surfaced
four defects that forced workarounds downstream.

- Drop the deprecated `SelectValue` string alias. It collided with the
  `SelectValue` component exported from the same barrel, so the component
  resolved to the type meaning and could not be imported (TS1484).

- Narrow the select's model to `SelectModelValue<T, Multiple>` and make
  `TabsRoot` generic over its value, so a plain `v-model` on a `Ref<string>`
  type-checks. Both roots declare the value prop and emit explicitly instead of
  via `defineModel`, which would widen the payload with `| undefined` even
  though neither control ever clears its value.

- Stop declaring `defineModel` keys in `defineEmits` as well. The duplicate
  erased the payload type from the generated declarations, shipping
  `(...args: unknown[]) => any` for eleven components' model events.

- Let every part accept global DOM attributes (`id`, `role`, `aria-*`,
  `data-*`, ...) through `PrimitiveAttributes`. The heritage clause is marked
  `@vue-ignore`, so they stay out of the runtime props and keep falling through
  via `$attrs` exactly as before.

- Give `SelectContent` and `SelectViewport` a single styleable root: the
  content forwards `$attrs` onto the panel, and the viewport's scrollbar CSS
  moves to a reference-counted `<head>` style tag. A forwarded `class` was
  previously dropped, leaving the panel unstyled.

The tsconfig vue preset gains `htmlAttributes: ["aria-*", "data-*"]` so
hyphenated data attributes are not camelized before they reach those types.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:14:44 +07:00
27 changed files with 433 additions and 124 deletions
+1 -1
View File
@@ -67,7 +67,7 @@
"tsdown": "catalog:" "tsdown": "catalog:"
}, },
"peerDependencies": { "peerDependencies": {
"eslint": ">=10.8.1" "eslint": ">=9.39.4"
}, },
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@robonen/tsconfig", "name": "@robonen/tsconfig",
"version": "0.1.0", "version": "0.1.1",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "Base typescript configuration for projects", "description": "Base typescript configuration for projects",
"keywords": [ "keywords": [
+1
View File
@@ -8,6 +8,7 @@
"vueCompilerOptions": { "vueCompilerOptions": {
"strictTemplates": true, "strictTemplates": true,
"fallthroughAttributes": true, "fallthroughAttributes": true,
"htmlAttributes": ["aria-*", "data-*"],
"inferTemplateDollarAttrs": true, "inferTemplateDollarAttrs": true,
"inferTemplateDollarEl": true, "inferTemplateDollarEl": true,
"inferTemplateDollarRefs": true "inferTemplateDollarRefs": true
+1 -1
View File
@@ -2,6 +2,6 @@
"$schema": "https://jsr.io/schema/config-file.v1.json", "$schema": "https://jsr.io/schema/config-file.v1.json",
"name": "@robonen/primitives", "name": "@robonen/primitives",
"license": "Apache-2.0", "license": "Apache-2.0",
"version": "0.0.2", "version": "0.0.5",
"exports": "./src/index.ts" "exports": "./src/index.ts"
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@robonen/primitives", "name": "@robonen/primitives",
"version": "0.0.2", "version": "0.0.5",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "Collection of UI primitives", "description": "Collection of UI primitives",
"keywords": [ "keywords": [
@@ -14,6 +14,9 @@ import type { RovingDirection } from '../../internal/utils/roving-focus';
export type AccordionType = 'single' | 'multiple'; export type AccordionType = 'single' | 'multiple';
export interface AccordionRootProps extends PrimitiveProps { export interface AccordionRootProps extends PrimitiveProps {
/** Controlled open value(s). Bind with `v-model`. */
modelValue?: string | string[];
/** Initial value(s) for uncontrolled mode. */ /** Initial value(s) for uncontrolled mode. */
defaultValue?: string | string[]; defaultValue?: string | string[];
@@ -51,6 +54,10 @@ export interface AccordionRootProps extends PrimitiveProps {
/** /**
* Emit contract for `AccordionRoot`. The payload narrows with `Type`: a single * Emit contract for `AccordionRoot`. The payload narrows with `Type`: a single
* accordion emits `string | undefined`, a multiple accordion emits `string[]`. * 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> { export interface AccordionRootEmits<Type extends AccordionType = AccordionType> {
'update:modelValue': [value: (Type extends 'single' ? string : string[]) | undefined]; 'update:modelValue': [value: (Type extends 'single' ? string : string[]) | undefined];
@@ -79,8 +86,6 @@ const {
as = 'div', as = 'div',
} = defineProps<AccordionRootProps>(); } = defineProps<AccordionRootProps>();
defineEmits<AccordionRootEmits>();
defineSlots<{ defineSlots<{
default?: (props: { default?: (props: {
/** Current open value(s): a `string | undefined` in single mode, `string[]` in multiple. */ /** Current open value(s): a `string | undefined` in single mode, `string[]` in multiple. */
+25 -15
View File
@@ -13,11 +13,11 @@ import type { TabsValue } from './context';
* via `defaultValue`), orientation, keyboard roving focus across triggers, and * via `defaultValue`), orientation, keyboard roving focus across triggers, and
* provides context to every `TabsList`, `TabsTrigger`, and `TabsContent`. * provides context to every `TabsList`, `TabsTrigger`, and `TabsContent`.
*/ */
export interface TabsRootProps extends PrimitiveProps { export interface TabsRootProps<Value extends TabsValue = TabsValue> extends PrimitiveProps {
/** Controlled selected value. Bind with `v-model`. */ /** Controlled selected value. Bind with `v-model`. */
modelValue?: TabsValue; modelValue?: Value;
/** Uncontrolled initial value. */ /** Uncontrolled initial value. */
defaultValue?: TabsValue; defaultValue?: Value;
/** Orientation of the tab list. @default 'horizontal' */ /** Orientation of the tab list. @default 'horizontal' */
orientation?: 'horizontal' | 'vertical'; orientation?: 'horizontal' | 'vertical';
/** /**
@@ -40,13 +40,14 @@ export interface TabsRootProps extends PrimitiveProps {
unmountOnHide?: boolean; unmountOnHide?: boolean;
} }
export interface TabsRootEmits { export interface TabsRootEmits<Value extends TabsValue = TabsValue> {
/** Fired when the selected value changes. */ /** Fired when the selected value changes. */
'update:modelValue': [value: TabsValue | undefined]; 'update:modelValue': [value: Value];
} }
</script> </script>
<script setup lang="ts"> <script setup lang="ts" generic="Value extends TabsValue = TabsValue">
import type { Ref } from 'vue';
import { computed, ref, shallowRef, toRef } from 'vue'; import { computed, ref, shallowRef, toRef } from 'vue';
import { resolveNextIndex, rovingKeyToAction } from '../../internal/utils/roving-focus'; import { resolveNextIndex, rovingKeyToAction } from '../../internal/utils/roving-focus';
import { useCollectionProvider } from '../../utilities/collection'; import { useCollectionProvider } from '../../utilities/collection';
@@ -63,15 +64,16 @@ const {
activationMode = 'automatic', activationMode = 'automatic',
unmountOnHide = true, unmountOnHide = true,
defaultValue, defaultValue,
modelValue,
as = 'div', as = 'div',
} = defineProps<TabsRootProps>(); } = defineProps<TabsRootProps<Value>>();
defineEmits<TabsRootEmits>(); const emit = defineEmits<TabsRootEmits<Value>>();
defineSlots<{ defineSlots<{
default?: (props: { default?: (props: {
/** Current selected value. */ /** Current selected value. */
value: TabsValue | undefined; value: Value | undefined;
}) => unknown; }) => unknown;
}>(); }>();
@@ -79,16 +81,24 @@ const { forwardRef } = useForwardExpose();
const direction = useDirection(() => dir); const direction = useDirection(() => dir);
const localValue = ref<TabsValue | undefined>(defaultValue); // `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 value = defineModel<TabsValue | undefined>({ const value = computed<Value | undefined>({
get: v => v ?? localValue.value, get: () => modelValue ?? localValue.value,
set: (v) => { set: (v) => {
localValue.value = v; localValue.value = v;
return v; if (v !== undefined) emit('update:modelValue', 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 baseId = useId(undefined, 'tabs');
const tabsListElement = shallowRef<HTMLElement>(); const tabsListElement = shallowRef<HTMLElement>();
@@ -116,7 +126,7 @@ function unregisterContent(v: TabsValue): void {
function select(v: TabsValue): void { function select(v: TabsValue): void {
if (disabled) return; if (disabled) return;
value.value = v; contextValue.value = v;
} }
// DOM-order tabs via Collection primitive — survives `v-for` reorders and // DOM-order tabs via Collection primitive — survives `v-for` reorders and
@@ -161,7 +171,7 @@ function onTriggerKeyDown(event: KeyboardEvent, el: HTMLElement): void {
} }
provideTabsContext({ provideTabsContext({
value, value: contextValue,
// Identity passthroughs via `toRef` — reactive without `computed`'s effect/cache. // Identity passthroughs via `toRef` — reactive without `computed`'s effect/cache.
orientation: toRef(() => orientation), orientation: toRef(() => orientation),
direction, direction,
@@ -66,6 +66,11 @@ export interface CalendarRootProps extends PrimitiveProps {
dateAdapter?: DateAdapter<Date>; 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 { export interface CalendarRootEmits {
'update:modelValue': [date: Date | Date[] | undefined]; 'update:modelValue': [date: Date | Date[] | undefined];
'update:placeholder': [date: Date]; 'update:placeholder': [date: Date];
@@ -106,8 +111,6 @@ const {
dateAdapter, dateAdapter,
} = defineProps<CalendarRootProps>(); } = defineProps<CalendarRootProps>();
defineEmits<CalendarRootEmits>();
defineSlots<{ defineSlots<{
default?: (props: { default?: (props: {
date: Date; date: Date;
@@ -40,6 +40,11 @@ export interface DatePickerRootProps extends PrimitiveProps,
hourCycle?: HourCycle; 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 { export interface DatePickerRootEmits {
'update:modelValue': [date: Date | undefined]; 'update:modelValue': [date: Date | undefined];
'update:placeholder': [date: Date]; 'update:placeholder': [date: Date];
@@ -95,8 +100,6 @@ const {
dateAdapter, dateAdapter,
} = defineProps<DatePickerRootProps>(); } = defineProps<DatePickerRootProps>();
defineEmits<DatePickerRootEmits>();
const { forwardRef, currentElement: parentElement } = useForwardExpose(); const { forwardRef, currentElement: parentElement } = useForwardExpose();
// Resolve the effective date backend: per-instance prop wins over the global // Resolve the effective date backend: per-instance prop wins over the global
@@ -37,6 +37,11 @@ export interface ProgressRootProps extends PrimitiveProps {
accessibleLabel?: string | ((value: number | null, max: number) => string | undefined); 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 { export interface ProgressRootEmits {
/** Emitted when the value changes (after validation/clamping). */ /** Emitted when the value changes (after validation/clamping). */
'update:modelValue': [value: number | null]; 'update:modelValue': [value: number | null];
@@ -59,8 +64,6 @@ const {
as = 'div', as = 'div',
} = defineProps<ProgressRootProps>(); } = defineProps<ProgressRootProps>();
defineEmits<ProgressRootEmits>();
const { forwardRef } = useForwardExpose(); const { forwardRef } = useForwardExpose();
const localValue = ref<number | null>(null); const localValue = ref<number | null>(null);
+5 -2
View File
@@ -43,6 +43,11 @@ export interface SwitchProps<T = boolean> extends PrimitiveProps {
value?: string; 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> { export interface SwitchEmits<T = boolean> {
/** Emitted whenever the value changes (also drives `v-model`). */ /** Emitted whenever the value changes (also drives `v-model`). */
'update:modelValue': [value: T]; 'update:modelValue': [value: T];
@@ -71,8 +76,6 @@ const {
as = 'button', as = 'button',
} = defineProps<SwitchProps<T>>(); } = defineProps<SwitchProps<T>>();
defineEmits<SwitchEmits<T>>();
const { forwardRef, currentElement } = useForwardExpose(); const { forwardRef, currentElement } = useForwardExpose();
const local = ref<T>((defaultValue ?? falsy) as T) as Ref<T>; const local = ref<T>((defaultValue ?? falsy) as T) as Ref<T>;
+5 -3
View File
@@ -4,7 +4,11 @@ import type { PrimitiveProps } from '../../internal/primitive';
/** Canonical `data-state` value reflected on the host element. */ /** Canonical `data-state` value reflected on the host element. */
export type ToggleState = 'on' | 'off'; export type ToggleState = 'on' | 'off';
/** Events emitted by `Toggle`. */ /**
* 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`.
*/
export interface ToggleEmits { export interface ToggleEmits {
/** Fired when the pressed state changes. Backs `v-model:pressed`. */ /** Fired when the pressed state changes. Backs `v-model:pressed`. */
'update:pressed': [pressed: boolean]; 'update:pressed': [pressed: boolean];
@@ -58,8 +62,6 @@ const {
value = 'on', value = 'on',
} = defineProps<ToggleProps>(); } = defineProps<ToggleProps>();
defineEmits<ToggleEmits>();
const { forwardRef, currentElement } = useForwardExpose(); const { forwardRef, currentElement } = useForwardExpose();
// A standalone Toggle nested inside a ToggleGroup must not also submit its own // A standalone Toggle nested inside a ToggleGroup must not also submit its own
@@ -4,7 +4,35 @@ import { renderSlotChild } from './Slot';
type FunctionalComponentContext = Omit<SetupContext, 'expose'>; type FunctionalComponentContext = Omit<SetupContext, 'expose'>;
export interface PrimitiveProps { 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 {
as?: keyof IntrinsicElementAttributes | Component; as?: keyof IntrinsicElementAttributes | Component;
} }
@@ -1,2 +1,2 @@
export { Primitive, type PrimitiveProps } from './Primitive'; export { Primitive, type PrimitiveAttributes, type PrimitiveProps } from './Primitive';
export { Slot } from './Slot'; export { Slot } from './Slot';
@@ -38,6 +38,11 @@ export interface NavigationMenuRootProps extends PrimitiveProps {
unmountOnHide?: boolean; 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 { export interface NavigationMenuRootEmits {
'update:modelValue': [value: string]; 'update:modelValue': [value: string];
} }
@@ -70,8 +75,6 @@ const {
as = 'nav', as = 'nav',
} = defineProps<NavigationMenuRootProps>(); } = defineProps<NavigationMenuRootProps>();
defineEmits<NavigationMenuRootEmits>();
defineSlots<{ defineSlots<{
default?: (props: { modelValue: string }) => unknown; default?: (props: { modelValue: string }) => unknown;
}>(); }>();
@@ -15,6 +15,11 @@ export interface NavigationMenuSubProps extends PrimitiveProps {
orientation?: Orientation; 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 { export interface NavigationMenuSubEmits {
'update:modelValue': [value: string]; 'update:modelValue': [value: string];
} }
@@ -35,8 +40,6 @@ defineOptions({ inheritAttrs: false });
const { defaultValue, orientation = 'horizontal', as = 'div' } = defineProps<NavigationMenuSubProps>(); const { defaultValue, orientation = 'horizontal', as = 'div' } = defineProps<NavigationMenuSubProps>();
defineEmits<NavigationMenuSubEmits>();
defineSlots<{ defineSlots<{
default?: (props: { modelValue: string }) => unknown; default?: (props: { modelValue: string }) => unknown;
}>(); }>();
@@ -44,6 +44,14 @@ export interface ToolbarRootEmits {
/** Backs `v-model:currentTabStopId`. */ /** Backs `v-model:currentTabStopId`. */
'update:currentTabStopId': [value: string | null | undefined]; '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>
<script setup lang="ts"> <script setup lang="ts">
@@ -64,7 +72,7 @@ const {
as = 'div', as = 'div',
} = defineProps<ToolbarRootProps>(); } = defineProps<ToolbarRootProps>();
const emit = defineEmits<ToolbarRootEmits>(); const emit = defineEmits<ToolbarRootOwnEmits>();
const { forwardRef } = useForwardExpose(); const { forwardRef } = useForwardExpose();
@@ -28,6 +28,11 @@ import { useSelectRootContext } from './context';
import SelectContentImpl from './SelectContentImpl.vue'; import SelectContentImpl from './SelectContentImpl.vue';
import SelectProvider from './SelectProvider.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 props = defineProps<SelectContentProps>();
const emit = defineEmits<SelectContentEmits>(); const emit = defineEmits<SelectContentEmits>();
const rootCtx = useSelectRootContext(); const rootCtx = useSelectRootContext();
@@ -57,7 +62,7 @@ onMounted(() => {
:present="present" :present="present"
> >
<SelectContentImpl <SelectContentImpl
v-bind="props" v-bind="{ ...props, ...$attrs }"
@close-auto-focus="emit('closeAutoFocus', $event)" @close-auto-focus="emit('closeAutoFocus', $event)"
@escape-key-down="emit('escapeKeyDown', $event)" @escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)" @pointer-down-outside="emit('pointerDownOutside', $event)"
@@ -63,8 +63,11 @@ const selectedItemTextRef = rootCtx.selectedItemTextRef;
const firstValidItemFoundRef = ref(false); const firstValidItemFoundRef = ref(false);
// Recompute the selected/first-valid item afresh for this open cycle. // 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.
selectedItemRef.value = undefined; selectedItemRef.value = undefined;
selectedItemTextRef.value = undefined;
// Resolve the actual listbox content element. The item-aligned strategy renders // Resolve the actual listbox content element. The item-aligned strategy renders
// a positioning wrapper whose first child is the listbox; the popper strategy // a positioning wrapper whose first child is the listbox; the popper strategy
@@ -47,6 +47,46 @@ const shouldExpandOnScrollRef = ref(false);
const shouldRepositionRef = ref(true); const shouldRepositionRef = ref(true);
const contentZIndex = ref(''); 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() { function position() {
const trigger = rootCtx.triggerElement.value; const trigger = rootCtx.triggerElement.value;
const valueNode = rootCtx.valueElement.value; const valueNode = rootCtx.valueElement.value;
@@ -54,20 +94,61 @@ function position() {
const content = contentElement.value; const content = contentElement.value;
const viewport = contentCtx.viewportRef.value; const viewport = contentCtx.viewportRef.value;
const selectedItem = contentCtx.selectedItemRef.value; const selectedItem = contentCtx.selectedItemRef.value;
const selectedItemText = contentCtx.selectedItemTextRef.value; const selectedItemText = contentCtx.selectedItemTextRef.value ?? itemTextOf(selectedItem);
if (!trigger || !valueNode || !wrapper || !content || !viewport || !selectedItem || !selectedItemText) { if (!trigger || !wrapper || !content || !viewport) {
emit('placed'); emit('placed');
return; return;
} }
const triggerRect = trigger.getBoundingClientRect(); // 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;
}
// --- Horizontal positioning --- // --- Measure: every layout read lives here, before the first write ---
const triggerRect = trigger.getBoundingClientRect();
const contentRect = content.getBoundingClientRect(); const contentRect = content.getBoundingClientRect();
const valueNodeRect = valueNode.getBoundingClientRect(); const valueNodeRect = valueNode.getBoundingClientRect();
const itemTextRect = selectedItemText.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') { if (rootCtx.dir.value !== 'rtl') {
const itemTextOffset = itemTextRect.left - contentRect.left; const itemTextOffset = itemTextRect.left - contentRect.left;
const left = valueNodeRect.left - itemTextOffset; const left = valueNodeRect.left - itemTextOffset;
@@ -75,10 +156,9 @@ function position() {
const minContentWidth = triggerRect.width + leftDelta; const minContentWidth = triggerRect.width + leftDelta;
const contentWidth = Math.max(minContentWidth, contentRect.width); const contentWidth = Math.max(minContentWidth, contentRect.width);
const rightEdge = window.innerWidth - CONTENT_MARGIN; const rightEdge = window.innerWidth - CONTENT_MARGIN;
const clampedLeft = clamp(left, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, rightEdge - contentWidth));
wrapper.style.minWidth = `${minContentWidth}px`; placement.minWidth = `${minContentWidth}px`;
wrapper.style.left = `${clampedLeft}px`; placement.left = `${clamp(left, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, rightEdge - contentWidth))}px`;
} }
else { else {
const itemTextOffset = contentRect.right - itemTextRect.right; const itemTextOffset = contentRect.right - itemTextRect.right;
@@ -87,67 +167,52 @@ function position() {
const minContentWidth = triggerRect.width + rightDelta; const minContentWidth = triggerRect.width + rightDelta;
const contentWidth = Math.max(minContentWidth, contentRect.width); const contentWidth = Math.max(minContentWidth, contentRect.width);
const leftEdge = window.innerWidth - CONTENT_MARGIN; const leftEdge = window.innerWidth - CONTENT_MARGIN;
const clampedRight = clamp(right, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, leftEdge - contentWidth));
wrapper.style.minWidth = `${minContentWidth}px`; placement.minWidth = `${minContentWidth}px`;
wrapper.style.right = `${clampedRight}px`; placement.right = `${clamp(right, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, leftEdge - contentWidth))}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 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 topEdgeToTriggerMiddle = triggerRect.top + triggerRect.height / 2 - CONTENT_MARGIN;
const triggerMiddleToBottomEdge = availableHeight - topEdgeToTriggerMiddle; const triggerMiddleToBottomEdge = availableHeight - topEdgeToTriggerMiddle;
const selectedItemHalfHeight = selectedItem.offsetHeight / 2; const selectedItemHalfHeight = selectedItemHeight / 2;
const itemOffsetMiddle = selectedItem.offsetTop + selectedItemHalfHeight; const itemOffsetMiddle = selectedItemOffsetTop + selectedItemHalfHeight;
const contentTopToItemMiddle = contentBorderTopWidth + contentPaddingTop + itemOffsetMiddle; const contentTopToItemMiddle = contentBorderTopWidth + contentPaddingTop + itemOffsetMiddle;
const itemMiddleToContentBottom = fullContentHeight - contentTopToItemMiddle; const itemMiddleToContentBottom = fullContentHeight - contentTopToItemMiddle;
const willAlignWithoutTopOverflow = contentTopToItemMiddle <= topEdgeToTriggerMiddle; let scrollTop: number | undefined;
if (willAlignWithoutTopOverflow) { if (contentTopToItemMiddle <= topEdgeToTriggerMiddle) {
const isLastItem = selectedItem === items.at(-1); const isLastItem = selectedItem === items.at(-1);
wrapper.style.bottom = '0px'; const viewportOffsetBottom = contentClientHeight - viewportOffsetTop - viewportOffsetHeight;
const viewportOffsetBottom = content.clientHeight - viewport.offsetTop - viewport.offsetHeight;
const clampedTriggerMiddleToBottomEdge = Math.max( const clampedTriggerMiddleToBottomEdge = Math.max(
triggerMiddleToBottomEdge, triggerMiddleToBottomEdge,
selectedItemHalfHeight + (isLastItem ? viewportPaddingBottom : 0) + viewportOffsetBottom + contentBorderBottomWidth, selectedItemHalfHeight + (isLastItem ? viewportPaddingBottom : 0) + viewportOffsetBottom + contentBorderBottomWidth,
); );
const height = contentTopToItemMiddle + clampedTriggerMiddleToBottomEdge;
wrapper.style.height = `${height}px`; placement.bottom = '0px';
placement.height = `${contentTopToItemMiddle + clampedTriggerMiddleToBottomEdge}px`;
} }
else { else {
const isFirstItem = selectedItem === items[0]; const isFirstItem = selectedItem === items[0];
wrapper.style.top = '0px';
const clampedTopEdgeToTriggerMiddle = Math.max( const clampedTopEdgeToTriggerMiddle = Math.max(
topEdgeToTriggerMiddle, topEdgeToTriggerMiddle,
contentBorderTopWidth + viewport.offsetTop + (isFirstItem ? viewportPaddingTop : 0) + selectedItemHalfHeight, contentBorderTopWidth + viewportOffsetTop + (isFirstItem ? viewportPaddingTop : 0) + selectedItemHalfHeight,
); );
const height = clampedTopEdgeToTriggerMiddle + itemMiddleToContentBottom;
wrapper.style.height = `${height}px`; placement.top = '0px';
viewport.scrollTop = contentTopToItemMiddle - topEdgeToTriggerMiddle + viewport.offsetTop; placement.height = `${clampedTopEdgeToTriggerMiddle + itemMiddleToContentBottom}px`;
scrollTop = contentTopToItemMiddle - topEdgeToTriggerMiddle + viewportOffsetTop;
} }
wrapper.style.margin = `${CONTENT_MARGIN}px 0`; placement.margin = `${CONTENT_MARGIN}px 0`;
wrapper.style.minHeight = `${minContentHeight}px`; placement.minHeight = `${Math.min(selectedItemHeight * 5, fullContentHeight)}px`;
wrapper.style.maxHeight = `${availableHeight}px`; placement.maxHeight = `${availableHeight}px`;
// --- Commit ---
commit(wrapper, placement);
if (scrollTop !== undefined) viewport.scrollTop = scrollTop;
emit('placed'); emit('placed');
requestAnimationFrame(() => (shouldExpandOnScrollRef.value = true)); requestAnimationFrame(() => (shouldExpandOnScrollRef.value = true));
@@ -2,6 +2,12 @@
import type { Direction } from '../../utilities/config-provider'; import type { Direction } from '../../utilities/config-provider';
import type { AcceptableValue } from './utils'; 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 * A custom, fully stylable replacement for the native `<select>` element: a
* trigger button that opens a floating listbox of options, with full keyboard * trigger button that opens a floating listbox of options, with full keyboard
@@ -16,7 +22,9 @@ import type { AcceptableValue } from './utils';
* (compared via `by`). Compose it from a `SelectTrigger` (with * (compared via `by`). Compose it from a `SelectTrigger` (with
* `SelectValue`/`SelectIcon`) plus a portalled `SelectContent` of `SelectItem`s. * `SelectValue`/`SelectIcon`) plus a portalled `SelectContent` of `SelectItem`s.
*/ */
export interface SelectRootProps<T extends AcceptableValue = AcceptableValue> { export interface SelectRootProps<T extends AcceptableValue = AcceptableValue, Multiple extends boolean = false> {
/** Controlled value. Bind with `v-model`. */
modelValue?: SelectModelValue<T, Multiple>;
/** Reading direction. Falls back to ConfigProvider. */ /** Reading direction. Falls back to ConfigProvider. */
dir?: Direction; dir?: Direction;
/** Disable the whole select. */ /** Disable the whole select. */
@@ -26,11 +34,11 @@ export interface SelectRootProps<T extends AcceptableValue = AcceptableValue> {
/** Native input name for form submission. */ /** Native input name for form submission. */
name?: string; name?: string;
/** Uncontrolled default value. */ /** Uncontrolled default value. */
defaultValue?: T | T[]; defaultValue?: SelectModelValue<T, Multiple>;
/** Uncontrolled default open state. */ /** Uncontrolled default open state. */
defaultOpen?: boolean; defaultOpen?: boolean;
/** Allow selecting multiple options; the model becomes an array. */ /** Allow selecting multiple options; the model becomes an array. */
multiple?: boolean; multiple?: Multiple;
/** /**
* Compare object values by a property key or a custom comparator. Omitted * Compare object values by a property key or a custom comparator. Omitted
* `===` for primitives / structural deep-equality for objects. * `===` for primitives / structural deep-equality for objects.
@@ -40,13 +48,20 @@ export interface SelectRootProps<T extends AcceptableValue = AcceptableValue> {
autocomplete?: string; autocomplete?: string;
} }
export interface SelectRootEmits<T extends AcceptableValue = AcceptableValue> { export interface SelectRootEmits<T extends AcceptableValue = AcceptableValue, Multiple extends boolean = false> {
'update:modelValue': [value: T | T[] | undefined]; 'update:modelValue': [value: SelectModelValue<T, Multiple>];
'update:open': [open: boolean]; '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>
<script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue"> <script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue, Multiple extends boolean = false">
import type { Ref } from 'vue'; import type { Ref } from 'vue';
import { computed, ref, shallowRef, toRef, watch } from 'vue'; import { computed, ref, shallowRef, toRef, watch } from 'vue';
@@ -60,6 +75,7 @@ import { compare, shouldShowPlaceholder } from './utils';
defineOptions({ inheritAttrs: false }); defineOptions({ inheritAttrs: false });
const { const {
modelValue,
dir, dir,
disabled = false, disabled = false,
required = false, required = false,
@@ -69,11 +85,13 @@ const {
multiple = false, multiple = false,
by, by,
autocomplete, autocomplete,
} = defineProps<SelectRootProps<T>>(); } = defineProps<SelectRootProps<T, Multiple>>();
const emit = defineEmits<SelectRootOwnEmits<T, Multiple>>();
defineSlots<{ defineSlots<{
default?: (props: { default?: (props: {
modelValue: T | T[] | undefined; modelValue: SelectModelValue<T, Multiple> | undefined;
open: boolean; open: boolean;
}) => unknown; }) => unknown;
}>(); }>();
@@ -88,16 +106,26 @@ const open = defineModel<boolean>('open', {
}, },
}); });
const localValue = ref<T | T[] | undefined>(defaultValue ?? (multiple ? ([] as T[]) : undefined)) as Ref<T | T[] | undefined>; type ModelValue = SelectModelValue<T, Multiple>;
const value = defineModel<T | T[] | undefined>('modelValue', {
default: undefined, // `defineModel` would type `update:modelValue` as `ModelValue | undefined`,
get: v => (v ?? localValue.value), // 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,
set: (v) => { set: (v) => {
localValue.value = v; localValue.value = v;
return v; emit('update:modelValue', v as ModelValue);
}, },
}); });
// 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 contentId = useId(undefined, 'select-content');
const dirRef = toRef(() => dir); const dirRef = toRef(() => dir);
const disabledRef = toRef(() => disabled); const disabledRef = toRef(() => disabled);
@@ -119,7 +147,7 @@ const displayValue = ref<string | undefined>(undefined);
const rawOptions = new Set<SelectOption>(); const rawOptions = new Set<SelectOption>();
const optionsSet = shallowRef(new Set<SelectOption>()); const optionsSet = shallowRef(new Set<SelectOption>());
const isEmptyModelValue = computed(() => shouldShowPlaceholder(value.value)); const isEmptyModelValue = computed(() => shouldShowPlaceholder(model.value));
function getOptionFrom(source: Iterable<SelectOption>, v: AcceptableValue): SelectOption | undefined { function getOptionFrom(source: Iterable<SelectOption>, v: AcceptableValue): SelectOption | undefined {
for (const option of source) { for (const option of source) {
@@ -143,8 +171,8 @@ function onOptionRemove(option: SelectOption) {
} }
// Persist a single-value label for the legacy `displayValue` slot path. // Persist a single-value label for the legacy `displayValue` slot path.
watch([optionsSet, value], () => { watch([optionsSet, model], () => {
const current = value.value; const current = model.value;
if (current === undefined || Array.isArray(current)) return; if (current === undefined || Array.isArray(current)) return;
const text = getOptionFrom(optionsSet.value, current)?.textContent; const text = getOptionFrom(optionsSet.value, current)?.textContent;
if (text !== undefined) displayValue.value = text; if (text !== undefined) displayValue.value = text;
@@ -152,21 +180,21 @@ watch([optionsSet, value], () => {
function handleValueChange(newValue: AcceptableValue) { function handleValueChange(newValue: AcceptableValue) {
if (multiple) { if (multiple) {
const array = Array.isArray(value.value) ? [...value.value] : []; const array = Array.isArray(model.value) ? [...model.value] : [];
const index = array.findIndex(v => compare(v as T, newValue as T, by as never)); const index = array.findIndex(v => compare(v as T, newValue as T, by as never));
if (index === -1) array.push(newValue as T); if (index === -1) array.push(newValue as T);
else array.splice(index, 1); else array.splice(index, 1);
value.value = [...array] as T[]; model.value = [...array] as T[];
} }
else { else {
value.value = newValue as T; model.value = newValue as T;
displayValue.value = getOptionFrom(rawOptions, newValue)?.textContent; displayValue.value = getOptionFrom(rawOptions, newValue)?.textContent;
open.value = false; open.value = false;
} }
} }
function isSelectedValue(itemValue: AcceptableValue): boolean { function isSelectedValue(itemValue: AcceptableValue): boolean {
const current = value.value; const current = model.value;
if (current === undefined) return false; if (current === undefined) return false;
if (Array.isArray(current)) { if (Array.isArray(current)) {
for (const v of current) { for (const v of current) {
@@ -197,7 +225,7 @@ const isFormControl = computed(() => {
}); });
provideSelectRootContext({ provideSelectRootContext({
value, value: model,
onValueChange: handleValueChange, onValueChange: handleValueChange,
open, open,
onOpenChange: (v) => { open.value = v; }, onOpenChange: (v) => { open.value = v; },
@@ -237,7 +265,7 @@ provideSelectRootContext({
:disabled="disabled" :disabled="disabled"
:multiple="multiple" :multiple="multiple"
:options="nativeOptions" :options="nativeOptions"
:value="value" :value="model"
@change="handleValueChange" @change="handleValueChange"
/> />
@@ -245,7 +273,7 @@ provideSelectRootContext({
v-else-if="name" v-else-if="name"
type="hidden" type="hidden"
:name="name" :name="name"
:value="Array.isArray(value) ? '' : (value ?? '')" :value="Array.isArray(model) ? '' : (model ?? '')"
:required="required" :required="required"
:disabled="disabled" :disabled="disabled"
:autocomplete="autocomplete" :autocomplete="autocomplete"
@@ -20,11 +20,11 @@ export interface SelectViewportProps extends PrimitiveProps {
<script setup lang="ts"> <script setup lang="ts">
import { ref, toRef, watchPostEffect } from 'vue'; import { ref, toRef, watchPostEffect } from 'vue';
import { useForwardExpose } from '@robonen/vue'; import { useForwardExpose, useStyleTag } from '@robonen/vue';
import { useNonce } from '../../utilities/config-provider'; import { useNonce } from '../../utilities/config-provider';
import { Primitive } from '../../internal/primitive'; import { Primitive } from '../../internal/primitive';
import { useSelectContentContext, useSelectItemAlignedPositionContext } from './context'; import { useSelectContentContext, useSelectItemAlignedPositionContext } from './context';
import { CONTENT_MARGIN } from './utils'; import { CONTENT_MARGIN, VIEWPORT_SCROLLBAR_CSS } from './utils';
const { as = 'div', nonce: propNonce } = defineProps<SelectViewportProps>(); const { as = 'div', nonce: propNonce } = defineProps<SelectViewportProps>();
@@ -32,6 +32,11 @@ const { forwardRef, currentElement } = useForwardExpose();
const contentCtx = useSelectContentContext(); const contentCtx = useSelectContentContext();
const nonce = useNonce(toRef(() => propNonce)); 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' const alignedCtx = contentCtx.position === 'item-aligned'
? useSelectItemAlignedPositionContext(null as never) ? useSelectItemAlignedPositionContext(null as never)
: undefined; : undefined;
@@ -82,8 +87,4 @@ function handleScroll(event: Event) {
> >
<slot /> <slot />
</Primitive> </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> </template>
@@ -385,3 +385,133 @@ describe('Select — native form submission', () => {
w.unmount(); 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,13 +4,6 @@ import type { AcceptableValue } from './utils';
import { useContextFactory } from '@robonen/vue'; 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 { export interface SelectOption {
value: AcceptableValue; value: AcceptableValue;
disabled?: boolean; disabled?: boolean;
+1 -2
View File
@@ -29,7 +29,6 @@ export {
} from './context'; } from './context';
export type { export type {
SelectValue,
SelectOption, SelectOption,
SelectRootContext, SelectRootContext,
SelectContentContext, SelectContentContext,
@@ -38,7 +37,7 @@ export type {
SelectItemContext, SelectItemContext,
} from './context'; } from './context';
export type { AcceptableValue as SelectAcceptableValue } from './utils'; export type { AcceptableValue as SelectAcceptableValue } from './utils';
export type { SelectRootProps, SelectRootEmits } from './SelectRoot.vue'; export type { SelectModelValue, SelectRootProps, SelectRootEmits } from './SelectRoot.vue';
export type { SelectTriggerProps } from './SelectTrigger.vue'; export type { SelectTriggerProps } from './SelectTrigger.vue';
export type { SelectValueProps } from './SelectValue.vue'; export type { SelectValueProps } from './SelectValue.vue';
export type { SelectIconProps } from './SelectIcon.vue'; export type { SelectIconProps } from './SelectIcon.vue';
@@ -9,6 +9,11 @@ export const OPEN_KEYS = [' ', 'Enter', 'ArrowUp', 'ArrowDown'];
export const SELECTION_KEYS = [' ', 'Enter']; export const SELECTION_KEYS = [' ', 'Enter'];
export const CONTENT_MARGIN = 10; 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' { export function getOpenState(open: boolean): 'open' | 'closed' {
return open ? 'open' : 'closed'; return open ? 'open' : 'closed';
} }
@@ -45,6 +45,14 @@ export interface RovingFocusGroupEmits {
'update:currentTabStopId': [value: string | null | undefined]; '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 { export interface RovingFocusGroupContext {
orientation: Ref<Orientation | undefined>; orientation: Ref<Orientation | undefined>;
dir: Ref<Direction>; dir: Ref<Direction>;
@@ -77,7 +85,7 @@ const {
as = 'div', as = 'div',
} = defineProps<RovingFocusGroupProps>(); } = defineProps<RovingFocusGroupProps>();
const emit = defineEmits<RovingFocusGroupEmits>(); const emit = defineEmits<RovingFocusGroupOwnEmits>();
const config = useConfig(); const config = useConfig();
// `dir` falls back to the provider's configured direction when not given as prop. // `dir` falls back to the provider's configured direction when not given as prop.