mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-03 16:10:21 +00:00
Co-authored-by: ktx-vaidehi <vaidehi.akhani@kiara.tech> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2159 lines
89 KiB
Vue
2159 lines
89 KiB
Vue
<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"> *</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"> *</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>
|