feat: strengthen module contracts and shared WebUI runtime

This commit is contained in:
2026-07-29 14:16:28 +02:00
parent 53e947935a
commit 68328f3d8e
57 changed files with 4358 additions and 371 deletions

View File

@@ -20,10 +20,26 @@ import DismissibleAlert from "./DismissibleAlert";
import FormField from "./FormField";
import LoadingFrame from "./LoadingFrame";
import PasswordField from "./PasswordField";
import {
ReferenceMultiSelect,
combineReferenceOptionProviders,
customReferenceOption,
staticReferenceOptionProvider,
type ReferenceOption
} from "./ReferenceSelect";
import { platformModuleReferenceProvider } from "../platform/referenceProviders";
import StatusBadge from "./StatusBadge";
import TableActionGroup from "./table/TableActionGroup";
import ToggleSwitch from "./ToggleSwitch";
import { useUnsavedDraftGuard } from "./UnsavedChangesGuard";
import {
usePlatformModules,
usePlatformUiCapabilities
} from "../platform/ModuleContext";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import type {
CredentialReferenceSelectorsUiCapability
} from "./ReferenceSelect";
export type CredentialEnvelopeTargetOption = {
id: string;
@@ -31,11 +47,19 @@ export type CredentialEnvelopeTargetOption = {
secondary?: string | null;
};
export type CredentialEnvelopeServerOption = {
id: string;
label: string;
secondary?: string | null;
disabled?: boolean;
};
export type CredentialEnvelopeManagerProps = {
settings: ApiSettings;
scopeType: Extract<MailProfileScope, "system" | "tenant" | "user" | "group">;
scopeId?: string | null;
targetOptions?: CredentialEnvelopeTargetOption[];
serverOptions?: CredentialEnvelopeServerOption[];
targetLabel?: string;
title?: string;
canWrite: boolean;
@@ -76,10 +100,13 @@ export default function CredentialEnvelopeManager({
scopeType,
scopeId,
targetOptions = [],
serverOptions = [],
targetLabel = "Target",
title = "Credential envelopes",
canWrite
}: CredentialEnvelopeManagerProps) {
const modules = usePlatformModules();
const { translateText } = usePlatformLanguage();
const [selectedTargetId, setSelectedTargetId] = useState(scopeId ?? targetOptions[0]?.id ?? "");
const [credentials, setCredentials] = useState<CredentialEnvelopeSummary[]>([]);
const [loading, setLoading] = useState(false);
@@ -95,6 +122,56 @@ export default function CredentialEnvelopeManager({
: selectedTargetId || null;
const scopeReady = scopeType === "system" || scopeType === "tenant" || Boolean(activeScopeId);
const draftDirty = Boolean(editing) && credentialDraftKey(draft) !== savedDraftKey;
const referenceCapabilities =
usePlatformUiCapabilities<CredentialReferenceSelectorsUiCapability>(
"core.credentialReferenceSelectors"
);
const moduleReferenceOptions = useMemo<ReferenceOption[]>(
() => modules.map((module) => ({
value: module.id,
label: translateText(module.label),
description: `${module.id} · version ${module.version}`,
kind: "module",
sourceModule: "core",
provenance: { version: module.version }
})),
[modules, translateText]
);
const serverReferenceOptions = useMemo<ReferenceOption[]>(
() => serverOptions.map((server) => ({
value: server.id,
label: server.label,
description: server.secondary ?? server.id,
kind: "server",
disabled: server.disabled,
availability: server.disabled ? "inactive" : "available",
provenance: { announcedByConsumer: true }
})),
[serverOptions]
);
const moduleReferenceProvider = useMemo(
() => platformModuleReferenceProvider(settings, moduleReferenceOptions),
[moduleReferenceOptions, settings]
);
const serverReferenceProvider = useMemo(
() =>
combineReferenceOptionProviders([
staticReferenceOptionProvider(serverReferenceOptions),
...referenceCapabilities.map((capability) =>
capability.serverProvider(settings, {
scopeType,
scopeId: activeScopeId
})
)
]),
[
activeScopeId,
referenceCapabilities,
scopeType,
serverReferenceOptions,
settings
]
);
useUnsavedDraftGuard({
dirty: draftDirty,
@@ -436,11 +513,26 @@ export default function CredentialEnvelopeManager({
<p>Empty module or server lists mean every module or server allowed by scope.</p>
</header>
<div className="form-grid two">
<FormField label="Modules" help="Comma-separated module IDs, for example mail, files, calendar, addresses.">
<input value={draft.allowedModules} disabled={saving} onChange={(event) => setDraft({ ...draft, allowedModules: event.target.value })} />
<FormField label="Modules" help="Choose installed modules that may use this credential. Empty means every module allowed by scope.">
<ReferenceMultiSelect
values={splitValues(draft.allowedModules)}
onChange={(values) => setDraft({ ...draft, allowedModules: values.join(", ") })}
provider={moduleReferenceProvider}
aria-label="Credential modules"
placeholder="Add a module"
disabled={saving}
/>
</FormField>
<FormField label="Servers" help="Optional references such as mail:server-id, files:connection-id, calendar:source-id, or addresses:source-id.">
<input value={draft.allowedServerRefs} disabled={saving} onChange={(event) => setDraft({ ...draft, allowedServerRefs: event.target.value })} />
<FormField label="Servers" help="Choose announced servers or enter an explicit module-qualified reference. Empty means every permitted server.">
<ReferenceMultiSelect
values={splitValues(draft.allowedServerRefs)}
onChange={(values) => setDraft({ ...draft, allowedServerRefs: values.join(", ") })}
provider={serverReferenceProvider}
createCustomOption={(value) => customReferenceOption(value, "server")}
aria-label="Credential servers"
placeholder="Add a server reference"
disabled={saving}
/>
</FormField>
<ToggleSwitch checked={draft.inheritToLowerScopes} disabled={saving} onChange={(inheritToLowerScopes) => setDraft({ ...draft, inheritToLowerScopes })} label="Visible to lower scopes" />
<ToggleSwitch checked={draft.isActive} disabled={saving} onChange={(isActive) => setDraft({ ...draft, isActive })} label="Active" />

View File

@@ -0,0 +1,119 @@
import {
useEffect,
useState,
type ReactNode
} from "react";
import { Link } from "react-router-dom";
import { adminErrorMessage } from "./admin/adminUtils";
export type DashboardWidgetDataState<T> = {
data: T | null;
loading: boolean;
error: string;
};
export function useDashboardWidgetData<T>(
load: () => Promise<T>,
refreshKey: number
): DashboardWidgetDataState<T> {
const [state, setState] = useState<DashboardWidgetDataState<T>>({
data: null,
loading: true,
error: ""
});
useEffect(() => {
let active = true;
setState((current) => ({
...current,
loading: true,
error: ""
}));
void load()
.then((data) => {
if (active) setState({ data, loading: false, error: "" });
})
.catch((error: unknown) => {
if (!active) return;
setState((current) => ({
...current,
loading: false,
error: adminErrorMessage(error)
}));
});
return () => {
active = false;
};
}, [load, refreshKey]);
return state;
}
export type DashboardWidgetListItem = {
id: string;
title: ReactNode;
detail?: ReactNode;
meta?: ReactNode;
leading?: ReactNode;
trailing?: ReactNode;
to?: string;
};
export function DashboardWidgetList({
items,
emptyText
}: {
items: DashboardWidgetListItem[];
emptyText: ReactNode;
}) {
if (!items.length) {
return <p className="muted dashboard-contribution-empty">{emptyText}</p>;
}
return (
<ul className="dashboard-contribution-list">
{items.map((item) => {
const content = (
<>
{item.leading && (
<span className="dashboard-contribution-leading">
{item.leading}
</span>
)}
<span className="dashboard-contribution-copy">
<span className="dashboard-contribution-title">
{item.title}
</span>
{item.detail && (
<span className="dashboard-contribution-detail">
{item.detail}
</span>
)}
</span>
{item.meta && (
<span className="dashboard-contribution-meta">
{item.meta}
</span>
)}
{item.trailing && (
<span className="dashboard-contribution-trailing">
{item.trailing}
</span>
)}
</>
);
return (
<li key={item.id}>
{item.to ? (
<Link className="dashboard-contribution-row" to={item.to}>
{content}
</Link>
) : (
<div className="dashboard-contribution-row">{content}</div>
)}
</li>
);
})}
</ul>
);
}

View File

@@ -0,0 +1,472 @@
import { useEffect, useMemo, useState } from "react";
import { X } from "lucide-react";
import SearchableSelect, {
filterSearchableSelectOptions,
type SearchableSelectCreateCustomOption,
type SearchableSelectOption,
type SearchableSelectProps
} from "./SearchableSelect";
import IconButton from "./IconButton";
import type { ApiSettings } from "../types";
export type ReferenceAvailability =
| "available"
| "inactive"
| "unavailable";
export type ReferenceOption = SearchableSelectOption & {
kind?: string | null;
availability?: ReferenceAvailability;
sourceModule?: string | null;
provenance?: Record<string, unknown>;
custom?: boolean;
};
export type ReferenceProviderContext = {
limit: number;
selectedValues: readonly string[];
signal: AbortSignal;
};
export type ReferenceOptionProvider = {
search: (
query: string,
context: ReferenceProviderContext
) => Promise<readonly ReferenceOption[]>;
resolve?: (
values: readonly string[],
context: Omit<ReferenceProviderContext, "limit"> & { limit?: number }
) => Promise<readonly ReferenceOption[]>;
};
export type ConfigurationReferenceSelectorsUiCapability = {
tenantProvider: (settings: ApiSettings) => ReferenceOptionProvider;
changeRequestProvider: (
settings: ApiSettings,
options: {
purpose: string;
tenantId?: string | null;
}
) => ReferenceOptionProvider;
};
export type CredentialReferenceSelectorContext = {
scopeType: "system" | "tenant" | "user" | "group";
scopeId?: string | null;
};
export type CredentialReferenceSelectorsUiCapability = {
serverProvider: (
settings: ApiSettings,
context: CredentialReferenceSelectorContext
) => ReferenceOptionProvider;
};
export type ReferenceSelectProps = Omit<
SearchableSelectProps,
"options" | "loadOptions" | "selectedOption"
> & {
provider: ReferenceOptionProvider;
selectedOption?: ReferenceOption | null;
};
export type ReferenceMultiSelectProps = {
id?: string;
values: readonly string[];
onChange: (values: string[]) => void;
provider: ReferenceOptionProvider;
selectedOptions?: readonly ReferenceOption[];
createCustomOption?: SearchableSelectCreateCustomOption;
"aria-label"?: string;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
disabled?: boolean;
minQueryLength?: number;
searchLimit?: number;
className?: string;
};
const EMPTY_REFERENCE_OPTIONS: readonly ReferenceOption[] = [];
export default function ReferenceSelect({
provider,
value,
selectedOption,
searchLimit = 50,
...props
}: ReferenceSelectProps) {
const [resolved, setResolved] = useState<ReferenceOption | null>(
selectedOption?.value === value ? selectedOption : null
);
useEffect(() => {
if (!value) {
setResolved(null);
return;
}
setResolved(
selectedOption?.value === value
? selectedOption
: unavailableReferenceOption(value)
);
}, [selectedOption, value]);
useEffect(() => {
if (!value || selectedOption?.value === value || !provider.resolve) return;
const controller = new AbortController();
void provider
.resolve([value], {
selectedValues: [value],
signal: controller.signal,
limit: 1
})
.then((options) => {
if (!controller.signal.aborted) {
setResolved(
options.find((option) => option.value === value)
?? unavailableReferenceOption(value)
);
}
})
.catch((error: unknown) => {
if (
!controller.signal.aborted
&& !(error instanceof DOMException && error.name === "AbortError")
) {
setResolved(unavailableReferenceOption(value));
}
});
return () => controller.abort();
}, [provider, selectedOption?.value, value]);
return (
<SearchableSelect
{...props}
value={value}
searchLimit={searchLimit}
selectedOption={resolved}
loadOptions={(query, context) =>
provider.search(query, {
...context,
selectedValues: value ? [value] : []
})
}
/>
);
}
export function ReferenceMultiSelect({
id,
values,
onChange,
provider,
selectedOptions = EMPTY_REFERENCE_OPTIONS,
createCustomOption,
"aria-label": ariaLabel = "Select references",
placeholder = "Add a reference",
searchPlaceholder,
emptyText,
disabled = false,
minQueryLength,
searchLimit = 50,
className = ""
}: ReferenceMultiSelectProps) {
const normalizedValues = useMemo(
() => [...new Set(values.map((value) => value.trim()).filter(Boolean))],
[values]
);
const normalizedValuesKey = normalizedValues.join("\u0000");
const [resolvedOptions, setResolvedOptions] = useState<
readonly ReferenceOption[]
>(selectedOptions);
useEffect(() => {
setResolvedOptions(selectedOptions);
}, [selectedOptions]);
useEffect(() => {
if (!normalizedValues.length || !provider.resolve) return;
const controller = new AbortController();
void provider
.resolve(normalizedValues, {
selectedValues: normalizedValues,
signal: controller.signal,
limit: normalizedValues.length
})
.then((options) => {
if (!controller.signal.aborted) setResolvedOptions(options);
})
.catch((error: unknown) => {
if (
!controller.signal.aborted
&& !(error instanceof DOMException && error.name === "AbortError")
) {
setResolvedOptions(EMPTY_REFERENCE_OPTIONS);
}
});
return () => controller.abort();
}, [normalizedValuesKey, provider]);
const optionsByValue = useMemo(
() =>
new Map(
[...selectedOptions, ...resolvedOptions].map((option) => [
option.value,
option
])
),
[resolvedOptions, selectedOptions]
);
const selected = normalizedValues.map(
(value) =>
optionsByValue.get(value)
?? (createCustomOption?.(value) as ReferenceOption | null | undefined)
?? unavailableReferenceOption(value)
);
const rootClassName = ["reference-multi-select", className]
.filter(Boolean)
.join(" ");
function add(value: string, option: SearchableSelectOption | null) {
if (!value || normalizedValues.includes(value)) return;
if (option) {
setResolvedOptions((current) => [
...current.filter((item) => item.value !== value),
option as ReferenceOption
]);
}
onChange([...normalizedValues, value]);
}
function remove(value: string) {
onChange(normalizedValues.filter((item) => item !== value));
}
return (
<div className={rootClassName}>
{selected.length ? (
<div
className="reference-multi-select-values"
role="list"
aria-label={`${ariaLabel} selected`}
>
{selected.map((option) => (
<div
key={option.value}
className={[
"reference-multi-select-value",
option.availability
? `is-${option.availability}`
: "",
option.custom ? "is-custom" : ""
]
.filter(Boolean)
.join(" ")}
role="listitem"
>
<span className="reference-multi-select-value-copy">
<strong>{option.label}</strong>
<small>{referenceOptionDetail(option)}</small>
</span>
<IconButton
label={`Remove ${option.label}`}
icon={<X size={15} aria-hidden="true" />}
onClick={() => remove(option.value)}
disabled={disabled}
/>
</div>
))}
</div>
) : null}
<SearchableSelect
id={id}
aria-label={ariaLabel}
value=""
onChange={add}
loadOptions={async (query, context) => {
const options = await provider.search(query, {
...context,
selectedValues: normalizedValues
});
return options.filter(
(option) => !normalizedValues.includes(option.value)
);
}}
createCustomOption={(value) => {
if (normalizedValues.includes(value)) return null;
return createCustomOption?.(value) ?? null;
}}
placeholder={placeholder}
searchPlaceholder={searchPlaceholder}
emptyText={emptyText}
disabled={disabled}
minQueryLength={minQueryLength}
searchLimit={searchLimit}
/>
</div>
);
}
export function staticReferenceOptionProvider(
options: readonly ReferenceOption[]
): ReferenceOptionProvider {
const byValue = new Map(options.map((option) => [option.value, option]));
return {
async search(query, context) {
if (context.signal.aborted) throw abortError();
return filterSearchableSelectOptions(
options,
query,
context.limit
) as ReferenceOption[];
},
async resolve(values, context) {
if (context.signal.aborted) throw abortError();
return values.map(
(value) => byValue.get(value) ?? unavailableReferenceOption(value)
);
}
};
}
export function combineReferenceOptionProviders(
providers: readonly ReferenceOptionProvider[]
): ReferenceOptionProvider {
if (providers.length === 1) return providers[0];
async function collect(
operation: (
provider: ReferenceOptionProvider
) => Promise<readonly ReferenceOption[]>
): Promise<ReferenceOption[]> {
const results = await Promise.allSettled(providers.map(operation));
const successful = results.filter(
(
result
): result is PromiseFulfilledResult<readonly ReferenceOption[]> =>
result.status === "fulfilled"
);
if (!successful.length) {
const failure = results.find(
(result): result is PromiseRejectedResult =>
result.status === "rejected"
);
throw failure?.reason ?? new Error("Reference options are unavailable.");
}
return mergeReferenceOptions(
successful.flatMap((result) => [...result.value])
);
}
return {
async search(query, context) {
const options = await collect((provider) =>
provider.search(query, context)
);
return options.slice(0, context.limit);
},
async resolve(values, context) {
const options = await collect((provider) =>
provider.resolve
? provider.resolve(values, context)
: provider.search("", {
...context,
limit: context.limit ?? Math.max(values.length, 50)
})
);
const byValue = new Map(options.map((option) => [option.value, option]));
return values.map(
(value) =>
byValue.get(value)
?? unavailableReferenceOption(value)
);
}
};
}
export function wildcardReferenceOption(
value: string,
kind = "pattern"
): ReferenceOption | null {
const clean = value.trim();
if (!clean || !/[*?[\]]/.test(clean)) return null;
return {
value: clean,
label: clean,
description: "Custom wildcard pattern",
kind,
availability: "available",
custom: true,
provenance: { input: "custom_pattern" }
};
}
export function customReferenceOption(
value: string,
kind = "custom"
): ReferenceOption | null {
const clean = value.trim();
if (!clean) return null;
return {
value: clean,
label: clean,
description: "Custom reference",
kind,
availability: "available",
custom: true,
provenance: { input: "custom_reference" }
};
}
export function unavailableReferenceOption(
value: string,
label = "Unavailable reference"
): ReferenceOption {
return {
value,
label,
description: value,
availability: "unavailable",
disabled: true,
provenance: { retainedReference: true }
};
}
function mergeReferenceOptions(
options: readonly ReferenceOption[]
): ReferenceOption[] {
const merged = new Map<string, ReferenceOption>();
for (const option of options) {
const current = merged.get(option.value);
if (!current || referenceOptionPriority(option) > referenceOptionPriority(current)) {
merged.set(option.value, option);
}
}
return [...merged.values()];
}
function referenceOptionPriority(option: ReferenceOption): number {
if (option.availability === "unavailable") return 0;
if (option.availability === "inactive" || option.disabled) return 1;
return 2;
}
function referenceOptionDetail(option: ReferenceOption): string {
const state = option.custom
? option.kind === "pattern"
? "Pattern"
: "Custom"
: option.availability === "inactive"
? "Inactive"
: option.availability === "unavailable"
? "Unavailable"
: "";
return [state, option.description, option.value !== option.label ? option.value : ""]
.filter(Boolean)
.join(" · ");
}
function abortError(): DOMException {
return new DOMException("The operation was aborted.", "AbortError");
}

View File

@@ -0,0 +1,379 @@
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type FocusEvent,
type KeyboardEvent
} from "react";
import { ChevronDown, Search } from "lucide-react";
import { usePlatformLanguage } from "../i18n/LanguageContext";
export type SearchableSelectOption = {
value: string;
label: string;
description?: string | null;
searchText?: string | null;
disabled?: boolean;
};
export type SearchableSelectLoadOptions = (
query: string,
options: { limit: number; signal: AbortSignal }
) => Promise<readonly SearchableSelectOption[]>;
export type SearchableSelectCreateCustomOption = (
value: string
) => SearchableSelectOption | null;
export type SearchableSelectProps = {
id?: string;
value: string;
onChange: (
value: string,
option: SearchableSelectOption | null
) => void;
options?: readonly SearchableSelectOption[];
loadOptions?: SearchableSelectLoadOptions;
createCustomOption?: SearchableSelectCreateCustomOption;
selectedOption?: SearchableSelectOption | null;
"aria-label"?: string;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
loadingText?: string;
errorText?: string;
disabled?: boolean;
required?: boolean;
minQueryLength?: number;
searchLimit?: number;
debounceMs?: number;
className?: string;
};
const EMPTY_OPTIONS: readonly SearchableSelectOption[] = [];
export function filterSearchableSelectOptions(
options: readonly SearchableSelectOption[],
query: string,
limit = 50
): SearchableSelectOption[] {
const needle = query.trim().toLocaleLowerCase();
const filtered = needle
? options.filter((option) =>
[
option.label,
option.description ?? "",
option.searchText ?? "",
option.value
]
.join(" ")
.toLocaleLowerCase()
.includes(needle)
)
: [...options];
return filtered.slice(0, Math.max(1, Math.floor(limit)));
}
export default function SearchableSelect({
id,
value,
onChange,
options = EMPTY_OPTIONS,
loadOptions,
createCustomOption,
selectedOption,
"aria-label": ariaLabel = "Select an option",
placeholder = "Select an option",
searchPlaceholder = "Search...",
emptyText = "No matching options.",
loadingText = "Loading...",
errorText = "Options could not be loaded.",
disabled = false,
required = false,
minQueryLength = 0,
searchLimit = 50,
debounceMs = 200,
className = ""
}: SearchableSelectProps) {
const { translateText } = usePlatformLanguage();
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, "-");
const pickerId = id || `searchable-select-${generatedId}`;
const listboxId = `${pickerId}-results`;
const statusId = `${pickerId}-status`;
const rootRef = useRef<HTMLDivElement | null>(null);
const resolvedSelected = useMemo(
() =>
selectedOption ??
options.find((option) => option.value === value) ??
null,
[options, selectedOption, value]
);
const selectedLabel = resolvedSelected?.label ?? value;
const [inputValue, setInputValue] = useState(selectedLabel);
const [results, setResults] =
useState<readonly SearchableSelectOption[]>(EMPTY_OPTIONS);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [failed, setFailed] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const normalizedMinLength = Math.max(0, Math.floor(minQueryLength));
const normalizedLimit = Math.max(1, Math.min(200, Math.floor(searchLimit)));
const visibleResults = useMemo(() => {
const customValue = inputValue.trim();
const customOption = customValue ? createCustomOption?.(customValue) : null;
if (
!customOption
|| results.some((option) => option.value === customOption.value)
) {
return results;
}
return [...results, customOption];
}, [createCustomOption, inputValue, results]);
useEffect(() => {
if (!open) setInputValue(selectedLabel);
}, [open, selectedLabel]);
useEffect(() => {
if (!open || disabled) {
setLoading(false);
setFailed(false);
setResults(EMPTY_OPTIONS);
return;
}
const query = inputValue.trim();
if (query.length < normalizedMinLength) {
setLoading(false);
setFailed(false);
setResults(EMPTY_OPTIONS);
return;
}
if (!loadOptions) {
setResults(filterSearchableSelectOptions(options, query, normalizedLimit));
setLoading(false);
setFailed(false);
return;
}
const controller = new AbortController();
const timer = window.setTimeout(() => {
setLoading(true);
setFailed(false);
void loadOptions(query, {
limit: normalizedLimit,
signal: controller.signal
})
.then((nextOptions) => {
if (!controller.signal.aborted) setResults(nextOptions);
})
.catch((error: unknown) => {
if (
controller.signal.aborted ||
(error instanceof DOMException && error.name === "AbortError")
) {
return;
}
setResults(EMPTY_OPTIONS);
setFailed(true);
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
}, Math.max(0, Math.floor(debounceMs)));
return () => {
controller.abort();
window.clearTimeout(timer);
};
}, [
debounceMs,
disabled,
inputValue,
loadOptions,
normalizedLimit,
normalizedMinLength,
open,
options
]);
useEffect(() => {
const selectedIndex = visibleResults.findIndex(
(option) => option.value === value && !option.disabled
);
const firstEnabledIndex = visibleResults.findIndex(
(option) => !option.disabled
);
setActiveIndex(selectedIndex >= 0 ? selectedIndex : firstEnabledIndex);
}, [value, visibleResults]);
function choose(option: SearchableSelectOption) {
if (disabled || option.disabled) return;
onChange(option.value, option);
setInputValue(option.label);
setOpen(false);
setActiveIndex(-1);
}
function moveActive(delta: 1 | -1) {
const enabledIndexes = visibleResults
.map((option, index) => ({ option, index }))
.filter(({ option }) => !option.disabled)
.map(({ index }) => index);
if (!enabledIndexes.length) {
setActiveIndex(-1);
return;
}
const currentPosition = enabledIndexes.indexOf(activeIndex);
const nextPosition =
currentPosition < 0
? delta > 0
? 0
: enabledIndexes.length - 1
: (currentPosition + delta + enabledIndexes.length) %
enabledIndexes.length;
setActiveIndex(enabledIndexes[nextPosition]);
}
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === "ArrowDown") {
event.preventDefault();
setOpen(true);
moveActive(1);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setOpen(true);
moveActive(-1);
return;
}
if (event.key === "Enter" && open && activeIndex >= 0) {
const option = visibleResults[activeIndex];
if (option && !option.disabled) {
event.preventDefault();
choose(option);
}
return;
}
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
setInputValue(selectedLabel);
setActiveIndex(-1);
}
}
function closeOnFocusLeave(event: FocusEvent<HTMLDivElement>) {
const nextTarget = event.relatedTarget;
if (nextTarget instanceof Node && rootRef.current?.contains(nextTarget)) {
return;
}
setOpen(false);
setInputValue(selectedLabel);
setActiveIndex(-1);
}
const minimumLengthMissing =
inputValue.trim().length < normalizedMinLength;
const visibleStatus = loading
? loadingText
: failed
? errorText
: minimumLengthMissing
? `Type at least ${normalizedMinLength} characters.`
: !visibleResults.length
? emptyText
: "";
const rootClassName = ["searchable-select", className]
.filter(Boolean)
.join(" ");
return (
<div
ref={rootRef}
className={rootClassName}
onBlur={closeOnFocusLeave}
>
<div className="searchable-select-control">
<Search size={17} aria-hidden="true" />
<input
id={pickerId}
type="search"
role="combobox"
value={inputValue}
disabled={disabled}
required={required && !value}
placeholder={translateText(
open ? searchPlaceholder : placeholder
)}
aria-label={translateText(ariaLabel)}
aria-autocomplete="list"
aria-expanded={open}
aria-controls={listboxId}
aria-activedescendant={
open && activeIndex >= 0
? `${pickerId}-option-${activeIndex}`
: undefined
}
aria-describedby={statusId}
aria-busy={loading}
onFocus={() => {
if (!open) setInputValue("");
setOpen(true);
}}
onChange={(event) => {
setInputValue(event.target.value);
setOpen(true);
setFailed(false);
if (value) onChange("", null);
}}
onKeyDown={handleKeyDown}
/>
<ChevronDown size={17} aria-hidden="true" />
</div>
{open && !disabled && (
<div
id={listboxId}
className="searchable-select-results"
role="listbox"
aria-label={translateText(ariaLabel)}
>
{visibleResults.map((option, index) => (
<button
key={option.value}
id={`${pickerId}-option-${index}`}
type="button"
role="option"
aria-selected={option.value === value}
className={[
"searchable-select-option",
option.value === value ? "is-selected" : "",
index === activeIndex ? "is-active" : ""
]
.filter(Boolean)
.join(" ")}
disabled={option.disabled}
onMouseEnter={() => {
if (!option.disabled) setActiveIndex(index);
}}
onClick={() => choose(option)}
>
<strong>{option.label}</strong>
{option.description && <small>{option.description}</small>}
</button>
))}
{visibleStatus && (
<p className="searchable-select-message">
{translateText(visibleStatus)}
</p>
)}
</div>
)}
<span id={statusId} className="sr-only" aria-live="polite">
{translateText(visibleStatus)}
</span>
</div>
);
}