Files
openobserve/web/src/lib/forms/Select/OSelect.vue
T
6bbdcd7d7e fix(ui): sharpen visual polish, keyboard focus and contrast across logs, tables and charts (#15993)
Co-authored-by: ktx-vaidehi <vaidehi.akhani@kiara.tech>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-29 10:39:35 +00:00

2159 lines
89 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// Copyright 2026 OpenObserve Inc.
import type {
SelectProps,
SelectEmits,
SelectSlots,
SelectValue as SelectPrimitiveValue,
SelectModelValue,
} from "./OSelect.types";
import {
SELECT_VALUE_MAP_KEY,
SELECT_PARENT_DATA_TEST_KEY,
NULL_VALUE_SENTINEL,
} from "./OSelect.types";
import OSelectItem from "./OSelectItem.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import {
ListboxFilter,
ListboxItem,
ListboxRoot,
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger,
SelectRoot,
SelectTrigger,
SelectValue,
SelectPortal,
SelectContent,
SelectViewport,
SelectScrollUpButton,
SelectScrollDownButton,
} from "reka-ui";
import { useVirtualizer } from "@tanstack/vue-virtual";
import {
type Ref,
computed,
inject,
nextTick,
onBeforeUnmount,
onMounted,
provide,
ref,
useAttrs,
useId,
useSlots,
watch,
watchEffect,
} from "vue";
import useBreakpoint from "@/composables/useBreakpoint";
import { mergeDescribedBy, splitControlAttrs, warnIfUnlabelled } from "@/lib/forms/controlAttrs";
import {
O_DROPDOWN_NESTED_KEY,
setActiveOverlay,
clearActiveOverlay,
} from "@/lib/overlay/Dropdown/ODropdown.context";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
const { t } = useI18nTyped();
type NormalizedOption = {
label: I18nText;
value: SelectPrimitiveValue;
disabled: boolean;
header: boolean;
icon?: string;
/** Pre-rendered Vue component (e.g. an inlined SVG) shown beside the option label.
* Used when the icon isn't a string name in the OIcon registry. */
iconComponent?: any;
/** Secondary description text shown below the label in the dropdown item. */
subLabel?: I18nText;
/** When true, renders subLabel on the same line as the label (name – url style). */
subLabelInline?: boolean;
/** Array of CSS color strings used to render a gradient swatch below the label. */
colorPalette?: string[];
/** Optional badge/chip text rendered inline next to the label (e.g. "recommended"). */
badge?: string;
/** Tooltip for the badge. Use when the badge is abbreviated (e.g. `C` → "Counter"). */
badgeTitle?: string;
/** Tints the badge neutral instead of positive, e.g. a badge naming why a disabled row is disabled. */
badgeMuted?: boolean;
/**
* Per-option badge colours, e.g. `{ color, background }`. A styled badge renders
* as a filled pill in that option's own colour, right-aligned into a single
* column; a bare (unstyled) badge stays inline next to the label.
*/
badgeStyle?: Record<string, string>;
/** Non-selectable row that shows or hides the options nested under it. */
expandable?: boolean;
/** Reka string key of the expandable option this one is nested under. */
parentKey?: string;
/** Extra lowercase text the search matches besides the label, e.g. a zone's legacy names. */
searchText?: string;
};
const DEFAULT_OPTION_LABEL = "label";
const DEFAULT_OPTION_VALUE = "value";
const DEFAULT_OPTION_DISABLED = "disabled";
const { lgUp } = useBreakpoint();
const props = withDefaults(defineProps<SelectProps>(), {
size: "md",
appearance: "field",
disabled: false,
clearable: false,
error: false,
multiple: false,
searchable: true,
searchDebounce: 0,
hideSelected: false,
collapsibleGroups: false,
pinSelectedInGroups: false,
creatable: false,
labelKey: DEFAULT_OPTION_LABEL,
valueKey: DEFAULT_OPTION_VALUE,
iconKey: undefined,
labelPosition: "outside",
rowClickSingleSelect: false,
optionTooltip: false,
// Intentionally no default — when undefined, the chip count is computed
// from the live trigger width. Pass a number to force a fixed cap.
});
// Forward the consumer's `data-test` from <OSelect data-test="…"> onto the
// root wrapper so E2E selectors can scope to the specific field instance.
// (Same pattern as ODialog / OInput.)
defineOptions({ inheritAttrs: false });
const $attrs = useAttrs();
const parentDataTest = computed(() => $attrs["data-test"] as string | undefined);
// Without a label the icon is aria-hidden and unreachable by assistive tech; the tooltip's own content never gets a name otherwise.
const tooltipIconLabel = computed(() =>
props.label
? t("components.select.moreInfoAbout", { field: props.label })
: t("components.select.moreInfo"),
);
// Forward tabindex to the real trigger; keep it off the wrapper (avoids a double tab-stop).
const inputTabindex = computed(() => $attrs["tabindex"] as number | string | undefined);
const split = computed(() => splitControlAttrs($attrs));
const wrapperAttrs = computed(() => split.value.wrapperAttrs);
const controlAttrs = computed(() => split.value.controlAttrs);
const insideLabelName = computed(() =>
props.labelPosition === "inside" && props.label ? props.label : undefined,
);
onMounted(() =>
warnIfUnlabelled(
"OSelect",
Boolean(props.label || $attrs["aria-label"] || $attrs["aria-labelledby"]),
),
);
const emit = defineEmits<SelectEmits>();
const slots = useSlots();
defineSlots<SelectSlots>();
const _fallbackId = useId();
const inputId = computed(() => props.id ?? _fallbackId);
const errorId = computed(() => `${inputId.value}-error`);
const labelId = computed(() => `${inputId.value}-label`);
// The popover trigger keeps Reka's own id, so `<label for>` cannot reach it.
const outsideLabelId = computed(() =>
props.label &&
props.labelPosition !== "inside" &&
!$attrs["aria-label"] &&
!$attrs["aria-labelledby"]
? labelId.value
: undefined,
);
// Dev-time warning: slot-based options with multiple/searchable require listbox
// mode, but listboxModeEnabled requires options prop. Guide the developer early.
if (import.meta.env.DEV) {
watchEffect(() => {
if (slots.default && (props.multiple || props.searchable)) {
console.warn(
"[OSelect] Slot-based options (OSelectItem/OSelectGroup) do not support " +
"`multiple`, `searchable`, or `useInput`. Pass options via the `options` prop instead.",
);
}
});
}
// Value map populated by OSelectItem children to preserve original types
// (Reka UI requires string values; this lets us recover number originals)
const valueMap = new Map<string, SelectPrimitiveValue>();
provide(SELECT_VALUE_MAP_KEY, valueMap);
// Forward the parent OSelect's data-test to OSelectItem children so they can
// auto-derive `<parent>-option` data-test attributes (non-listbox mode).
provide(SELECT_PARENT_DATA_TEST_KEY, parentDataTest);
const isPrimitiveSelectValue = (value: unknown): value is SelectPrimitiveValue =>
value === null || ["string", "number", "boolean"].includes(typeof value);
/** Map null ↔ sentinel so Reka UI receives a valid string */
function toRekaString(v: SelectPrimitiveValue): string {
if (v === null) return NULL_VALUE_SENTINEL;
return String(v);
}
function normalizeOption(input: unknown): NormalizedOption | null {
if (isPrimitiveSelectValue(input)) {
return {
label: raw(String(input)),
value: input,
disabled: false,
header: false,
};
}
if (!input || typeof input !== "object") return null;
const option = input as Record<string, unknown>;
const rawLabel = option[props.labelKey];
// Group header: items with header:true or isTab:true are non-selectable labels
const isHeader = Boolean(option["header"]) || Boolean(option["isTab"]);
if (isHeader) {
return {
label: raw(rawLabel === undefined || rawLabel === null ? "" : String(rawLabel)),
value: `__header__${String(rawLabel)}`,
disabled: true,
header: true,
};
}
const rawValue = option[props.valueKey];
if (!isPrimitiveSelectValue(rawValue)) return null;
const rawIcon = props.iconKey ? option[props.iconKey] : undefined;
// Auto-pick up `iconComponent` on the option (raw Vue component / inlined SVG).
// Lets consumers like ConfigPanel pass `iconComponent: markRaw(MyIcon)` per option
// when the icon isn't a string in the OIcon registry.
const rawIconComponent = option["iconComponent"];
const rawSubLabel = option["subLabel"];
const rawSubLabelInline = option["subLabelInline"];
const rawColorPalette = option["colorPalette"];
return {
label: raw(rawLabel === undefined || rawLabel === null ? String(rawValue) : String(rawLabel)),
value: rawValue,
disabled: Boolean(option[DEFAULT_OPTION_DISABLED]),
header: false,
icon: typeof rawIcon === "string" && rawIcon ? rawIcon : undefined,
iconComponent: rawIconComponent ?? undefined,
subLabel: typeof rawSubLabel === "string" ? raw(rawSubLabel) : undefined,
subLabelInline: rawSubLabelInline === true,
colorPalette: Array.isArray(rawColorPalette) ? (rawColorPalette as string[]) : undefined,
badge: typeof option["badge"] === "string" ? (option["badge"] as string) : undefined,
badgeTitle:
typeof option["badgeTitle"] === "string" ? (option["badgeTitle"] as string) : undefined,
badgeStyle:
option["badgeStyle"] && typeof option["badgeStyle"] === "object"
? (option["badgeStyle"] as Record<string, string>)
: undefined,
badgeMuted: option["badgeMuted"] === true,
expandable: option["expandable"] === true,
parentKey: isPrimitiveSelectValue(option["parentValue"])
? toRekaString(option["parentValue"])
: undefined,
searchText:
typeof option["searchText"] === "string" ? option["searchText"].toLowerCase() : undefined,
};
}
const normalizedOptions = computed<NormalizedOption[]>(() => {
if (!props.options?.length) return [];
return props.options
.map((option) => normalizeOption(option))
.filter((opt): opt is NormalizedOption => opt !== null);
});
// Mirror prop-supplied options into valueMap so resolveListboxValue can recover
// their original type. Without this, numeric values like `1` round-trip as `"1"`
// (Reka stores listbox values as strings) and strict-equality checks against
// the typed option value — e.g. the multi-select checkbox indicator — never match.
// Also registers NULL_VALUE_SENTINEL → null so null options round-trip correctly.
watchEffect(() => {
for (const o of normalizedOptions.value) {
if (!o.header) valueMap.set(toRekaString(o.value), o.value);
}
});
const searchTerm = ref("");
const popoverOpen = ref(false);
const filterDebounceTimer = ref<ReturnType<typeof setTimeout> | null>(null);
// Set of Reka string keys that were selected when the popover last opened.
// Frozen for the lifetime of one open session so items don't re-order mid-interaction.
// Cleared when the popover opens with nothing selected.
const pinnedSelected = ref<Set<string>>(new Set());
const inputEnabled = computed(() => props.searchable);
// Not a `withDefaults` default: that is evaluated once at module scope, which
// would freeze the placeholder in whatever locale was active at first load.
const resolvedSearchPlaceholder = computed(
() => props.searchPlaceholder ?? t("common.searchEllipsis"),
);
const matchesTerm = (opt: NormalizedOption, term: string) =>
opt.label.toLowerCase().includes(term) || !!opt.searchText?.includes(term);
const baseFilteredOptions = computed(() => {
if (!inputEnabled.value) return normalizedOptions.value;
const term = searchTerm.value.trim().toLowerCase();
let options = normalizedOptions.value;
if (props.hideSelected && props.multiple && selectedValues.value.length > 0) {
options = options.filter((opt) => opt.header || !selectedValues.value.includes(opt.value));
// Drop headers that no longer have any following non-header items
options = options.filter((opt, i) => {
if (!opt.header) return true;
const next = options[i + 1];
return next !== undefined && !next.header;
});
}
if (!term) {
// In multi-select mode, float previously-selected items to the top so users
// can immediately see and manage their current choices on re-open.
// Grouped lists keep their order unless `pinSelectedInGroups` is set; then
// items float within their own group (a header and the items under it; a
// flat list is one group), so no item leaves its group. sort() is stable.
if (
props.multiple &&
pinnedSelected.value.size > 0 &&
(props.pinSelectedInGroups || !options.some((o) => o.header))
) {
const pinned = pinnedSelected.value;
let group = 0;
return options
.map((o) => ({
o,
group: o.header ? ++group : group,
rank: o.header ? 0 : pinned.has(toRekaString(o.value)) ? 1 : 2,
}))
.sort((a, b) => a.group - b.group || a.rank - b.rank)
.map(({ o }) => o);
}
return options;
}
// Keep headers visible if any of their following non-header items match
const result: NormalizedOption[] = [];
for (let i = 0; i < options.length; i++) {
const opt = options[i];
if (opt.header) {
// Include the header only if at least one subsequent non-header item matches
let j = i + 1;
while (j < options.length && !options[j].header) {
if (matchesTerm(options[j], term)) {
result.push(opt);
break;
}
j++;
}
} else {
if (matchesTerm(opt, term)) {
result.push(opt);
}
}
}
return result;
});
// ── Collapsible groups (opt-in via `collapsibleGroups`) ──────────────────────
// Group headers become accordion toggles: collapsing a group hides its items
// (the header stays, so it can be re-expanded). Keyed by header label — group
// labels are unique in practice. Ignored while searching so matches always show.
const collapsedGroups = ref<Set<string>>(new Set());
const isGroupCollapsed = (label: string) => collapsedGroups.value.has(label);
const toggleGroup = (label: string) => {
const next = new Set(collapsedGroups.value);
next.has(label) ? next.delete(label) : next.add(label);
collapsedGroups.value = next;
};
// ── Expandable options (`expandable` / `parentValue` on an option) ─────────
const hasNestedOptions = computed(() =>
normalizedOptions.value.some((o) => o.parentKey !== undefined),
);
// Toggles made while open; null until the first one, so each open starts from the selection.
const toggledParents = ref<Set<string> | null>(null);
const expandedParents = computed(() => {
if (toggledParents.value) return toggledParents.value;
const selected = new Set(selectedValues.value.map((v) => toRekaString(v)));
const parents = new Set<string>();
for (const opt of normalizedOptions.value) {
if (opt.parentKey !== undefined && selected.has(toRekaString(opt.value))) {
parents.add(opt.parentKey);
}
}
return parents;
});
const isSearching = computed(() => searchTerm.value.trim() !== "");
const isExpanded = (value: SelectPrimitiveValue) => expandedParents.value.has(toRekaString(value));
const isNestedRow = (opt: NormalizedOption) => opt.parentKey !== undefined && !isSearching.value;
const toggleExpanded = (value: SelectPrimitiveValue) => {
const next = new Set(expandedParents.value);
const key = toRekaString(value);
next.has(key) ? next.delete(key) : next.add(key);
toggledParents.value = next;
};
watch(popoverOpen, (open) => {
if (!open) toggledParents.value = null;
});
// Hides the options of collapsed rows; a search lists every match flat, without the rows.
const nestedFilteredOptions = computed(() => {
const base = baseFilteredOptions.value;
if (!hasNestedOptions.value) return base;
if (isSearching.value) {
const flat = base.filter((o) => !o.expandable);
// A header kept only for a matching expandable row has nothing left under it.
return flat.filter((o, i) => !o.header || (flat[i + 1] !== undefined && !flat[i + 1].header));
}
return base.filter((o) => o.parentKey === undefined || expandedParents.value.has(o.parentKey));
});
// The final option list the dropdown renders — the single source of truth that
// feeds the virtualizer, keyboard navigation, and the empty-state check. It
// layers the accordion collapse on top of `baseFilteredOptions` (which has
// already applied the search + hide-selected filters), one step before render.
//
// `baseFilteredOptions` is a FLAT array where each group header
// (`{ header: true, label }`) is followed by that group's items, e.g.
// [Destinations(header), dest1, dest2, Workflows(header), wf1, wf2]
// Collapsing a group must hide its items but KEEP the header (so the bar stays
// visible and clickable to re-expand).
//
// Two early exits leave `base` untouched:
// 1. Not in collapsible mode, or nothing collapsed — every other select in the
// app hits this, so the feature is invisible unless opted in.
// 2. An active search term — collapse is bypassed so a match inside a collapsed
// group still shows (otherwise search would look broken).
//
// Otherwise walk the flat list: on a header, remember whether ITS group is
// collapsed (and always keep the header); on an item, keep it only while the
// current group isn't collapsed. `hidden` carries forward until the next header
// resets it.
const filteredOptions = computed(() => {
const base = nestedFilteredOptions.value;
if (!props.collapsibleGroups || collapsedGroups.value.size === 0) return base;
if (searchTerm.value.trim()) return base; // search spans every group
const out: NormalizedOption[] = [];
let hidden = false;
for (const opt of base) {
if (opt.header) {
hidden = collapsedGroups.value.has(opt.label);
out.push(opt);
} else if (!hidden) {
out.push(opt);
}
}
return out;
});
const listboxModeEnabled = computed(
() =>
!slots.default &&
(props.multiple ||
props.searchable ||
props.creatable ||
(!!props.options?.length &&
(props.labelKey !== DEFAULT_OPTION_LABEL || props.valueKey !== DEFAULT_OPTION_VALUE))),
);
const selectedValues = computed<SelectPrimitiveValue[]>(() => {
if (Array.isArray(props.modelValue)) return props.modelValue;
if (props.modelValue === undefined || props.modelValue === "") {
return [];
}
if (props.modelValue === null) {
// null is a valid selection when a null-valued option exists (e.g. "Auto", "None", "Default")
const hasNullOption = normalizedOptions.value.some((opt) => !opt.header && opt.value === null);
return hasNullOption ? [null] : [];
}
return [props.modelValue];
});
const hasSelection = computed(() => selectedValues.value.some((v) => v !== undefined));
const selectedLabels = computed(() => {
return selectedValues.value
.map((selectedValue) => {
const option = normalizedOptions.value.find(
(opt) => !opt.expandable && opt.value === selectedValue,
);
if (option) return option.label;
// Don't render "null" for unmatched null values — treat as empty/placeholder
if (selectedValue === null) return null;
return String(selectedValue);
})
.filter((label): label is string => Boolean(label));
});
const triggerDisplayLabel = computed(() => {
if (!hasSelection.value) return "";
if (props.multiple) {
// "Select all" is a sentinel elsewhere (e.g. an absent/empty field reads as
// "every panel"); showing its own label here keeps the trigger readable
// instead of listing every option.
if (props.allSelectedLabel && allSelected.value) return String(props.allSelectedLabel);
return selectedLabels.value.join(", ");
}
return selectedLabels.value[0] ?? "";
});
watch(searchTerm, (value) => {
if (!inputEnabled.value) return;
if ((props.searchDebounce ?? 0) > 0) {
if (filterDebounceTimer.value) clearTimeout(filterDebounceTimer.value);
filterDebounceTimer.value = setTimeout(() => emit("search", value), props.searchDebounce);
return;
}
emit("search", value);
});
// Clear the search term whenever the popover closes so re-opening the
// dropdown always shows the full unfiltered option list.
// In multi-select mode, also snapshot the current selection so selected items
// can be floated to the top on the next open.
watch(popoverOpen, (open) => {
if (!open) {
searchTerm.value = "";
} else if (props.multiple) {
pinnedSelected.value = new Set(selectedValues.value.map((v) => toRekaString(v)));
}
});
// ── Error state ────────────────────────────────────────────────────────────
// The error message is only shown when `props.error` is true. `error` is the
// single source of truth for whether the error is visible; `errorMessage` only
// controls the text.
const effectiveError = computed(() => {
if (!props.error) return null;
return props.errorMessage || " ";
});
const hasError = computed(() => !!effectiveError.value);
const errorDescribedBy = computed(() => (effectiveError.value?.trim() ? errorId.value : undefined));
const stringValue = computed(() =>
!Array.isArray(props.modelValue) && props.modelValue !== undefined
? toRekaString(props.modelValue)
: undefined,
);
function handleUpdate(value: string | null) {
// Reka's SelectRoot types the value as nullable; null means no selection here.
if (value === null) {
emit("update:modelValue", undefined);
return;
}
// Recover the original type: prefer props.options, then the slot-item registry
const opt = normalizedOptions.value.find((o) => String(o.value) === value);
let resolved: SelectPrimitiveValue;
if (opt !== undefined) {
resolved = opt.value;
} else if (valueMap.has(value)) {
resolved = valueMap.get(value)!;
} else {
resolved = value;
}
emit("update:modelValue", resolved);
}
function resolveListboxValue(value: unknown): SelectModelValue {
if (props.multiple) {
if (!Array.isArray(value)) {
return value === undefined || value === null ? [] : [String(value)];
}
return value.map((item) => {
const key = String(item);
return valueMap.has(key) ? (valueMap.get(key) as SelectPrimitiveValue) : key;
});
}
if (Array.isArray(value)) {
const first = value[0];
if (first === undefined || first === null) return undefined;
const key = String(first);
return valueMap.has(key) ? (valueMap.get(key) as SelectPrimitiveValue) : key;
}
if (value === undefined || value === null) return undefined;
const key = String(value);
return valueMap.has(key) ? (valueMap.get(key) as SelectPrimitiveValue) : key;
}
function handleListboxUpdate(value: unknown) {
const resolved = resolveListboxValue(value);
// Single-select: prevent deselecting the already-selected option
if (!props.multiple) {
if (resolved === undefined || resolved === "") {
// Reka toggles off the current selection by emitting "" or undefined — ignore it.
// Note: resolved === null is a valid mapped value (e.g. "None"/"Auto"/"Default"
// options that use null as their value), so it must NOT be blocked here.
popoverOpen.value = false;
return;
}
}
emit("update:modelValue", resolved);
if (!props.multiple) {
popoverOpen.value = false;
}
}
function handleRowClickSingleSelect(rekaStringValue: string) {
const resolved = valueMap.has(rekaStringValue)
? (valueMap.get(rekaStringValue) as SelectPrimitiveValue)
: rekaStringValue;
emit("update:modelValue", [resolved]);
popoverOpen.value = false;
}
function handleItemClickCapture(event: MouseEvent, rekaStringValue: string) {
if (!props.rowClickSingleSelect) return;
// Find the separator line rendered inside this item. Anything clicked at or
// to the left of the separator is the "checkbox zone" — let Reka toggle normally.
// Anything to the right is the "label zone" — single-select and close.
const separator = (event.currentTarget as HTMLElement | null)?.querySelector(
"[data-select-separator]",
);
if (separator) {
const { right } = separator.getBoundingClientRect();
if (event.clientX <= right) return; // checkbox zone — let Reka handle
} else {
// Fallback: no separator means rowClickSingleSelect isn't active for this item
return;
}
event.stopPropagation();
handleRowClickSingleSelect(rekaStringValue);
}
const listboxStringModelValue = computed<string | string[]>(() => {
const resolved = props.multiple
? selectedValues.value.map((value) => toRekaString(value))
: selectedValues.value[0] !== undefined
? toRekaString(selectedValues.value[0])
: "";
return resolved;
});
function handleClear() {
const clearedValue: SelectModelValue = props.multiple ? [] : undefined;
emit("update:modelValue", clearedValue);
emit("clear");
searchTerm.value = "";
}
// ── Select-all (multi-select listbox) ─────────────────────────────────────
// Operates on all enabled, non-header options — not just the currently
// filtered subset — so the master state stays predictable regardless of the
// search term.
const selectableOptions = computed<NormalizedOption[]>(() =>
normalizedOptions.value.filter(
(o: NormalizedOption) => !o.header && !o.disabled && !o.expandable,
),
);
const allSelected = computed(() => {
const all = selectableOptions.value;
if (all.length === 0) return false;
return all.every((o: NormalizedOption) => selectedValues.value.includes(o.value));
});
const partiallySelected = computed(() => {
if (allSelected.value) return false;
return selectableOptions.value.some((o: NormalizedOption) =>
selectedValues.value.includes(o.value),
);
});
function toggleAllSelections() {
if (allSelected.value) {
emit("update:modelValue", []);
} else {
emit(
"update:modelValue",
selectableOptions.value.map((o: NormalizedOption) => o.value),
);
}
}
function handleCreateFromInput() {
if (!props.creatable) return;
const term = searchTerm.value.trim();
if (!term) return;
const hasExisting = normalizedOptions.value.some(
(opt) => opt.label === term || String(opt.value) === term,
);
if (hasExisting) return;
emit("create", term);
}
onBeforeUnmount(() => {
if (filterDebounceTimer.value) clearTimeout(filterDebounceTimer.value);
});
/** Close the dropdown programmatically */
function close() {
popoverOpen.value = false;
searchTerm.value = "";
}
/** Focus the trigger and open it, so a programmatic call reads as more than a ring. */
function focus() {
if (typeof document === "undefined") return;
document.getElementById(inputId.value)?.focus();
if (props.disabled) return;
if (listboxModeEnabled.value) {
popoverOpen.value = true;
} else {
selectOpen.value = true;
}
}
defineExpose({ close, focus });
// Type-to-search on the closed trigger: a printable keystroke opens the
// dropdown and seeds the filter, mirroring native <select> typeahead. The
// event is consumed here so page-level single-letter shortcuts (logs "s",
// "r", "h", …) never fire while the select has keyboard focus.
function handleTriggerKeydown(e: KeyboardEvent) {
if (props.disabled) return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
if (e.key.length !== 1 || e.key === " ") return;
e.preventDefault();
e.stopPropagation();
popoverOpen.value = true;
if (inputEnabled.value) searchTerm.value = searchTerm.value + e.key;
}
const searchFilterRef = ref<{ $el: HTMLInputElement } | null>(null);
// Reka's open auto-focus selects the search text, so the next keystroke would overwrite the seeded character.
function handlePopoverOpenAutoFocus(e: Event) {
const input = searchFilterRef.value?.$el;
if (!inputEnabled.value || !input) return;
e.preventDefault();
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
}
// ── Virtual scroll (listbox mode) ─────────────────────────────────────────
// Items are virtualised whenever the listbox has more than 50 entries, keeping
// DOM nodes minimal even for 20 000+ option datasets.
const listboxScrollEl = ref<HTMLElement | null>(null);
// ── Keyboard navigation ────────────────────────────────────────────────────
// Because items are virtualised (absolutely positioned inside a spacer div
// rather than real DOM children of ListboxRoot), Reka's built-in roving-focus
// never reaches them. We manage highlight state ourselves with an index.
//
// Strategy:
// - highlightedIndex tracks which filteredOptions entry is highlighted.
// - Up / Down move the index through selectable (non-header, non-disabled)
// items with wrap-around.
// - Enter commits the selection; Escape closes the dropdown.
// - The highlight class is applied reactively — no DOM focus juggling.
// - The search <input> retains browser focus the whole time the dropdown is
// open, so typing always goes straight to the filter.
const highlightedIndex = ref(-1);
// Indices into filteredOptions that are actual selectable items (no headers/disabled).
const navigableIndices = computed(() =>
filteredOptions.value
.map((opt, i) => ({ opt, i }))
.filter(({ opt }) => !opt.header && !opt.disabled)
.map(({ i }) => i),
);
// Reset highlight whenever the dropdown opens or the filtered list changes.
// Single-select: land on the current value instead of the top of the list —
// a 48-row virtualised list (e.g. a time-of-day picker) otherwise always opens
// scrolled to its first entry, forcing a scroll/search just to see what's
// already chosen. Multi-select keeps its own affordance (pinned-to-top in
// `baseFilteredOptions`), so it stays untouched.
watch(popoverOpen, (open) => {
if (!open) {
highlightedIndex.value = -1;
return;
}
if (props.multiple) {
highlightedIndex.value = -1;
return;
}
const selected = selectedValues.value[0];
const index =
selected === undefined
? -1
: filteredOptions.value.findIndex((opt) => !opt.header && opt.value === selected);
highlightedIndex.value = index;
if (index < 0) return;
nextTick(() => {
virtualizer.value.scrollToIndex(index, { align: "center", behavior: "auto" });
nextTick(scrollHighlightedIntoView);
});
});
watch(filteredOptions, () => {
highlightedIndex.value = -1;
});
// The list opens scrolled to the current value; a search keeping that offset hides its best matches above the fold.
watch(searchTerm, () => {
if (listboxScrollEl.value) listboxScrollEl.value.scrollTop = 0;
});
function scrollHighlightedIntoView() {
if (highlightedIndex.value < 0 || !listboxScrollEl.value) return;
const rows = listboxScrollEl.value.querySelectorAll<HTMLElement>("[data-vrow]");
for (const row of rows) {
if (Number(row.dataset.vrow) === highlightedIndex.value) {
row.scrollIntoView({ block: "nearest" });
break;
}
}
}
function expandKeyTarget(key: string, opt: NormalizedOption): NormalizedOption | undefined {
if (opt.expandable) {
const expanded = isExpanded(opt.value);
const toggles =
key === "Enter" || (key === "ArrowRight" && !expanded) || (key === "ArrowLeft" && expanded);
return toggles ? opt : undefined;
}
if (key !== "ArrowLeft" || opt.parentKey === undefined) return undefined;
return filteredOptions.value.find((o) => toRekaString(o.value) === opt.parentKey);
}
// Enter toggles an expandable row, Right expands it, Left collapses it (or its row from a nested option).
function handleExpandKey(e: KeyboardEvent): boolean {
if (isSearching.value) return false;
const opt = filteredOptions.value[highlightedIndex.value];
const target = opt ? expandKeyTarget(e.key, opt) : undefined;
if (!target) return false;
e.preventDefault();
toggleExpanded(target.value);
// Runs after the filteredOptions watcher has reset the highlight.
nextTick(() => {
highlightedIndex.value = filteredOptions.value.indexOf(target);
nextTick(scrollHighlightedIntoView);
});
return true;
}
function handleDropdownKeydown(e: KeyboardEvent) {
const nav = navigableIndices.value;
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
popoverOpen.value = false;
return;
}
if (handleExpandKey(e)) return;
// Allow Enter to trigger create even when no options are visible.
// Otherwise creatable=true with an empty filtered list silently swallows
// the keystroke, breaking the "type a new name and press Enter" UX.
if (nav.length === 0) {
if (e.key === "Enter" && props.creatable) {
e.preventDefault();
handleCreateFromInput();
}
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
const cur = nav.indexOf(highlightedIndex.value);
highlightedIndex.value = cur < nav.length - 1 ? nav[cur + 1] : nav[0];
nextTick(scrollHighlightedIntoView);
} else if (e.key === "ArrowUp") {
e.preventDefault();
const cur = nav.indexOf(highlightedIndex.value);
highlightedIndex.value = cur > 0 ? nav[cur - 1] : nav[nav.length - 1];
nextTick(scrollHighlightedIntoView);
} else if (e.key === "Enter") {
e.preventDefault();
if (highlightedIndex.value >= 0) {
const opt = filteredOptions.value[highlightedIndex.value];
if (opt) handleListboxUpdate(toRekaString(opt.value));
} else {
handleCreateFromInput();
}
}
}
const virtualizer = useVirtualizer(
computed(() => ({
count: filteredOptions.value.length,
getScrollElement: () => listboxScrollEl.value,
estimateSize: (index: number) => {
const opt = filteredOptions.value[index];
if (!opt) return 28;
const hasSubLabel = !!opt.subLabel;
const isInline = !!opt.subLabelInline;
const hasPalette = !!opt.colorPalette?.length;
const hasBadge = !!opt.badge;
// Inline subLabel renders on the same line — single-row height.
if (hasSubLabel && isInline) return 28;
// py-2(16) + label(20) + gap(4) + 2-line subLabel(34) + gap(4) + gradient(6) = 84px
if (hasPalette && hasSubLabel) return 84;
// py-2(16) + label(20) + gap(4) + gradient(6) = 46px — no subLabel
if (hasPalette) return 46;
// badge+subLabel: py-2(16) + label+badge(20) + gap(4) + 1-line subLabel(16) = 56px
if (hasBadge && hasSubLabel) return 56;
// py-2(16) + label(20) + gap(4) + 1-line subLabel(16) = 56px
if (hasSubLabel) return 56;
// badge is inline with label — same single-row height
if (hasBadge) return 28;
return 28;
},
overscan: 8,
})),
);
/** Build a CSS linear-gradient string from an array of color stops. */
function getPaletteGradient(colors: string[]): string {
return `linear-gradient(to right, ${colors.join(", ")})`;
}
// Aligned with OInput and OButton sm: h-[2.125rem] ≈ 30px at the 14px html base.
const heightClasses: Record<NonNullable<SelectProps["size"]>, string> = {
xs: "h-7 text-xs",
sm: "h-[2.125rem] text-sm",
md: "h-[2.125rem] text-sm",
};
// Open-state tracking. Reka's PopoverTrigger / SelectTrigger expose
// `data-state="open"`, but Vue can't reach across components with a
// CSS group selector without extra wiring — track a ref instead and
// bind the chevron's rotation class to it.
const selectOpen = ref(false);
const isOpen = computed(() => (listboxModeEnabled.value ? popoverOpen.value : selectOpen.value));
// Clicking the field label opens the dropdown — mirroring how clicking an
// OInput's label focuses its input. Reka's triggers open on `pointerdown`, so
// the `click` a native <label for> forwards never opens them; open explicitly.
// `.prevent` (on the template handler) stops the redundant native forwarding.
function handleLabelClick() {
if (props.disabled) return;
if (listboxModeEnabled.value) {
popoverOpen.value = true;
} else {
selectOpen.value = true;
}
}
// When this OSelect is nested inside an ODropdown, signal its
// open/close so the parent ignores the pointer event that closes us.
const parentDropdownRegistry = inject<{ open: () => () => void } | null>(
O_DROPDOWN_NESTED_KEY,
null,
);
let closeNestedRegistration: (() => void) | null = null;
watch(isOpen, (open) => {
if (!parentDropdownRegistry) return;
if (open && !closeNestedRegistration) {
closeNestedRegistration = parentDropdownRegistry.open();
} else if (!open && closeNestedRegistration) {
closeNestedRegistration();
closeNestedRegistration = null;
}
});
onBeforeUnmount(() => {
if (closeNestedRegistration) {
closeNestedRegistration();
closeNestedRegistration = null;
}
});
// Single-active-overlay coordination. Opening any other top-level O overlay
// closes this one (and vice-versa), so two unrelated dropdowns are never open
// at once — even when the new one is opened by focus rather than a click that
// Reka would treat as a dismiss. Nested selects (inside an open ODropdown) opt
// out so they don't close their ancestor.
const closeSelf = () => {
popoverOpen.value = false;
selectOpen.value = false;
};
if (!parentDropdownRegistry) {
watch(isOpen, (open) => {
if (open) setActiveOverlay(closeSelf);
else clearActiveOverlay(closeSelf);
});
onBeforeUnmount(() => clearActiveOverlay(closeSelf));
}
// Close when the sidebar scroll container scrolls, preventing the portal
// from floating disconnected at the top of the screen.
const sidebarScrollTick = inject<Ref<number> | null>("sidebarScrollTick", null);
if (sidebarScrollTick) {
watch(sidebarScrollTick, () => {
if (popoverOpen.value) popoverOpen.value = false;
if (selectOpen.value) selectOpen.value = false;
});
}
// Generic close-on-scroll: while the dropdown is open, scrolling any ancestor
// scroll container (page, query-builder area, joins popup, etc.) would leave
// the portaled list floating detached from the trigger — close it instead.
// Scrolls that originate inside the option list are ignored so the list stays
// scrollable.
function handleViewportScroll(event: Event) {
if (!isOpen.value) return;
const target = event.target as (Element & Node) | Document | null;
if (!target) return;
// Ignore scrolls that originate inside the option list so it stays scrollable.
if (
target instanceof Element &&
(listboxScrollEl.value?.contains(target) || target.closest?.('[role="listbox"]'))
) {
return;
}
// Only close when the scrolled container actually holds this select's
// trigger — i.e. the scroll moves the trigger and would leave the portaled
// menu detached. Scrolls in unrelated sections (e.g. the field list while a
// config-panel dropdown is open) are ignored.
const triggerEl = triggerWrapperRef.value ?? selectTriggerWrapperRef.value;
if (triggerEl && target.contains(triggerEl)) {
popoverOpen.value = false;
selectOpen.value = false;
}
}
// Type-ahead, label clicks, focus(), Escape and picks set popoverOpen directly and Reka never reports them.
watch(popoverOpen, (open, wasOpen) => {
if (open && !wasOpen) emit("open");
else if (!open && wasOpen) emit("close");
});
watch(isOpen, (open) => {
if (typeof window === "undefined") return;
if (open) {
window.addEventListener("scroll", handleViewportScroll, {
capture: true,
passive: true,
});
} else {
window.removeEventListener("scroll", handleViewportScroll, true);
}
});
onBeforeUnmount(() => {
if (typeof window !== "undefined") {
window.removeEventListener("scroll", handleViewportScroll, true);
}
});
// ── Chip overflow (width-aware) ──────────────────────────────────────────
// In multi-select mode, fit as many chips as the trigger width allows; the
// rest collapse into a "+N more" indicator. When `maxVisibleChips` is set,
// that becomes a hard upper bound; otherwise the count is computed live
// from the trigger's measured width via ResizeObserver.
/** The outer wrapper around the trigger. Measured to size the chip row. */
const triggerWrapperRef = ref<HTMLElement | null>(null);
// Wrapper around the native-select (non-listbox) trigger, used by the
// close-on-scroll handler to detect whether a scroll moves this select's trigger.
const selectTriggerWrapperRef = ref<HTMLElement | null>(null);
const triggerWidth = ref(0);
let triggerResizeObserver: ResizeObserver | null = null;
/*
* Why watch instead of onMounted: `triggerWrapperRef` lives inside a
* `v-if="listboxModeEnabled"` branch. If a consumer toggles `multiple` /
* `searchable` at runtime, the listbox wrapper mounts *after* the parent
* component's `onMounted` already ran — so a one-shot observer setup
* would leave `triggerWidth` stuck at 0 forever. Watching the ref lets us
* attach the observer whenever the element comes into existence (and tear
* it down when it goes away). `flush: 'post'` ensures the DOM node is
* already painted so `clientWidth` is meaningful.
*/
watch(
triggerWrapperRef,
(el, _prev, onCleanup) => {
triggerResizeObserver?.disconnect();
triggerResizeObserver = null;
if (!el) {
triggerWidth.value = 0;
return;
}
triggerWidth.value = el.clientWidth;
triggerResizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
triggerWidth.value = entry.contentRect.width;
}
});
triggerResizeObserver.observe(el);
onCleanup(() => {
triggerResizeObserver?.disconnect();
triggerResizeObserver = null;
});
},
{ flush: "post" },
);
onBeforeUnmount(() => {
triggerResizeObserver?.disconnect();
triggerResizeObserver = null;
});
/**
* Estimate a chip's rendered width in pixels from its label.
* Chip styling: `px-2 py-0.5 text-xs` (12px font), max-w-40 = 160px.
* Roughly 6.5px per character at 12px font + 16px horizontal padding.
*/
function estimateChipWidth(label: string): number {
const labelW = Math.min(Math.ceil(label.length * 6.5), 144); // cap to 160-16 padding
return labelW + 16; // px-2 each side
}
// Space reserved inside the trigger for non-chip content: left ps-3 padding +
// right padding (which contains the absolutely-positioned chevron, and the
// clear button when shown). Mirrors `triggerEndPadding` so the chip-fit
// calculation matches the actual squeeze on the content area.
const reservedTriggerSpace = computed(() => {
let reserved = 12 + 28; // ps-3 (12px) + pe-7 (28px) — chevron-only
if (props.clearable && hasSelection.value) reserved += 20; // pe-12 - pe-7
return reserved;
});
const overflowChipEstimatedWidth = 70; // "+N more" pill width approximation
const chipGap = 4; // gap-1 between chips
/**
* Number of chips that actually fit in the current trigger width. If
* `maxVisibleChips` is set on the prop, it caps the result. Always returns
* at least 1 so the user can see something is selected.
*/
const dynamicMaxChips = computed(() => {
const total = selectedLabels.value.length;
if (total === 0) return 0;
// No measurement yet (SSR / pre-mount) — fall back to the prop or a safe
// default so we don't render nothing.
if (triggerWidth.value === 0) {
return Math.min(props.maxVisibleChips ?? 3, total);
}
const available = Math.max(0, triggerWidth.value - reservedTriggerSpace.value);
let used = 0;
let count = 0;
for (let i = 0; i < total; i++) {
const chipW = estimateChipWidth(selectedLabels.value[i]);
const remaining = total - i - 1;
// If there'll be hidden chips after this one, reserve space for the
// "+N more" pill so it can still fit on the row.
const overflowReserve = remaining > 0 ? overflowChipEstimatedWidth + chipGap : 0;
const needed = chipW + (i > 0 ? chipGap : 0) + overflowReserve;
if (used + needed > available) break;
used += chipW + (i > 0 ? chipGap : 0);
count++;
}
const fit = Math.max(1, count);
// Honour an explicit cap when provided.
if (props.maxVisibleChips !== undefined) {
return Math.min(fit, props.maxVisibleChips);
}
return fit;
});
const visibleSelectedLabels = computed(() => selectedLabels.value.slice(0, dynamicMaxChips.value));
const overflowSelectedCount = computed(() =>
Math.max(0, selectedLabels.value.length - dynamicMaxChips.value),
);
// Trigger right padding. Chevron + clear icons live in an absolutely-positioned
// flex container at the trigger's right edge, so changing this padding only
// squeezes the content area — it does not shift the chevron position when the
// clear button toggles into view.
const isInlineAppearance = computed(() => props.appearance === "inline");
const triggerTextTone = computed(() =>
props.disabled
? "text-select-disabled-text"
: hasSelection.value
? "text-select-text"
: "text-select-placeholder",
);
/**
* Inline-appearance trigger chrome. Deliberately contributes NOTHING to the
* line box it sits in: the hover boundary is an inset ring (painted inside the
* box, no size) and the negative margins cancel the padding — so dropping this
* into a fixed-height description band does not stretch it.
*/
const inlineTriggerClasses = computed(() => [
"rounded-default relative -mx-1 -my-0.5 flex w-auto shrink-0 items-center px-1 pe-6 py-0.5",
"text-text-body text-xs font-medium",
"ring-1 ring-inset transition-[color,background-color,box-shadow] duration-150",
"focus:outline-none focus-visible:outline-accent/40 focus-visible:outline-2",
// The hover ring is gated on !hasError so it can't override the resting error
// ring on hover (equal specificity, later class wins) and hide the error.
props.disabled
? "ring-transparent cursor-not-allowed opacity-60"
: hasError.value
? "ring-select-border-error cursor-pointer hover:bg-surface-subtle hover:text-text-heading"
: "ring-transparent cursor-pointer hover:bg-surface-subtle hover:text-text-heading hover:ring-border-default",
]);
const triggerEndPadding = computed(() =>
props.clearable && hasSelection.value ? "pe-12" : "pe-7",
);
const fieldWidthClass = computed(() => {
switch (props.width) {
case "xs":
return "w-field-width-xs";
case "sm":
return "w-field-width-sm";
case "md":
return "w-field-width-md";
case "lg":
return "w-field-width-lg";
default:
return "w-full";
}
});
</script>
<template>
<div
v-bind="wrapperAttrs"
:class="[
'flex flex-col gap-1',
isInlineAppearance ? 'w-auto' : fieldWidthClass,
labelPosition === 'inside' ? 'min-w-0' : '',
]"
>
<!-- Label -->
<label
v-if="(label || $slots.tooltip) && labelPosition !== 'inside'"
:id="labelId"
:for="inputId"
:class="[
'o-input-label text-compact flex items-center gap-1 leading-tight',
disabled
? 'text-input-label-text-disabled font-normal'
: 'text-input-label-text cursor-pointer font-medium',
]"
@click.prevent="handleLabelClick"
>
{{ label }}<span v-if="required" aria-hidden="true" class="select-none">*</span>
<OIcon
v-if="$slots.tooltip"
name="info-outline"
size="sm"
:label="tooltipIconLabel"
:data-test="parentDataTest ? `${parentDataTest}-info` : undefined"
class="cursor-help"
><slot name="tooltip"
/></OIcon>
</label>
<template v-if="listboxModeEnabled">
<PopoverRoot v-model:open="popoverOpen">
<div ref="triggerWrapperRef" class="relative flex items-center">
<PopoverTrigger
type="button"
:id="inputId"
:name="name"
:disabled="disabled"
:tabindex="inputTabindex"
role="combobox"
:aria-expanded="popoverOpen"
:aria-invalid="hasError || undefined"
:aria-label="insideLabelName"
:aria-labelledby="outsideLabelId"
v-bind="controlAttrs"
:aria-describedby="mergeDescribedBy(errorDescribedBy, controlAttrs)"
@keydown="handleTriggerKeydown"
:data-test="parentDataTest ? `${parentDataTest}-trigger` : undefined"
:data-test-selected-value="
multiple
? selectedValues.map((v) => String(v)).join(',')
: selectedValues[0] !== undefined
? String(selectedValues[0])
: ''
"
:data-test-selected-label="triggerDisplayLabel"
:class="
isInlineAppearance
? inlineTriggerClasses
: [
'rounded-default relative flex w-full border',
// In inside-label mode padding is handled per-row; in normal mode it goes on the trigger
labelPosition === 'inside' && label
? ''
: $slots['icon-left']
? 'ps-2'
: 'ps-3',
'bg-select-bg',
hasError
? 'border-select-border-error focus:ring-select-border-error/30 data-[state=open]:ring-select-border-error/30 focus:ring-[0.125rem] data-[state=open]:ring-[0.125rem]'
: 'border-select-border hover:border-select-border-hover focus:border-select-border-focus focus:ring-focus-ring-accent data-[state=open]:border-select-border-focus data-[state=open]:ring-focus-ring-accent focus:ring-[0.125rem] data-[state=open]:ring-[0.125rem]',
/* Keep the red error border on focus; focus border color applies only when there's no error. */
'focus:outline-none',
'transition-[color,background-color,border-color,box-shadow] duration-150',
'disabled:bg-select-disabled-bg disabled:border-select-disabled-border disabled:cursor-not-allowed disabled:border-dashed',
labelPosition === 'inside' && label
? ['flex-col justify-between py-0.5', heightClasses[size ?? 'md']]
: ['items-center', triggerEndPadding, heightClasses[size ?? 'md']],
]
"
>
<!-- Inside label: in-flow with whitespace-nowrap so it drives the trigger's auto-width -->
<span
v-if="label && labelPosition === 'inside'"
class="text-3xs text-text-secondary pointer-events-none ps-3 pe-7 text-start leading-none whitespace-nowrap select-none"
>{{ label }}<span v-if="required" aria-hidden="true">&nbsp;*</span></span
>
<!-- Content row: flex-row for inside-label; display:contents (transparent) for normal mode -->
<div
:class="
labelPosition === 'inside' && label
? [
'flex w-full min-w-0 flex-1 items-center',
triggerEndPadding,
$slots['icon-left'] ? 'ps-2' : 'ps-3',
]
: 'contents'
"
>
<!-- Icon-left slot (inside trigger, left — matches OButton #icon-left) -->
<span
v-if="$slots['icon-left']"
class="text-select-placeholder me-1.5 flex shrink-0 items-center"
>
<slot name="icon-left" />
</span>
<slot name="trigger" :value="modelValue">
<template v-if="multiple && allSelectedLabel && allSelected">
<span class="text-select-text flex-1 truncate text-start text-sm">{{
allSelectedLabel
}}</span>
</template>
<template v-else-if="multiple && selectedLabels.length > 0">
<div class="flex flex-1 flex-nowrap items-center gap-1 overflow-hidden pe-2">
<slot
v-for="(labelText, idx) in visibleSelectedLabels"
name="chip"
:label="String(labelText ?? '')"
:value="selectedValues[idx]"
>
<OTruncatedText
:key="`${idx}-${String(labelText ?? '')}`"
class="rounded-default bg-select-item-selected-bg text-select-item-selected-text inline-block max-w-40 shrink-0 px-2 py-0.5 text-xs leading-none"
>
{{ labelText }}
</OTruncatedText>
</slot>
<span
v-if="overflowSelectedCount > 0"
class="rounded-default bg-select-item-hover-bg text-select-text inline-flex shrink-0 items-center px-2 py-0.5 text-xs"
data-test="o-select-overflow-chip"
>
+{{ overflowSelectedCount }} {{ t("components.select.more") }}
</span>
</div>
</template>
<template v-else>
<span
:class="[
'text-start',
// An inline trigger is a word in a sentence: it grows to fit
// its value. `truncate` would also zero its min-content
// width, letting any ancestor squeeze it to a lone ellipsis.
isInlineAppearance ? 'whitespace-nowrap' : 'flex-1 truncate text-sm',
labelPosition === 'inside' && label ? 'text-xs leading-4' : '',
disabled
? 'text-select-disabled-text'
: hasSelection
? 'text-select-text'
: 'text-select-placeholder',
]"
>
{{ hasSelection ? triggerDisplayLabel : placeholder }}
</span>
<OTooltip v-if="hasSelection" overflow-only />
</template>
</slot>
</div>
</PopoverTrigger>
<!--
Trailing icons: clear (left) then chevron (right).
Container is absolutely positioned and pointer-events:none so the
chevron alone never blocks the trigger click. The clear button
re-enables its own pointer events. Because the chevron's position
is anchored to the container's right edge, toggling the clear
button in or out does not shift the chevron.
-->
<div
:class="[
'pointer-events-none absolute flex items-center gap-1.5',
isInlineAppearance ? 'end-1' : 'end-2.5',
]"
>
<button
v-if="clearable && hasSelection && !isInlineAppearance"
type="button"
tabindex="-1"
:aria-label="t('components.select.clearSelection')"
:class="[
'flex size-3.5 items-center justify-center',
'text-input-clear-btn hover:text-input-clear-btn-hover',
'pointer-events-auto transition-colors',
]"
@click.stop="handleClear"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-3.5"
aria-hidden="true"
>
<path
d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"
/>
</svg>
</button>
<OSpinner v-if="loading" size="xs" />
<span
v-else
aria-hidden="true"
:class="[
'text-select-icon flex items-center justify-center',
'transition-transform duration-150',
isOpen ? 'rotate-180' : '',
]"
>
<!-- chevron-down — rotates 180° when the popover is open -->
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</span>
</div>
</div>
<PopoverPortal>
<PopoverContent
:side-offset="4"
align="start"
:trap-focus="false"
:disable-outside-pointer-events="false"
:hide-when-detached="true"
:data-test="parentDataTest ? `${parentDataTest}-popover` : undefined"
@open-auto-focus="handlePopoverOpenAutoFocus"
:class="[
'z-10001 min-w-(--reka-popover-trigger-width)',
'flex flex-col overflow-hidden',
'rounded-default shadow-overlay border border-transparent',
'bg-select-content-bg',
]"
:style="[
dropdownStyle,
{ maxHeight: 'min(18rem, var(--reka-popover-content-available-height, 18rem))' },
]"
@click.stop
>
<ListboxRoot
:model-value="listboxStringModelValue"
:multiple="multiple"
:disabled="disabled"
class="flex min-h-0 flex-1 flex-col"
@update:model-value="handleListboxUpdate"
>
<!-- Single bordered container wrapping search + list -->
<div
:class="[
'rounded-default border-dropdown-border overflow-hidden border',
'bg-select-content-bg flex min-h-0 flex-1 flex-col',
]"
>
<ListboxFilter
v-if="inputEnabled"
ref="searchFilterRef"
v-model="searchTerm"
auto-focus
:data-test="parentDataTest ? `${parentDataTest}-search` : undefined"
:class="[
'text-input-text w-full shrink-0 bg-transparent px-3',
'placeholder:text-input-placeholder outline-none',
'border-dropdown-border border-b',
heightClasses[size ?? 'md'],
]"
:placeholder="resolvedSearchPlaceholder"
@keydown="handleDropdownKeydown"
/>
<!-- Select-all row: outside the listbox keyboard navigation but still focusable. -->
<div
v-if="selectAll && multiple && selectableOptions.length > 0"
role="checkbox"
tabindex="0"
:aria-label="t('components.select.toggleAllOptions')"
:aria-checked="allSelected ? 'true' : partiallySelected ? 'mixed' : 'false'"
:class="[
'relative flex w-full shrink-0 items-center gap-2',
'py-1.5 ps-3 pe-3 text-sm',
'text-select-item-text',
'cursor-pointer outline-none select-none',
'hover:bg-select-item-hover-bg',
'focus-visible:bg-select-item-hover-bg',
'transition-colors duration-(--duration-hover-out) hover:duration-(--duration-hover-in)',
'border-select-content-border mb-1 border-b pb-1.5',
]"
data-test="o-select-all"
@click="toggleAllSelections"
@keydown.enter.prevent="toggleAllSelections"
@keydown.space.prevent="toggleAllSelections"
>
<span
:class="[
'flex shrink-0 items-center justify-center',
'rounded-checkbox size-3.5 border transition-colors',
allSelected
? 'bg-checkbox-checked-bg border-checkbox-checked-border'
: 'bg-checkbox-bg border-checkbox-border',
]"
aria-hidden="true"
>
<svg
v-if="allSelected"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-checkbox-checked-fg size-full p-0.5"
>
<polyline points="2,6 5,9 10,3" />
</svg>
</span>
<span class="truncate font-medium">{{
t("components.select.selectAllLabel")
}}</span>
</div>
<!-- Consumer-supplied rows rendered above the option list -->
<slot name="before-options" />
<!-- Virtual scroll container — keyboard nav handled by handleDropdownKeydown
on the ListboxFilter input above. Items are index-highlighted reactively.
max-h-72 matches the popover's own 18rem cap exactly, so this inner limit
is never the binding constraint (max-h-60 used to clip a 9-option list
48px short of the space the popover actually had). -->
<div
ref="listboxScrollEl"
:class="[
'overflow-auto',
multiple && rowClickSingleSelect ? 'min-h-24 flex-1' : 'max-h-72',
]"
>
<div
v-if="filteredOptions.length === 0"
class="text-select-placeholder px-3 py-2 text-sm"
>
<slot name="empty">{{ t("components.select.noOptionsFound") }}</slot>
</div>
<!-- Virtualised list — spacer div with absolutely positioned rows -->
<div
v-if="filteredOptions.length > 0"
:style="{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative',
}"
>
<div
v-for="vRow in virtualizer.getVirtualItems()"
:key="`${vRow.key}-${toRekaString(filteredOptions[vRow.index].value)}`"
:data-vrow="vRow.index"
:style="{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${vRow.size}px`,
transform: `translateY(${vRow.start}px)`,
}"
>
<!-- Group header — non-interactive label. Virtualisation
renders each flex as a sibling, so a real ListboxGroup
can't wrap its items. Render a plain styled header
instead of misusing ListboxGroup/Label ARIA roles. -->
<div
v-if="filteredOptions[vRow.index].header"
:class="[
'text-select-item-text flex h-full w-full px-3 py-1 text-xs font-bold select-none',
collapsibleGroups
? 'bg-surface-subtle cursor-pointer items-center gap-1'
: 'bg-select-content-bg pointer-events-none',
]"
@click.stop="
collapsibleGroups && toggleGroup(filteredOptions[vRow.index].label)
"
>
<OIcon
v-if="collapsibleGroups"
name="keyboard-arrow-up"
size="sm"
:class="[
'shrink-0 transition-transform duration-150',
!isGroupCollapsed(filteredOptions[vRow.index].label)
? 'rotate-180'
: '',
]"
/>
{{ filteredOptions[vRow.index].label }}
</div>
<!-- Not a ListboxItem, so Reka can never select it; mousedown.prevent keeps focus in the search input. -->
<div
v-else-if="filteredOptions[vRow.index].expandable"
:data-test="parentDataTest ? `${parentDataTest}-expand` : undefined"
:data-test-value="toRekaString(filteredOptions[vRow.index].value)"
:aria-expanded="isExpanded(filteredOptions[vRow.index].value)"
:class="[
'flex h-full w-full items-center justify-between gap-2 ps-3 pe-3 text-sm',
'text-select-item-text cursor-pointer select-none',
'transition-colors duration-(--duration-hover-out) hover:duration-(--duration-hover-in)',
vRow.index === highlightedIndex
? 'bg-select-item-hover-bg'
: 'hover:bg-select-item-hover-bg',
]"
@mousedown.prevent
@click.stop="toggleExpanded(filteredOptions[vRow.index].value)"
>
<OTruncatedText>{{ filteredOptions[vRow.index].label }}</OTruncatedText>
<OIcon
name="chevron-right"
size="sm"
:class="[
'shrink-0 transition-transform duration-150',
isExpanded(filteredOptions[vRow.index].value) ? 'rotate-90' : '',
]"
/>
</div>
<!-- Regular item -->
<!-- rowClickSingleSelect wrapper: intercepts clicks at capture phase.
Checkbox clicks (marked by @pointerdown on the checkbox span)
fall through to Reka for normal toggle. Row/label clicks replace
the whole selection with just this item and close the dropdown. -->
<div
v-else
class="contents"
@click.capture="
handleItemClickCapture(
$event,
toRekaString(filteredOptions[vRow.index].value),
)
"
>
<ListboxItem
:value="toRekaString(filteredOptions[vRow.index].value)"
:disabled="filteredOptions[vRow.index].disabled"
:data-test="parentDataTest ? `${parentDataTest}-option` : undefined"
:data-test-value="toRekaString(filteredOptions[vRow.index].value)"
:data-test-label="String(filteredOptions[vRow.index].label ?? '')"
:class="[
'relative flex h-full w-full gap-2',
isNestedRow(filteredOptions[vRow.index]) ? 'ps-7' : 'ps-3',
'pe-3 text-sm',
'text-select-item-text',
'cursor-pointer outline-none select-none',
'transition-colors duration-(--duration-hover-out) hover:duration-(--duration-hover-in)',
// Use flex-col for stacked rich items; flex-row for inline subLabel or simple items
(filteredOptions[vRow.index].subLabel &&
!filteredOptions[vRow.index].subLabelInline) ||
filteredOptions[vRow.index].colorPalette?.length
? 'flex-col items-start py-2'
: 'flex-row items-center',
// highlightedIndex-based highlight — works with virtualised items.
// Reka's data-highlighted only fires for direct DOM children of
// ListboxRoot; virtual rows are not, so we track highlight ourselves.
vRow.index === highlightedIndex
? 'bg-select-item-hover-bg'
: 'hover:bg-select-item-hover-bg',
multiple
? ''
: 'data-[state=checked]:bg-select-item-selected-bg data-[state=checked]:text-select-item-selected-text',
'data-disabled:text-select-item-disabled data-disabled:pointer-events-none data-disabled:cursor-not-allowed',
]"
>
<!-- Multi-select: always-visible checkbox indicator -->
<template v-if="multiple">
<span
:class="[
'flex shrink-0 items-center justify-center',
'rounded-checkbox size-3.5 border transition-colors',
selectedValues.includes(filteredOptions[vRow.index].value)
? 'bg-checkbox-checked-bg border-checkbox-checked-border'
: 'bg-checkbox-bg border-checkbox-border',
]"
aria-hidden="true"
data-select-checkbox
>
<svg
v-if="selectedValues.includes(filteredOptions[vRow.index].value)"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-checkbox-checked-fg size-full p-0.5"
>
<polyline points="2,6 5,9 10,3" />
</svg>
</span>
<!-- Separator between checkbox zone and label zone (rowClickSingleSelect only) -->
<span
v-if="rowClickSingleSelect"
class="bg-card-glass-border mx-1 my-1 w-px shrink-0 self-stretch"
aria-hidden="true"
data-select-separator
/>
</template>
<!-- Inline subLabel: name – url on a single row -->
<template
v-if="
filteredOptions[vRow.index].subLabel &&
filteredOptions[vRow.index].subLabelInline
"
>
<span class="shrink-0 font-medium">{{
filteredOptions[vRow.index].label
}}</span>
<span class="text-select-placeholder mx-1 shrink-0">–</span>
<OTruncatedText class="text-text-secondary text-xs">{{
filteredOptions[vRow.index].subLabel
}}</OTruncatedText>
</template>
<!-- Rich item: label + optional badge + optional subLabel + optional color palette swatch.
Wrapped in a single div so ListboxItem's gap-2 does not
add unexpected space between each child and overflow the fixed height. -->
<template
v-else-if="
filteredOptions[vRow.index].subLabel ||
filteredOptions[vRow.index].colorPalette?.length ||
filteredOptions[vRow.index].badge
"
>
<div class="flex w-full flex-col gap-1 overflow-hidden">
<span class="flex w-full items-center gap-1.5 leading-snug">
<!-- The weight is what separates a label from the
sub-label under it. With no sub-label there is
nothing to separate from, and every option in
the list reading bold emphasises nothing. -->
<OTruncatedText
:class="filteredOptions[vRow.index].subLabel ? 'font-medium' : ''"
>{{ filteredOptions[vRow.index].label }}</OTruncatedText
>
<span
v-if="filteredOptions[vRow.index].badge"
class="rounded-default shrink-0 border border-solid"
:class="[
// `pointer-events-auto` keeps a muted badge's tooltip reachable despite the row's own `pointer-events-none`.
filteredOptions[vRow.index].badgeMuted
? 'text-text-secondary border-border-default pointer-events-auto'
: 'text-status-positive border-status-positive',
filteredOptions[vRow.index].badgeTitle
? 'cursor-help'
: undefined,
filteredOptions[vRow.index].badgeStyle
? 'ms-auto inline-flex h-[1.125rem] min-w-[1.125rem] items-center justify-center border-current px-1 text-xs leading-none font-semibold'
: 'text-3xs px-1 py-px leading-tight font-medium',
]"
:title="filteredOptions[vRow.index].badgeTitle"
:style="filteredOptions[vRow.index].badgeStyle"
>{{ filteredOptions[vRow.index].badge }}</span
>
</span>
<OTruncatedText
v-if="filteredOptions[vRow.index].subLabel"
:lines="2"
class="text-text-secondary w-full text-xs leading-snug whitespace-normal"
>{{ filteredOptions[vRow.index].subLabel }}</OTruncatedText
>
<div
v-if="filteredOptions[vRow.index].colorPalette?.length"
class="rounded-default h-1.5 w-full"
:style="{
background: getPaletteGradient(
filteredOptions[vRow.index].colorPalette!,
),
}"
/>
</div>
</template>
<!-- Simple item: optional icon + label -->
<template v-else>
<!-- Per-option icon: prefer a raw Vue component (iconComponent),
fall back to a string name (icon) resolved via OIcon registry.
When iconKey is set but this option has no icon, reserve the
same space with a blank spacer so labels stay aligned. -->
<component
v-if="filteredOptions[vRow.index].iconComponent"
:is="filteredOptions[vRow.index].iconComponent"
class="size-4 shrink-0"
/>
<OIcon
v-else-if="filteredOptions[vRow.index].icon"
:name="filteredOptions[vRow.index].icon"
size="sm"
class="shrink-0"
/>
<span v-else-if="iconKey" class="size-4 shrink-0" />
<OTruncatedText>{{ filteredOptions[vRow.index].label }}</OTruncatedText>
</template>
</ListboxItem>
</div>
</div>
</div>
</div>
<slot name="after-options" />
<!-- rowClickSingleSelect hint bar — inside bordered container so the border wraps it -->
<template v-if="multiple && rowClickSingleSelect">
<div class="bg-input-border mx-2 mt-1 h-px shrink-0" aria-hidden="true" />
<div
class="pointer-events-none flex shrink-0 items-center gap-2 px-3 py-2 select-none"
>
<!-- Checkbox zone hint -->
<span
class="text-2xs text-select-placeholder flex shrink-0 items-center gap-1.5"
>
<span
class="rounded-checkbox border-select-placeholder inline-flex size-3.5 shrink-0 items-center justify-center border"
aria-hidden="true"
>
<svg
viewBox="0 0 10 10"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-2.5 p-px"
>
<polyline points="1.5,5 4,8 8.5,2" />
</svg>
</span>
<span>{{ t("components.select.multiSelect") }}</span>
</span>
<span class="bg-input-border h-3.5 w-px shrink-0" aria-hidden="true" />
<!-- Name zone hint -->
<span
class="text-2xs text-select-placeholder flex shrink-0 items-center gap-1.5"
>
<svg
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="size-3 shrink-0"
aria-hidden="true"
>
<path d="M4 2h6M4 5h6M4 8h3" />
</svg>
<span>{{ t("components.select.singleSelect") }}</span>
</span>
</div>
</template>
</div>
<!-- end bordered container -->
</ListboxRoot>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>
<SelectRoot
v-else
:model-value="stringValue"
:open="selectOpen"
:disabled="disabled"
:name="name"
@update:model-value="handleUpdate"
@update:open="
(v) => {
selectOpen = v;
if (v) emit('open');
else emit('close');
}
"
>
<div ref="selectTriggerWrapperRef" class="relative flex items-center">
<SelectTrigger
:id="inputId"
:tabindex="inputTabindex"
:aria-invalid="hasError || undefined"
:aria-label="insideLabelName"
v-bind="controlAttrs"
:aria-describedby="mergeDescribedBy(errorDescribedBy, controlAttrs)"
:data-test="parentDataTest ? `${parentDataTest}-trigger` : undefined"
:class="
isInlineAppearance
? inlineTriggerClasses
: [
'rounded-default relative flex w-full border',
// In inside-label mode padding is handled per-row
labelPosition === 'inside' && label ? '' : 'ps-3',
'bg-select-bg',
hasError
? 'border-select-border-error focus:ring-select-border-error/30 data-[state=open]:ring-select-border-error/30 focus:ring-[0.125rem] data-[state=open]:ring-[0.125rem]'
: 'border-select-border hover:border-select-border-hover focus:border-select-border-focus focus:ring-focus-ring-accent data-[state=open]:border-select-border-focus data-[state=open]:ring-focus-ring-accent focus:ring-[0.125rem] data-[state=open]:ring-[0.125rem]',
/* Keep the red error border on focus; focus border color applies only when there's no error. */
'focus:outline-none',
'transition-[color,background-color,border-color,box-shadow] duration-150',
'data-disabled:bg-select-disabled-bg data-disabled:border-select-disabled-border data-disabled:cursor-not-allowed data-disabled:border-dashed',
labelPosition === 'inside' && label
? ['flex-col justify-between py-0.5', heightClasses[size ?? 'md']]
: ['items-center', triggerEndPadding, heightClasses[size ?? 'md']],
]
"
>
<!-- Inside label: in-flow with whitespace-nowrap so it drives the trigger's auto-width -->
<span
v-if="label && labelPosition === 'inside'"
class="text-3xs text-text-secondary pointer-events-none ps-3 pe-7 text-start leading-none whitespace-nowrap select-none"
>{{ label }}<span v-if="required" aria-hidden="true">&nbsp;*</span></span
>
<!-- Content row: flex-row for inside-label; display:contents for normal mode -->
<div
:class="
labelPosition === 'inside' && label
? ['flex w-full min-w-0 flex-1 items-center', triggerEndPadding, 'ps-3']
: 'contents'
"
>
<SelectValue
v-if="isInlineAppearance"
:placeholder="placeholder"
:class="['text-start whitespace-nowrap', triggerTextTone]"
>
<slot name="trigger" :value="modelValue" />
</SelectValue>
<!-- reka's SelectValue is pointer-events:none, so the wrapper is what cuts and takes the hover. -->
<OTruncatedText
v-else
:class="[
'flex-1 text-start text-sm',
labelPosition === 'inside' && label ? 'text-xs leading-4' : '',
triggerTextTone,
]"
:tooltip="hasSelection && !$slots.trigger ? undefined : false"
>
<SelectValue :placeholder="placeholder">
<slot name="trigger" :value="modelValue" />
</SelectValue>
</OTruncatedText>
</div>
</SelectTrigger>
<!-- Trailing icons: clear (left) then chevron (right). See note in
the listbox branch above for the pointer-events strategy. -->
<div
:class="[
'pointer-events-none absolute flex items-center gap-1.5',
isInlineAppearance ? 'end-1' : 'end-2.5',
]"
>
<button
v-if="clearable && hasSelection && !isInlineAppearance"
type="button"
tabindex="-1"
:aria-label="t('components.select.clearSelection')"
:class="[
'flex size-3.5 items-center justify-center',
'text-input-clear-btn hover:text-input-clear-btn-hover',
'pointer-events-auto transition-colors',
]"
@click.stop="handleClear"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-3.5"
aria-hidden="true"
>
<path
d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"
/>
</svg>
</button>
<OSpinner v-if="loading" size="xs" />
<span
v-else
aria-hidden="true"
:class="[
'text-select-icon flex items-center justify-center',
'transition-transform duration-150',
isOpen ? 'rotate-180' : '',
]"
>
<!-- chevron-down — rotates 180° when the dropdown is open -->
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</span>
</div>
</div>
<SelectPortal>
<SelectContent
position="popper"
:side-offset="4"
:hide-when-detached="true"
:collision-padding="lgUp ? 0 : 8"
:data-test="parentDataTest ? `${parentDataTest}-popover` : undefined"
:class="[
'z-10001 min-w-(--reka-select-trigger-width) max-lg:max-w-[calc(100vw-1rem)]',
'overflow-hidden',
'max-lg:max-h-[var(--reka-popper-available-height,75vh)] max-lg:overflow-y-auto',
'rounded-default shadow-overlay border border-transparent',
'bg-select-content-bg',
// Clip-path reveal: unveiled at full size from its trigger edge (no
// scale/squish). Wipes down by default; top-placed wipes up. Soft
// ease-out-expo in (200ms), quick wipe out (140ms).
'data-[state=open]:animate-[o2-reveal-down-in_140ms_cubic-bezier(0.16,1,0.3,1)]',
'data-[state=closed]:animate-[o2-reveal-down-out_100ms_cubic-bezier(0.4,0,1,1)]',
'data-[side=top]:data-[state=open]:animate-[o2-reveal-up-in_140ms_cubic-bezier(0.16,1,0.3,1)]',
'data-[side=top]:data-[state=closed]:animate-[o2-reveal-up-out_100ms_cubic-bezier(0.4,0,1,1)]',
]"
:style="[
dropdownStyle,
{ maxHeight: 'min(15rem, var(--reka-select-content-available-height, 15rem))' },
]"
@click.stop
@pointerdown.stop
>
<SelectScrollUpButton class="text-select-icon flex h-6 items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-4"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M11.78 9.78a.75.75 0 0 1-1.06 0L8 7.06 5.28 9.78a.75.75 0 0 1-1.06-1.06l3-3a.75.75 0 0 1 1.06 0l3 3a.75.75 0 0 1 0 1.06Z"
clip-rule="evenodd"
/>
</svg>
</SelectScrollUpButton>
<SelectViewport class="p-1">
<template v-if="normalizedOptions.length && !slots.default">
<OSelectItem
v-for="opt in normalizedOptions"
:key="String(opt.value)"
:value="opt.value"
:label="opt.label"
:disabled="opt.disabled || opt.expandable"
/>
</template>
<div v-else-if="!slots.default" class="text-select-placeholder px-3 py-2 text-sm">
<slot name="empty">{{ t("components.select.noOptionsFound") }}</slot>
</div>
<slot />
</SelectViewport>
<SelectScrollDownButton class="text-select-icon flex h-6 items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-4"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</SelectScrollDownButton>
</SelectContent>
</SelectPortal>
</SelectRoot>
<!-- Error message -->
<span
v-if="effectiveError && effectiveError.trim()"
:id="errorId"
:data-test="parentDataTest ? `${parentDataTest}-error` : undefined"
class="text-select-error-text text-xs leading-none"
role="alert"
>
{{ effectiveError }}
</span>
<!-- Help text -->
<span v-else-if="helpText" class="text-input-help-text text-xs leading-none">
{{ helpText }}
</span>
</div>
</template>