feat(core): add bounded people picker foundation
This commit is contained in:
517
webui/src/components/people/PeoplePicker.tsx
Normal file
517
webui/src/components/people/PeoplePicker.tsx
Normal 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;
|
||||
}
|
||||
78
webui/src/components/people/peoplePickerTypes.ts
Normal file
78
webui/src/components/people/peoplePickerTypes.ts
Normal 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;
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user