Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea96d720f2 | |||
| 6da5ecaa83 | |||
| 1d2130f279 |
@@ -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": [
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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. */
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user