feat(core): add bounded people picker foundation

This commit is contained in:
2026-07-22 03:01:56 +02:00
parent 17376332a2
commit 22646c614c
10 changed files with 1137 additions and 0 deletions

View File

@@ -0,0 +1,517 @@
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type FormEvent,
type FocusEvent,
type KeyboardEvent,
type ReactNode
} from "react";
import { Plus, Search, Trash2, UserPlus } from "lucide-react";
import { i18nMessage, usePlatformLanguage } from "../../i18n/LanguageContext";
import { isValidEmailAddress } from "../../utils/emailAddresses";
import Button from "../Button";
import FieldLabel from "../help/FieldLabel";
import DataGrid, { DataGridEmptyAction, type DataGridColumn } from "../table/DataGrid";
import TableActionGroup from "../table/TableActionGroup";
import {
dedupePeoplePickerItems,
externalPeoplePickerItem,
peoplePickerDedupeKey,
selectionFromPeoplePickerCandidate,
type PeoplePickerItem,
type PeoplePickerSearch,
type PeoplePickerSearchCandidate,
type PeoplePickerSearchGroup
} from "./peoplePickerTypes";
export type PeoplePickerProps = {
id?: string;
label?: ReactNode;
help?: ReactNode;
selectedLabel?: ReactNode;
value: readonly PeoplePickerItem[];
onChange: (value: PeoplePickerItem[]) => void;
search: PeoplePickerSearch;
disabled?: boolean;
required?: boolean;
allowManualExternal?: boolean;
manualEmailRequired?: boolean;
minQueryLength?: number;
searchLimit?: number;
debounceMs?: number;
className?: string;
};
type IndexedCandidate = {
candidate: PeoplePickerSearchCandidate;
group: PeoplePickerSearchGroup;
index: number;
};
const I18N = {
people: "i18n:govoplan-core.people.b37554f6",
searchPeople: "i18n:govoplan-core.search_people_and_contacts.8e028a78",
searchPlaceholder: "i18n:govoplan-core.search_by_name_or_email_address.6283d84d",
searching: "i18n:govoplan-core.searching.1a6a5ba8",
noMatches: "i18n:govoplan-core.no_matching_people_or_contacts_found.4c134688",
addExternal: "i18n:govoplan-core.add_external_person.b19c7abc",
externalPerson: "i18n:govoplan-core.external_person.35115d8e",
selectedPeople: "i18n:govoplan-core.selected_people.49a953a1",
noSelection: "i18n:govoplan-core.no_people_selected_yet.9383cc74",
source: "i18n:govoplan-core.source.6da13add",
addPerson: "i18n:govoplan-core.add_person.3b10561b",
removePerson: "i18n:govoplan-core.remove_person.2ea46758",
alreadySelected: "i18n:govoplan-core.this_person_is_already_selected.d337154c",
enterName: "i18n:govoplan-core.enter_a_name.86c810d9",
searchFailed: "i18n:govoplan-core.unable_to_search_people_right_now.fcf680f7",
addSomeone: "i18n:govoplan-core.add_someone_to_the_selection.c617dd69"
} as const;
function translatedNode(node: ReactNode, translateText: (value: string) => string): ReactNode {
return typeof node === "string" ? translateText(node) : node;
}
function flattenGroups(groups: readonly PeoplePickerSearchGroup[]): IndexedCandidate[] {
const flattened: IndexedCandidate[] = [];
const seen = new Set<string>();
for (const group of groups) {
for (const candidate of group.candidates) {
if (!candidate.selection_key || seen.has(candidate.selection_key)) continue;
seen.add(candidate.selection_key);
flattened.push({ candidate, group, index: flattened.length });
}
}
return flattened;
}
export default function PeoplePicker({
id,
label = I18N.people,
help,
selectedLabel = I18N.selectedPeople,
value,
onChange,
search,
disabled = false,
required = false,
allowManualExternal = true,
manualEmailRequired = true,
minQueryLength = 2,
searchLimit = 25,
debounceMs = 250,
className = ""
}: PeoplePickerProps) {
const { translateText } = usePlatformLanguage();
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, "-");
const pickerId = id || `people-picker-${generatedId}`;
const labelId = `${pickerId}-label`;
const inputId = `${pickerId}-search`;
const listboxId = `${pickerId}-results`;
const statusId = `${pickerId}-status`;
const searchInputRef = useRef<HTMLInputElement | null>(null);
const manualNameRef = useRef<HTMLInputElement | null>(null);
const manualEmailRef = useRef<HTMLInputElement | null>(null);
const selectedItems = useMemo(() => dedupePeoplePickerItems(value), [value]);
const selectedKeys = useMemo(
() => new Set(selectedItems.map(peoplePickerDedupeKey)),
[selectedItems]
);
const [query, setQuery] = useState("");
const [groups, setGroups] = useState<readonly PeoplePickerSearchGroup[]>([]);
const [loading, setLoading] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
const [searchError, setSearchError] = useState("");
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const [manualOpen, setManualOpen] = useState(false);
const [manualName, setManualName] = useState("");
const [manualEmail, setManualEmail] = useState("");
const [manualError, setManualError] = useState("");
const normalizedMinQueryLength = Math.max(0, Math.floor(minQueryLength));
const normalizedSearchLimit = Math.max(1, Math.min(Math.floor(searchLimit), 100));
const flattened = useMemo(() => flattenGroups(groups), [groups]);
function candidateUnavailable(candidate: PeoplePickerSearchCandidate): boolean {
return Boolean(candidate.disabled || selectedKeys.has(peoplePickerDedupeKey(candidate)));
}
useEffect(() => {
const normalizedQuery = query.trim();
if (disabled || normalizedQuery.length < normalizedMinQueryLength) {
setGroups([]);
setLoading(false);
setHasSearched(false);
setSearchError("");
return;
}
const controller = new AbortController();
const timer = window.setTimeout(() => {
setLoading(true);
setSearchError("");
void search(normalizedQuery, { limit: normalizedSearchLimit, signal: controller.signal })
.then((nextGroups) => {
if (controller.signal.aborted) return;
setGroups(nextGroups);
setHasSearched(true);
})
.catch((error: unknown) => {
if (controller.signal.aborted || (error instanceof DOMException && error.name === "AbortError")) return;
setGroups([]);
setHasSearched(true);
setSearchError(I18N.searchFailed);
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
}, Math.max(0, Math.floor(debounceMs)));
return () => {
controller.abort();
window.clearTimeout(timer);
};
}, [debounceMs, disabled, normalizedMinQueryLength, normalizedSearchLimit, query, search]);
useEffect(() => {
const firstAvailable = flattened.find((item) => !candidateUnavailable(item.candidate));
setActiveIndex(firstAvailable?.index ?? -1);
}, [flattened, selectedKeys]);
useEffect(() => {
if (manualOpen) manualNameRef.current?.focus();
}, [manualOpen]);
function focusSearch() {
if (disabled) return;
setOpen(true);
searchInputRef.current?.focus();
}
function addCandidate(candidate: PeoplePickerSearchCandidate) {
if (disabled || candidateUnavailable(candidate)) return;
onChange(dedupePeoplePickerItems([...selectedItems, selectionFromPeoplePickerCandidate(candidate)]));
setQuery("");
setGroups([]);
setHasSearched(false);
setOpen(false);
window.requestAnimationFrame(() => searchInputRef.current?.focus());
}
function removeItem(item: PeoplePickerItem) {
if (disabled) return;
const removedKey = peoplePickerDedupeKey(item);
onChange(selectedItems.filter((candidate) => peoplePickerDedupeKey(candidate) !== removedKey));
}
function moveActive(delta: 1 | -1) {
const available = flattened.filter((item) => !candidateUnavailable(item.candidate));
if (available.length === 0) {
setActiveIndex(-1);
return;
}
const currentPosition = available.findIndex((item) => item.index === activeIndex);
const nextPosition = currentPosition < 0
? (delta > 0 ? 0 : available.length - 1)
: (currentPosition + delta + available.length) % available.length;
setActiveIndex(available[nextPosition].index);
}
function onSearchKeyDown(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" && activeIndex >= 0 && open) {
event.preventDefault();
const active = flattened.find((item) => item.index === activeIndex);
if (active) addCandidate(active.candidate);
return;
}
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
setActiveIndex(-1);
}
}
function addManualPerson(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const name = manualName.trim();
const email = manualEmail.trim().toLowerCase();
if (!name) {
setManualError(I18N.enterName);
manualNameRef.current?.focus();
return;
}
if ((manualEmailRequired && !email) || (email && !isValidEmailAddress(email))) {
setManualError("i18n:govoplan-core.enter_a_valid_email_address.2e09edea");
manualEmailRef.current?.focus();
return;
}
const item = externalPeoplePickerItem(name, email || null);
if (selectedKeys.has(peoplePickerDedupeKey(item))) {
setManualError(I18N.alreadySelected);
return;
}
onChange(dedupePeoplePickerItems([...selectedItems, item]));
setManualName("");
setManualEmail("");
setManualError("");
setManualOpen(false);
}
function closeResultsOnFocusLeave(event: FocusEvent<HTMLElement>) {
const nextTarget = event.relatedTarget;
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;
setOpen(false);
}
const statusMessage = loading
? I18N.searching
: searchError
? searchError
: query.trim() && query.trim().length < normalizedMinQueryLength
? i18nMessage("i18n:govoplan-core.type_at_least__value0__characters_to_search.d7d04bad", {
value0: normalizedMinQueryLength
})
: hasSearched && flattened.length === 0
? I18N.noMatches
: "";
const resultsVisible = open && !disabled && query.trim().length >= normalizedMinQueryLength;
const columns: DataGridColumn<PeoplePickerItem>[] = [
{
id: "name",
header: "i18n:govoplan-core.name.709a2322",
minWidth: 180,
resizable: true,
render: (item) => (
<div className="people-picker-selected-person">
<strong>{item.display_name}</strong>
{item.description && <small>{item.description}</small>}
</div>
)
},
{
id: "email",
header: "i18n:govoplan-core.email_address.c94d3175",
minWidth: 220,
resizable: true,
value: (item) => item.email || "—"
},
{
id: "source",
header: I18N.source,
minWidth: 130,
resizable: true,
value: (item) => translateText(item.source_label || kindLabel(item.kind))
},
{
id: "actions",
header: "i18n:govoplan-core.actions.c3cd636a",
width: 104,
sticky: "end",
align: "right",
render: (item) => (
<TableActionGroup
minimumSlots={2}
actions={[
{
id: "add",
label: I18N.addPerson,
icon: <Plus size={16} aria-hidden="true" />,
disabled,
onClick: focusSearch
},
{
id: "remove",
label: I18N.removePerson,
icon: <Trash2 size={16} aria-hidden="true" />,
variant: "danger",
disabled,
onClick: () => removeItem(item)
}
]}
/>
)
}
];
return (
<section className={`people-picker ${className}`.trim()} aria-labelledby={labelId} onBlur={closeResultsOnFocusLeave}>
<div className="people-picker-search-field">
<label htmlFor={inputId} id={labelId}>
<FieldLabel help={help}>{translatedNode(label, translateText)}</FieldLabel>
</label>
<div className="people-picker-search-control">
<Search size={17} aria-hidden="true" />
<input
ref={searchInputRef}
id={inputId}
type="search"
role="combobox"
value={query}
disabled={disabled}
required={required && selectedItems.length === 0}
placeholder={translateText(I18N.searchPlaceholder)}
aria-label={translateText(I18N.searchPeople)}
aria-autocomplete="list"
aria-expanded={resultsVisible}
aria-controls={listboxId}
aria-activedescendant={activeIndex >= 0 ? `${pickerId}-option-${activeIndex}` : undefined}
aria-describedby={statusId}
aria-busy={loading}
onFocus={() => setOpen(true)}
onChange={(event) => {
setQuery(event.target.value);
setOpen(true);
setSearchError("");
}}
onKeyDown={onSearchKeyDown}
/>
</div>
{resultsVisible && (
<div id={listboxId} className="people-picker-results" role="listbox" aria-label={translateText(I18N.searchPeople)}>
{groups.map((group) => {
const candidates = flattened.filter((item) => item.group.key === group.key);
if (candidates.length === 0) return null;
const groupLabelId = `${pickerId}-group-${group.key.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
return (
<div key={group.key} className="people-picker-result-group" role="group" aria-labelledby={groupLabelId}>
<div id={groupLabelId} className="people-picker-result-group-label">{translateText(group.label)}</div>
{candidates.map(({ candidate, index }) => {
const selected = selectedKeys.has(peoplePickerDedupeKey(candidate));
const unavailable = candidateUnavailable(candidate);
const disabledReason = selected ? I18N.alreadySelected : candidate.disabled_reason;
return (
<button
id={`${pickerId}-option-${index}`}
key={candidate.selection_key}
type="button"
role="option"
className={`people-picker-result ${activeIndex === index ? "is-active" : ""}`.trim()}
aria-selected={selected}
aria-disabled={unavailable}
disabled={unavailable}
title={disabledReason ? translateText(disabledReason) : undefined}
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => !unavailable && setActiveIndex(index)}
onFocus={() => !unavailable && setActiveIndex(index)}
onClick={() => addCandidate(candidate)}
>
<span className="people-picker-result-main">
<strong>{candidate.display_name}</strong>
{candidate.email && <small>{candidate.email}</small>}
</span>
<span className="people-picker-result-context">
{candidate.description && <small>{candidate.description}</small>}
<small>{translateText(candidate.source_label || group.label)}</small>
</span>
</button>
);
})}
</div>
);
})}
</div>
)}
<div id={statusId} className={`people-picker-status ${searchError ? "danger-text" : ""}`.trim()} role={searchError ? "alert" : "status"} aria-live="polite">
{statusMessage && translateText(statusMessage)}
</div>
</div>
{allowManualExternal && (
<div className="people-picker-manual">
<Button
type="button"
variant="secondary"
disabled={disabled}
aria-expanded={manualOpen}
onClick={() => {
setManualOpen((current) => !current);
setManualError("");
}}
>
<UserPlus size={16} aria-hidden="true" />
{translateText(I18N.addExternal)}
</Button>
{manualOpen && (
<form className="people-picker-manual-form" onSubmit={addManualPerson}>
<label>
<FieldLabel>{translateText("i18n:govoplan-core.name.709a2322")}</FieldLabel>
<input
ref={manualNameRef}
value={manualName}
disabled={disabled}
required
autoComplete="name"
onChange={(event) => {
setManualName(event.target.value);
setManualError("");
}}
/>
</label>
<label>
<FieldLabel>{translateText("i18n:govoplan-core.email_address.c94d3175")}</FieldLabel>
<input
ref={manualEmailRef}
type="email"
inputMode="email"
value={manualEmail}
disabled={disabled}
required={manualEmailRequired}
autoComplete="email"
onChange={(event) => {
setManualEmail(event.target.value);
setManualError("");
}}
/>
</label>
<div className="people-picker-manual-actions">
<Button type="button" disabled={disabled} onClick={() => setManualOpen(false)}>
{translateText("i18n:govoplan-core.cancel.77dfd213")}
</Button>
<Button type="submit" variant="primary" disabled={disabled}>
{translateText(I18N.addPerson)}
</Button>
</div>
{manualError && <p className="form-help danger-text" role="alert">{translateText(manualError)}</p>}
</form>
)}
</div>
)}
<div className="people-picker-selection" aria-labelledby={`${pickerId}-selection-label`}>
<span id={`${pickerId}-selection-label`}><FieldLabel>{translatedNode(selectedLabel, translateText)}</FieldLabel></span>
<DataGrid
id={`${pickerId}-selection`}
rows={selectedItems}
columns={columns}
getRowKey={peoplePickerDedupeKey}
emptyText={I18N.noSelection}
emptyActionColumnId="actions"
emptyAction={<DataGridEmptyAction disabled={disabled} reorderable={false} onAdd={focusSearch} label={I18N.addSomeone} />}
initialFit="container"
/>
</div>
</section>
);
}
function kindLabel(kind: string): string {
if (kind === "account") return "i18n:govoplan-core.accounts.36bae316";
if (kind === "contact") return "i18n:govoplan-core.contacts.b0dd615c";
if (kind === "external") return I18N.externalPerson;
return kind;
}

View File

@@ -0,0 +1,78 @@
export type PeoplePickerItemKind = "account" | "contact" | "external" | "function" | (string & {});
export type PeoplePickerItem = {
selection_key: string;
kind: PeoplePickerItemKind;
reference_id?: string | null;
display_name: string;
email?: string | null;
source_module?: string | null;
source_label?: string | null;
source_ref?: string | null;
source_revision?: string | null;
description?: string | null;
provenance?: Record<string, unknown>;
metadata?: Record<string, unknown>;
};
export type PeoplePickerSearchCandidate = PeoplePickerItem & {
disabled?: boolean;
disabled_reason?: string | null;
};
export type PeoplePickerSearchGroup = {
key: string;
label: string;
candidates: readonly PeoplePickerSearchCandidate[];
};
export type PeoplePickerSearchOptions = {
limit: number;
signal: AbortSignal;
};
export type PeoplePickerSearch = (
query: string,
options: PeoplePickerSearchOptions
) => Promise<readonly PeoplePickerSearchGroup[]>;
export function peoplePickerDedupeKey(item: Pick<PeoplePickerItem, "selection_key" | "email">): string {
const email = item.email?.trim().toLowerCase();
return email ? `email:${email}` : `selection:${item.selection_key.trim().toLowerCase()}`;
}
export function dedupePeoplePickerItems(items: readonly PeoplePickerItem[]): PeoplePickerItem[] {
const seen = new Set<string>();
const result: PeoplePickerItem[] = [];
for (const item of items) {
const key = peoplePickerDedupeKey(item);
if (!item.selection_key.trim() || seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
}
export function externalPeoplePickerItem(displayName: string, email?: string | null): PeoplePickerItem {
const normalizedName = displayName.trim();
const normalizedEmail = email?.trim().toLowerCase() || null;
const identityPart = normalizedEmail || normalizedName.toLowerCase();
return {
selection_key: `external:${identityPart}`,
kind: "external",
reference_id: null,
display_name: normalizedName,
email: normalizedEmail,
source_module: null,
source_label: "i18n:govoplan-core.external_person.35115d8e",
source_ref: null,
source_revision: null,
provenance: {},
metadata: { manual: true }
};
}
export function selectionFromPeoplePickerCandidate(candidate: PeoplePickerSearchCandidate): PeoplePickerItem {
const { disabled: _disabled, disabled_reason: _disabledReason, ...selection } = candidate;
return selection;
}

View File

@@ -563,6 +563,25 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.word_document.f593629d": "Word document",
"i18n:govoplan-core.working.13b7bfca": "Working…",
"i18n:govoplan-core.workspace.4ca0a75c": "Workspace",
"i18n:govoplan-core.accounts.36bae316": "Accounts",
"i18n:govoplan-core.contacts.b0dd615c": "Contacts",
"i18n:govoplan-core.people.b37554f6": "People",
"i18n:govoplan-core.search_people_and_contacts.8e028a78": "Search people and contacts",
"i18n:govoplan-core.search_by_name_or_email_address.6283d84d": "Search by name or email address",
"i18n:govoplan-core.searching.1a6a5ba8": "Searching…",
"i18n:govoplan-core.no_matching_people_or_contacts_found.4c134688": "No matching people or contacts found.",
"i18n:govoplan-core.external_person.35115d8e": "External person",
"i18n:govoplan-core.add_external_person.b19c7abc": "Add external person",
"i18n:govoplan-core.selected_people.49a953a1": "Selected people",
"i18n:govoplan-core.no_people_selected_yet.9383cc74": "No people selected yet.",
"i18n:govoplan-core.source.6da13add": "Source",
"i18n:govoplan-core.add_person.3b10561b": "Add person",
"i18n:govoplan-core.remove_person.2ea46758": "Remove person",
"i18n:govoplan-core.this_person_is_already_selected.d337154c": "This person is already selected.",
"i18n:govoplan-core.enter_a_name.86c810d9": "Enter a name.",
"i18n:govoplan-core.unable_to_search_people_right_now.fcf680f7": "Unable to search people right now.",
"i18n:govoplan-core.type_at_least__value0__characters_to_search.d7d04bad": "Type at least {value0} characters to search.",
"i18n:govoplan-core.add_someone_to_the_selection.c617dd69": "Add someone to the selection",
"i18n:govoplan-core.xlsx_spreadsheet.db8c2fc9": "XLSX spreadsheet",
"i18n:govoplan-core.yes_active.b1ef7d60": "Yes / active",
"i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56": "Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.",
@@ -1132,6 +1151,25 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.word_document.f593629d": "Word document",
"i18n:govoplan-core.working.13b7bfca": "Working…",
"i18n:govoplan-core.workspace.4ca0a75c": "Arbeitsbereich",
"i18n:govoplan-core.accounts.36bae316": "Benutzerkonten",
"i18n:govoplan-core.contacts.b0dd615c": "Kontakte",
"i18n:govoplan-core.people.b37554f6": "Personen",
"i18n:govoplan-core.search_people_and_contacts.8e028a78": "Personen und Kontakte suchen",
"i18n:govoplan-core.search_by_name_or_email_address.6283d84d": "Nach Name oder E-Mail-Adresse suchen",
"i18n:govoplan-core.searching.1a6a5ba8": "Suche…",
"i18n:govoplan-core.no_matching_people_or_contacts_found.4c134688": "Keine passenden Personen oder Kontakte gefunden.",
"i18n:govoplan-core.external_person.35115d8e": "Externe Person",
"i18n:govoplan-core.add_external_person.b19c7abc": "Externe Person hinzufügen",
"i18n:govoplan-core.selected_people.49a953a1": "Ausgewählte Personen",
"i18n:govoplan-core.no_people_selected_yet.9383cc74": "Noch keine Personen ausgewählt.",
"i18n:govoplan-core.source.6da13add": "Quelle",
"i18n:govoplan-core.add_person.3b10561b": "Person hinzufügen",
"i18n:govoplan-core.remove_person.2ea46758": "Person entfernen",
"i18n:govoplan-core.this_person_is_already_selected.d337154c": "Diese Person ist bereits ausgewählt.",
"i18n:govoplan-core.enter_a_name.86c810d9": "Geben Sie einen Namen ein.",
"i18n:govoplan-core.unable_to_search_people_right_now.fcf680f7": "Die Personensuche ist derzeit nicht möglich.",
"i18n:govoplan-core.type_at_least__value0__characters_to_search.d7d04bad": "Geben Sie mindestens {value0} Zeichen für die Suche ein.",
"i18n:govoplan-core.add_someone_to_the_selection.c617dd69": "Person zur Auswahl hinzufügen",
"i18n:govoplan-core.xlsx_spreadsheet.db8c2fc9": "XLSX spreadsheet",
"i18n:govoplan-core.yes_active.b1ef7d60": "Yes / active",
"i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56": "Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.",

View File

@@ -84,6 +84,10 @@ export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
export { default as PageTitle } from "./components/PageTitle";
export { default as PasswordField } from "./components/PasswordField";
export { default as PeoplePicker } from "./components/people/PeoplePicker";
export type { PeoplePickerProps } from "./components/people/PeoplePicker";
export { dedupePeoplePickerItems, externalPeoplePickerItem, peoplePickerDedupeKey, selectionFromPeoplePickerCandidate } from "./components/people/peoplePickerTypes";
export type { PeoplePickerItem, PeoplePickerItemKind, PeoplePickerSearch, PeoplePickerSearchCandidate, PeoplePickerSearchGroup, PeoplePickerSearchOptions } from "./components/people/peoplePickerTypes";
export { default as ResourceAccessExplanation, formatResourceAccessProvenanceDetails, resourceAccessProvenanceKindLabel } from "./components/ResourceAccessExplanation";
export type { ResourceAccessExplanationProps } from "./components/ResourceAccessExplanation";
export type { PasswordFieldProps } from "./components/PasswordField";

View File

@@ -1516,6 +1516,166 @@
display: none;
}
/* Principal-aware account/contact picker with structured external entry. */
.people-picker {
display: grid;
gap: 16px;
min-width: 0;
}
.people-picker-search-field,
.people-picker-selection,
.people-picker-manual {
display: grid;
gap: 7px;
min-width: 0;
}
.people-picker-search-control {
position: relative;
display: flex;
align-items: center;
}
.people-picker-search-control > svg {
position: absolute;
left: 12px;
z-index: 1;
color: var(--muted);
pointer-events: none;
}
.people-picker-search-control input {
padding-left: 38px;
}
.people-picker-search-control:focus-within > svg {
color: var(--primary);
}
.people-picker-results {
display: grid;
max-height: 360px;
overflow: auto;
border: var(--border-line-dark);
border-radius: 8px;
background: var(--surface);
box-shadow: var(--shadow-popover);
padding: 5px;
}
.people-picker-result-group {
display: grid;
gap: 2px;
}
.people-picker-result-group + .people-picker-result-group {
margin-top: 5px;
border-top: var(--border-line);
padding-top: 5px;
}
.people-picker-result-group-label {
color: var(--text-label);
font-size: 11px;
font-weight: 800;
letter-spacing: .04em;
padding: 5px 9px 3px;
text-transform: uppercase;
}
.people-picker-result {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(120px, auto);
align-items: center;
gap: 16px;
width: 100%;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text);
cursor: pointer;
padding: 8px 9px;
text-align: left;
}
.people-picker-result:hover:not(:disabled),
.people-picker-result.is-active:not(:disabled),
.people-picker-result:focus-visible:not(:disabled) {
background: var(--primary-soft);
box-shadow: var(--primary-inset-ring);
outline: none;
}
.people-picker-result:disabled {
background: var(--control-disabled-bg);
color: var(--control-disabled-text);
cursor: not-allowed;
}
.people-picker-result-main,
.people-picker-result-context,
.people-picker-selected-person {
display: grid;
gap: 2px;
min-width: 0;
}
.people-picker-result-main strong,
.people-picker-result-main small,
.people-picker-result-context small,
.people-picker-selected-person strong,
.people-picker-selected-person small {
overflow-wrap: anywhere;
}
.people-picker-result-main small,
.people-picker-result-context,
.people-picker-selected-person small {
color: var(--muted);
font-size: 12px;
}
.people-picker-result-context {
justify-items: end;
text-align: right;
}
.people-picker-status {
min-height: 18px;
color: var(--muted);
font-size: 12px;
}
.people-picker-manual {
justify-items: start;
}
.people-picker-manual-form {
display: grid;
grid-template-columns: minmax(160px, 1fr) minmax(220px, 1fr) auto;
align-items: end;
gap: 12px;
width: 100%;
border: var(--border-line);
border-radius: 8px;
background: var(--surface-subtle);
padding: 12px;
}
.people-picker-manual-form > label {
display: grid;
gap: 6px;
min-width: 0;
}
.people-picker-manual-actions {
display: flex;
align-items: center;
gap: 8px;
}
.people-picker-manual-form > .form-help {
grid-column: 1 / -1;
margin: 0;
}
.people-picker-selection > .field-label,
.people-picker-selection > span > .field-label {
width: fit-content;
}
@media (max-width: 760px) {
.people-picker-result {
grid-template-columns: 1fr;
gap: 4px;
}
.people-picker-result-context {
justify-items: start;
text-align: left;
}
.people-picker-manual-form {
grid-template-columns: 1fr;
}
}
/* Bootstrap-like switch controls without importing Bootstrap. */
.toggle-switch-row {
display: flex;