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
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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@robonen/tsconfig",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Base typescript configuration for projects",
|
||||
"keywords": [
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"vueCompilerOptions": {
|
||||
"strictTemplates": true,
|
||||
"fallthroughAttributes": true,
|
||||
"htmlAttributes": ["aria-*", "data-*"],
|
||||
"inferTemplateDollarAttrs": true,
|
||||
"inferTemplateDollarEl": true,
|
||||
"inferTemplateDollarRefs": true
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
"$schema": "https://jsr.io/schema/config-file.v1.json",
|
||||
"name": "@robonen/primitives",
|
||||
"license": "Apache-2.0",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.3",
|
||||
"exports": "./src/index.ts"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@robonen/primitives",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.3",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Collection of UI primitives",
|
||||
"keywords": [
|
||||
|
||||
@@ -14,6 +14,9 @@ import type { RovingDirection } from '../../internal/utils/roving-focus';
|
||||
export type AccordionType = 'single' | 'multiple';
|
||||
|
||||
export interface AccordionRootProps extends PrimitiveProps {
|
||||
/** Controlled open value(s). Bind with `v-model`. */
|
||||
modelValue?: string | string[];
|
||||
|
||||
/** Initial value(s) for uncontrolled mode. */
|
||||
defaultValue?: string | string[];
|
||||
|
||||
@@ -51,6 +54,10 @@ export interface AccordionRootProps extends PrimitiveProps {
|
||||
/**
|
||||
* Emit contract for `AccordionRoot`. The payload narrows with `Type`: a single
|
||||
* accordion emits `string | undefined`, a multiple accordion emits `string[]`.
|
||||
*
|
||||
* The event itself is declared by `defineModel`: passing a model key through
|
||||
* `defineEmits` as well erases its payload type from the generated
|
||||
* declarations, leaving consumers with `unknown`.
|
||||
*/
|
||||
export interface AccordionRootEmits<Type extends AccordionType = AccordionType> {
|
||||
'update:modelValue': [value: (Type extends 'single' ? string : string[]) | undefined];
|
||||
@@ -79,8 +86,6 @@ const {
|
||||
as = 'div',
|
||||
} = defineProps<AccordionRootProps>();
|
||||
|
||||
defineEmits<AccordionRootEmits>();
|
||||
|
||||
defineSlots<{
|
||||
default?: (props: {
|
||||
/** Current open value(s): a `string | undefined` in single mode, `string[]` in multiple. */
|
||||
|
||||
@@ -13,11 +13,11 @@ import type { TabsValue } from './context';
|
||||
* via `defaultValue`), orientation, keyboard roving focus across triggers, and
|
||||
* provides context to every `TabsList`, `TabsTrigger`, and `TabsContent`.
|
||||
*/
|
||||
export interface TabsRootProps extends PrimitiveProps {
|
||||
export interface TabsRootProps<Value extends TabsValue = TabsValue> extends PrimitiveProps {
|
||||
/** Controlled selected value. Bind with `v-model`. */
|
||||
modelValue?: TabsValue;
|
||||
modelValue?: Value;
|
||||
/** Uncontrolled initial value. */
|
||||
defaultValue?: TabsValue;
|
||||
defaultValue?: Value;
|
||||
/** Orientation of the tab list. @default 'horizontal' */
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
/**
|
||||
@@ -40,13 +40,14 @@ export interface TabsRootProps extends PrimitiveProps {
|
||||
unmountOnHide?: boolean;
|
||||
}
|
||||
|
||||
export interface TabsRootEmits {
|
||||
export interface TabsRootEmits<Value extends TabsValue = TabsValue> {
|
||||
/** Fired when the selected value changes. */
|
||||
'update:modelValue': [value: TabsValue | undefined];
|
||||
'update:modelValue': [value: Value];
|
||||
}
|
||||
</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 { resolveNextIndex, rovingKeyToAction } from '../../internal/utils/roving-focus';
|
||||
import { useCollectionProvider } from '../../utilities/collection';
|
||||
@@ -63,15 +64,16 @@ const {
|
||||
activationMode = 'automatic',
|
||||
unmountOnHide = true,
|
||||
defaultValue,
|
||||
modelValue,
|
||||
as = 'div',
|
||||
} = defineProps<TabsRootProps>();
|
||||
} = defineProps<TabsRootProps<Value>>();
|
||||
|
||||
defineEmits<TabsRootEmits>();
|
||||
const emit = defineEmits<TabsRootEmits<Value>>();
|
||||
|
||||
defineSlots<{
|
||||
default?: (props: {
|
||||
/** Current selected value. */
|
||||
value: TabsValue | undefined;
|
||||
value: Value | undefined;
|
||||
}) => unknown;
|
||||
}>();
|
||||
|
||||
@@ -79,16 +81,24 @@ const { forwardRef } = useForwardExpose();
|
||||
|
||||
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>({
|
||||
get: v => v ?? localValue.value,
|
||||
const value = computed<Value | undefined>({
|
||||
get: () => modelValue ?? localValue.value,
|
||||
set: (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 tabsListElement = shallowRef<HTMLElement>();
|
||||
|
||||
@@ -116,7 +126,7 @@ function unregisterContent(v: TabsValue): void {
|
||||
|
||||
function select(v: TabsValue): void {
|
||||
if (disabled) return;
|
||||
value.value = v;
|
||||
contextValue.value = v;
|
||||
}
|
||||
|
||||
// DOM-order tabs via Collection primitive — survives `v-for` reorders and
|
||||
@@ -161,7 +171,7 @@ function onTriggerKeyDown(event: KeyboardEvent, el: HTMLElement): void {
|
||||
}
|
||||
|
||||
provideTabsContext({
|
||||
value,
|
||||
value: contextValue,
|
||||
// Identity passthroughs via `toRef` — reactive without `computed`'s effect/cache.
|
||||
orientation: toRef(() => orientation),
|
||||
direction,
|
||||
|
||||
@@ -66,6 +66,11 @@ export interface CalendarRootProps extends PrimitiveProps {
|
||||
dateAdapter?: DateAdapter<Date>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit contract for `CalendarRoot`. The model events are declared by `defineModel`:
|
||||
* passing a model key through `defineEmits` as well erases its payload type
|
||||
* from the generated declarations, leaving consumers with `unknown`.
|
||||
*/
|
||||
export interface CalendarRootEmits {
|
||||
'update:modelValue': [date: Date | Date[] | undefined];
|
||||
'update:placeholder': [date: Date];
|
||||
@@ -106,8 +111,6 @@ const {
|
||||
dateAdapter,
|
||||
} = defineProps<CalendarRootProps>();
|
||||
|
||||
defineEmits<CalendarRootEmits>();
|
||||
|
||||
defineSlots<{
|
||||
default?: (props: {
|
||||
date: Date;
|
||||
|
||||
@@ -40,6 +40,11 @@ export interface DatePickerRootProps extends PrimitiveProps,
|
||||
hourCycle?: HourCycle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit contract for `DatePickerRoot`. The model events are declared by `defineModel`:
|
||||
* passing a model key through `defineEmits` as well erases its payload type
|
||||
* from the generated declarations, leaving consumers with `unknown`.
|
||||
*/
|
||||
export interface DatePickerRootEmits {
|
||||
'update:modelValue': [date: Date | undefined];
|
||||
'update:placeholder': [date: Date];
|
||||
@@ -95,8 +100,6 @@ const {
|
||||
dateAdapter,
|
||||
} = defineProps<DatePickerRootProps>();
|
||||
|
||||
defineEmits<DatePickerRootEmits>();
|
||||
|
||||
const { forwardRef, currentElement: parentElement } = useForwardExpose();
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit contract for `ProgressRoot`. The model events are declared by `defineModel`:
|
||||
* passing a model key through `defineEmits` as well erases its payload type
|
||||
* from the generated declarations, leaving consumers with `unknown`.
|
||||
*/
|
||||
export interface ProgressRootEmits {
|
||||
/** Emitted when the value changes (after validation/clamping). */
|
||||
'update:modelValue': [value: number | null];
|
||||
@@ -59,8 +64,6 @@ const {
|
||||
as = 'div',
|
||||
} = defineProps<ProgressRootProps>();
|
||||
|
||||
defineEmits<ProgressRootEmits>();
|
||||
|
||||
const { forwardRef } = useForwardExpose();
|
||||
|
||||
const localValue = ref<number | null>(null);
|
||||
|
||||
@@ -43,6 +43,11 @@ export interface SwitchProps<T = boolean> extends PrimitiveProps {
|
||||
value?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit contract for `Switch`. The model events are declared by `defineModel`:
|
||||
* passing a model key through `defineEmits` as well erases its payload type
|
||||
* from the generated declarations, leaving consumers with `unknown`.
|
||||
*/
|
||||
export interface SwitchEmits<T = boolean> {
|
||||
/** Emitted whenever the value changes (also drives `v-model`). */
|
||||
'update:modelValue': [value: T];
|
||||
@@ -71,8 +76,6 @@ const {
|
||||
as = 'button',
|
||||
} = defineProps<SwitchProps<T>>();
|
||||
|
||||
defineEmits<SwitchEmits<T>>();
|
||||
|
||||
const { forwardRef, currentElement } = useForwardExpose();
|
||||
|
||||
const local = ref<T>((defaultValue ?? falsy) as T) as Ref<T>;
|
||||
|
||||
@@ -4,7 +4,11 @@ import type { PrimitiveProps } from '../../internal/primitive';
|
||||
/** Canonical `data-state` value reflected on the host element. */
|
||||
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 {
|
||||
/** Fired when the pressed state changes. Backs `v-model:pressed`. */
|
||||
'update:pressed': [pressed: boolean];
|
||||
@@ -58,8 +62,6 @@ const {
|
||||
value = 'on',
|
||||
} = defineProps<ToggleProps>();
|
||||
|
||||
defineEmits<ToggleEmits>();
|
||||
|
||||
const { forwardRef, currentElement } = useForwardExpose();
|
||||
|
||||
// A standalone Toggle nested inside a ToggleGroup must not also submit its own
|
||||
|
||||
@@ -4,7 +4,35 @@ import { renderSlotChild } from './Slot';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { Primitive, type PrimitiveProps } from './Primitive';
|
||||
export { Primitive, type PrimitiveAttributes, type PrimitiveProps } from './Primitive';
|
||||
export { Slot } from './Slot';
|
||||
|
||||
@@ -38,6 +38,11 @@ export interface NavigationMenuRootProps extends PrimitiveProps {
|
||||
unmountOnHide?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit contract for `NavigationMenuRoot`. The model events are declared by `defineModel`:
|
||||
* passing a model key through `defineEmits` as well erases its payload type
|
||||
* from the generated declarations, leaving consumers with `unknown`.
|
||||
*/
|
||||
export interface NavigationMenuRootEmits {
|
||||
'update:modelValue': [value: string];
|
||||
}
|
||||
@@ -70,8 +75,6 @@ const {
|
||||
as = 'nav',
|
||||
} = defineProps<NavigationMenuRootProps>();
|
||||
|
||||
defineEmits<NavigationMenuRootEmits>();
|
||||
|
||||
defineSlots<{
|
||||
default?: (props: { modelValue: string }) => unknown;
|
||||
}>();
|
||||
|
||||
@@ -15,6 +15,11 @@ export interface NavigationMenuSubProps extends PrimitiveProps {
|
||||
orientation?: Orientation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit contract for `NavigationMenuSub`. The model events are declared by `defineModel`:
|
||||
* passing a model key through `defineEmits` as well erases its payload type
|
||||
* from the generated declarations, leaving consumers with `unknown`.
|
||||
*/
|
||||
export interface NavigationMenuSubEmits {
|
||||
'update:modelValue': [value: string];
|
||||
}
|
||||
@@ -35,8 +40,6 @@ defineOptions({ inheritAttrs: false });
|
||||
|
||||
const { defaultValue, orientation = 'horizontal', as = 'div' } = defineProps<NavigationMenuSubProps>();
|
||||
|
||||
defineEmits<NavigationMenuSubEmits>();
|
||||
|
||||
defineSlots<{
|
||||
default?: (props: { modelValue: string }) => unknown;
|
||||
}>();
|
||||
|
||||
@@ -44,6 +44,14 @@ export interface ToolbarRootEmits {
|
||||
/** Backs `v-model:currentTabStopId`. */
|
||||
'update:currentTabStopId': [value: string | null | undefined];
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset `defineEmits` declares. `update:currentTabStopId` comes from
|
||||
* `defineModel`; passing a model key through `defineEmits` as well erases its
|
||||
* payload type from the generated declarations, leaving consumers with
|
||||
* `unknown`.
|
||||
*/
|
||||
type ToolbarRootOwnEmits = Omit<ToolbarRootEmits, 'update:currentTabStopId'>;
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -64,7 +72,7 @@ const {
|
||||
as = 'div',
|
||||
} = defineProps<ToolbarRootProps>();
|
||||
|
||||
const emit = defineEmits<ToolbarRootEmits>();
|
||||
const emit = defineEmits<ToolbarRootOwnEmits>();
|
||||
|
||||
const { forwardRef } = useForwardExpose();
|
||||
|
||||
|
||||
@@ -28,6 +28,11 @@ import { useSelectRootContext } from './context';
|
||||
import SelectContentImpl from './SelectContentImpl.vue';
|
||||
import SelectProvider from './SelectProvider.vue';
|
||||
|
||||
// Neither branch below is a single element root (`Presence` wraps the panel,
|
||||
// the closed branch is a `Teleport`), so Vue cannot inherit `class`/`style` or
|
||||
// any other attribute automatically — they are forwarded onto the panel itself.
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = defineProps<SelectContentProps>();
|
||||
const emit = defineEmits<SelectContentEmits>();
|
||||
const rootCtx = useSelectRootContext();
|
||||
@@ -57,7 +62,7 @@ onMounted(() => {
|
||||
:present="present"
|
||||
>
|
||||
<SelectContentImpl
|
||||
v-bind="props"
|
||||
v-bind="{ ...props, ...$attrs }"
|
||||
@close-auto-focus="emit('closeAutoFocus', $event)"
|
||||
@escape-key-down="emit('escapeKeyDown', $event)"
|
||||
@pointer-down-outside="emit('pointerDownOutside', $event)"
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
import type { Direction } from '../../utilities/config-provider';
|
||||
import type { AcceptableValue } from './utils';
|
||||
|
||||
/**
|
||||
* Shape of the select's model value: an array of `T` in multiple mode, a bare
|
||||
* `T` otherwise. Keeps `v-model` narrow on both sides of the binding.
|
||||
*/
|
||||
export type SelectModelValue<T extends AcceptableValue, Multiple extends boolean> = Multiple extends true ? T[] : T;
|
||||
|
||||
/**
|
||||
* A custom, fully stylable replacement for the native `<select>` element: a
|
||||
* trigger button that opens a floating listbox of options, with full keyboard
|
||||
@@ -16,7 +22,9 @@ import type { AcceptableValue } from './utils';
|
||||
* (compared via `by`). Compose it from a `SelectTrigger` (with
|
||||
* `SelectValue`/`SelectIcon`) plus a portalled `SelectContent` of `SelectItem`s.
|
||||
*/
|
||||
export interface SelectRootProps<T extends AcceptableValue = AcceptableValue> {
|
||||
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. */
|
||||
dir?: Direction;
|
||||
/** Disable the whole select. */
|
||||
@@ -26,11 +34,11 @@ export interface SelectRootProps<T extends AcceptableValue = AcceptableValue> {
|
||||
/** Native input name for form submission. */
|
||||
name?: string;
|
||||
/** Uncontrolled default value. */
|
||||
defaultValue?: T | T[];
|
||||
defaultValue?: SelectModelValue<T, Multiple>;
|
||||
/** Uncontrolled default open state. */
|
||||
defaultOpen?: boolean;
|
||||
/** 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 →
|
||||
* `===` for primitives / structural deep-equality for objects.
|
||||
@@ -40,13 +48,20 @@ export interface SelectRootProps<T extends AcceptableValue = AcceptableValue> {
|
||||
autocomplete?: string;
|
||||
}
|
||||
|
||||
export interface SelectRootEmits<T extends AcceptableValue = AcceptableValue> {
|
||||
'update:modelValue': [value: T | T[] | undefined];
|
||||
export interface SelectRootEmits<T extends AcceptableValue = AcceptableValue, Multiple extends boolean = false> {
|
||||
'update:modelValue': [value: SelectModelValue<T, Multiple>];
|
||||
'update:open': [open: boolean];
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset `defineEmits` declares. `update:open` comes from `defineModel`;
|
||||
* passing a model key through `defineEmits` as well erases its payload type
|
||||
* from the generated declarations, leaving consumers with `unknown`.
|
||||
*/
|
||||
type SelectRootOwnEmits<T extends AcceptableValue, Multiple extends boolean> = Omit<SelectRootEmits<T, Multiple>, 'update:open'>;
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue">
|
||||
<script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue, Multiple extends boolean = false">
|
||||
import type { Ref } from 'vue';
|
||||
import { computed, ref, shallowRef, toRef, watch } from 'vue';
|
||||
|
||||
@@ -60,6 +75,7 @@ import { compare, shouldShowPlaceholder } from './utils';
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const {
|
||||
modelValue,
|
||||
dir,
|
||||
disabled = false,
|
||||
required = false,
|
||||
@@ -69,11 +85,13 @@ const {
|
||||
multiple = false,
|
||||
by,
|
||||
autocomplete,
|
||||
} = defineProps<SelectRootProps<T>>();
|
||||
} = defineProps<SelectRootProps<T, Multiple>>();
|
||||
|
||||
const emit = defineEmits<SelectRootOwnEmits<T, Multiple>>();
|
||||
|
||||
defineSlots<{
|
||||
default?: (props: {
|
||||
modelValue: T | T[] | undefined;
|
||||
modelValue: SelectModelValue<T, Multiple> | undefined;
|
||||
open: boolean;
|
||||
}) => 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>;
|
||||
const value = defineModel<T | T[] | undefined>('modelValue', {
|
||||
default: undefined,
|
||||
get: v => (v ?? localValue.value),
|
||||
type ModelValue = SelectModelValue<T, Multiple>;
|
||||
|
||||
// `defineModel` would type `update:modelValue` as `ModelValue | undefined`,
|
||||
// forcing every consumer's `v-model` target to accept `undefined` even though
|
||||
// a selection is never cleared. The prop and the emit are declared separately
|
||||
// so the emitted payload stays exactly `ModelValue` (see AGENTS §3.2.3).
|
||||
const localValue = ref(defaultValue ?? (multiple ? [] : undefined)) as Ref<ModelValue | undefined>;
|
||||
const value = computed<ModelValue | undefined>({
|
||||
get: () => modelValue ?? localValue.value,
|
||||
set: (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 dirRef = toRef(() => dir);
|
||||
const disabledRef = toRef(() => disabled);
|
||||
@@ -119,7 +147,7 @@ const displayValue = ref<string | undefined>(undefined);
|
||||
const rawOptions = 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 {
|
||||
for (const option of source) {
|
||||
@@ -143,8 +171,8 @@ function onOptionRemove(option: SelectOption) {
|
||||
}
|
||||
|
||||
// Persist a single-value label for the legacy `displayValue` slot path.
|
||||
watch([optionsSet, value], () => {
|
||||
const current = value.value;
|
||||
watch([optionsSet, model], () => {
|
||||
const current = model.value;
|
||||
if (current === undefined || Array.isArray(current)) return;
|
||||
const text = getOptionFrom(optionsSet.value, current)?.textContent;
|
||||
if (text !== undefined) displayValue.value = text;
|
||||
@@ -152,21 +180,21 @@ watch([optionsSet, value], () => {
|
||||
|
||||
function handleValueChange(newValue: AcceptableValue) {
|
||||
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));
|
||||
if (index === -1) array.push(newValue as T);
|
||||
else array.splice(index, 1);
|
||||
value.value = [...array] as T[];
|
||||
model.value = [...array] as T[];
|
||||
}
|
||||
else {
|
||||
value.value = newValue as T;
|
||||
model.value = newValue as T;
|
||||
displayValue.value = getOptionFrom(rawOptions, newValue)?.textContent;
|
||||
open.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function isSelectedValue(itemValue: AcceptableValue): boolean {
|
||||
const current = value.value;
|
||||
const current = model.value;
|
||||
if (current === undefined) return false;
|
||||
if (Array.isArray(current)) {
|
||||
for (const v of current) {
|
||||
@@ -197,7 +225,7 @@ const isFormControl = computed(() => {
|
||||
});
|
||||
|
||||
provideSelectRootContext({
|
||||
value,
|
||||
value: model,
|
||||
onValueChange: handleValueChange,
|
||||
open,
|
||||
onOpenChange: (v) => { open.value = v; },
|
||||
@@ -237,7 +265,7 @@ provideSelectRootContext({
|
||||
:disabled="disabled"
|
||||
:multiple="multiple"
|
||||
:options="nativeOptions"
|
||||
:value="value"
|
||||
:value="model"
|
||||
@change="handleValueChange"
|
||||
/>
|
||||
|
||||
@@ -245,7 +273,7 @@ provideSelectRootContext({
|
||||
v-else-if="name"
|
||||
type="hidden"
|
||||
:name="name"
|
||||
:value="Array.isArray(value) ? '' : (value ?? '')"
|
||||
:value="Array.isArray(model) ? '' : (model ?? '')"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:autocomplete="autocomplete"
|
||||
|
||||
@@ -20,11 +20,11 @@ export interface SelectViewportProps extends PrimitiveProps {
|
||||
<script setup lang="ts">
|
||||
import { ref, toRef, watchPostEffect } from 'vue';
|
||||
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { useForwardExpose, useStyleTag } from '@robonen/vue';
|
||||
import { useNonce } from '../../utilities/config-provider';
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
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>();
|
||||
|
||||
@@ -32,6 +32,11 @@ const { forwardRef, currentElement } = useForwardExpose();
|
||||
const contentCtx = useSelectContentContext();
|
||||
const nonce = useNonce(toRef(() => propNonce));
|
||||
|
||||
// Injected into `<head>` (one reference-counted tag per document) rather than
|
||||
// rendered as a sibling `<style>`: a second root node would turn this component
|
||||
// into a fragment, and Vue cannot inherit a consumer's `class` onto a fragment.
|
||||
useStyleTag(VIEWPORT_SCROLLBAR_CSS, { id: 'primitives-select-viewport', nonce: nonce.value });
|
||||
|
||||
const alignedCtx = contentCtx.position === 'item-aligned'
|
||||
? useSelectItemAlignedPositionContext(null as never)
|
||||
: undefined;
|
||||
@@ -82,8 +87,4 @@ function handleScroll(event: Event) {
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
<Primitive as="style" :nonce="nonce">
|
||||
[data-primitives-select-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}
|
||||
[data-primitives-select-viewport]::-webkit-scrollbar{display:none;}
|
||||
</Primitive>
|
||||
</template>
|
||||
|
||||
@@ -385,3 +385,74 @@ describe('Select — native form submission', () => {
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Select — attribute forwarding on the panel', () => {
|
||||
function mountStyled() {
|
||||
return track(mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => h(
|
||||
SelectRoot,
|
||||
{ defaultOpen: true },
|
||||
{
|
||||
default: () => [
|
||||
h(SelectTrigger, { id: 'styled-trigger', 'aria-label': 'Fruit' }, {
|
||||
default: () => h(SelectValue, { placeholder: 'Pick one' }),
|
||||
}),
|
||||
h(SelectPortal, null, {
|
||||
default: () => h(SelectContent, { class: 'panel', 'data-panel': 'yes' }, {
|
||||
default: () => h(SelectViewport, { class: 'viewport' }, {
|
||||
default: () => h(SelectItem, { value: 'apple' }, {
|
||||
default: () => h(SelectItemText, null, { default: () => 'Apple' }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
);
|
||||
},
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
));
|
||||
}
|
||||
|
||||
it('forwards class and data attributes from SelectContent to the panel element', async () => {
|
||||
const w = mountStyled();
|
||||
await flush();
|
||||
const panel = document.querySelector('[data-primitives-select-content]') as HTMLElement | null;
|
||||
expect(panel).toBeTruthy();
|
||||
expect(panel!.classList.contains('panel')).toBe(true);
|
||||
expect(panel!.getAttribute('data-panel')).toBe('yes');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('forwards class from SelectViewport to the viewport element', async () => {
|
||||
const w = mountStyled();
|
||||
await flush();
|
||||
const viewport = document.querySelector('[data-primitives-select-viewport]') as HTMLElement | null;
|
||||
expect(viewport).toBeTruthy();
|
||||
expect(viewport!.classList.contains('viewport')).toBe(true);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('keeps the trigger a single root that accepts native attributes', async () => {
|
||||
const w = mountStyled();
|
||||
await flush();
|
||||
const trigger = getTrigger();
|
||||
expect(trigger.id).toBe('styled-trigger');
|
||||
expect(trigger.getAttribute('aria-label')).toBe('Fruit');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('injects the scrollbar-hiding stylesheet into head instead of a sibling style node', async () => {
|
||||
const w = mountStyled();
|
||||
await flush();
|
||||
const injected = document.head.querySelector('#primitives-select-viewport');
|
||||
expect(injected).toBeTruthy();
|
||||
expect(injected!.textContent).toContain('[data-primitives-select-viewport]');
|
||||
const panel = document.querySelector('[data-primitives-select-content]') as HTMLElement;
|
||||
expect(panel.querySelector('style')).toBeNull();
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,13 +4,6 @@ import type { AcceptableValue } from './utils';
|
||||
|
||||
import { useContextFactory } from '@robonen/vue';
|
||||
|
||||
/**
|
||||
* @deprecated Kept for backward compatibility. The select now accepts any
|
||||
* {@link AcceptableValue} (string/number/boolean/object). `SelectValue` remains
|
||||
* a string alias so existing `string`-typed consumers keep compiling.
|
||||
*/
|
||||
export type SelectValue = string;
|
||||
|
||||
export interface SelectOption {
|
||||
value: AcceptableValue;
|
||||
disabled?: boolean;
|
||||
|
||||
@@ -29,7 +29,6 @@ export {
|
||||
} from './context';
|
||||
|
||||
export type {
|
||||
SelectValue,
|
||||
SelectOption,
|
||||
SelectRootContext,
|
||||
SelectContentContext,
|
||||
@@ -38,7 +37,7 @@ export type {
|
||||
SelectItemContext,
|
||||
} from './context';
|
||||
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 { SelectValueProps } from './SelectValue.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 CONTENT_MARGIN = 10;
|
||||
|
||||
/** Hides the viewport's scrollbar across engines while keeping it scrollable. */
|
||||
export const VIEWPORT_SCROLLBAR_CSS
|
||||
= '[data-primitives-select-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}'
|
||||
+ '[data-primitives-select-viewport]::-webkit-scrollbar{display:none;}';
|
||||
|
||||
export function getOpenState(open: boolean): 'open' | 'closed' {
|
||||
return open ? 'open' : 'closed';
|
||||
}
|
||||
|
||||
@@ -45,6 +45,14 @@ export interface RovingFocusGroupEmits {
|
||||
'update:currentTabStopId': [value: string | null | undefined];
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset `defineEmits` declares. `update:currentTabStopId` comes from
|
||||
* `defineModel`; passing a model key through `defineEmits` as well erases its
|
||||
* payload type from the generated declarations, leaving consumers with
|
||||
* `unknown`.
|
||||
*/
|
||||
type RovingFocusGroupOwnEmits = Omit<RovingFocusGroupEmits, 'update:currentTabStopId'>;
|
||||
|
||||
export interface RovingFocusGroupContext {
|
||||
orientation: Ref<Orientation | undefined>;
|
||||
dir: Ref<Direction>;
|
||||
@@ -77,7 +85,7 @@ const {
|
||||
as = 'div',
|
||||
} = defineProps<RovingFocusGroupProps>();
|
||||
|
||||
const emit = defineEmits<RovingFocusGroupEmits>();
|
||||
const emit = defineEmits<RovingFocusGroupOwnEmits>();
|
||||
|
||||
const config = useConfig();
|
||||
// `dir` falls back to the provider's configured direction when not given as prop.
|
||||
|
||||
Reference in New Issue
Block a user