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>
This commit is contained in:
2026-08-10 00:14:44 +07:00
parent e59e05ac05
commit 1d2130f279
24 changed files with 260 additions and 78 deletions
+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.3",
"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.3",
"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)"
@@ -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,74 @@ 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();
});
});
@@ -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.