Files
siftlode/frontend/src/modules/files/UserPicker.tsx
T
peter 63a0fecc8b refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
2026-10-22 23:12:45 +02:00

70 lines
2.2 KiB
TypeScript

import { useTranslation } from "react-i18next";
import Combobox from "../../components/ui/Combobox";
// Pick one registered user by name or email: the download quota's "any user" picker and the share
// dialog's recipient. Both used to hand-roll an input over an absolute list that only a mouse could
// reach (no arrow keys, no listbox for a screen reader, closed by a blur timer); this is the shared
// Combobox in single-select, which brings all of that. A pick is an ACTION here, not a value to show,
// so nothing stays selected: the field clears for the next one.
export interface PickableUser {
id: number;
email: string;
display_name?: string | null;
}
export default function UserPicker<U extends PickableUser>({
users,
onPick,
label,
placeholder,
loading,
error,
onRetry,
disabled,
}: {
users: readonly U[];
onPick: (u: U) => void;
label: string;
placeholder: string;
loading?: boolean;
/** The user list failed to load: the picker says so instead of "No matches". */
error?: boolean;
/** Load the user list again after `error`. */
onRetry?: () => void;
/** The last pick is still being acted on: no second one meanwhile. */
disabled?: boolean;
}) {
const { t } = useTranslation();
const byId = new Map(users.map((u) => [String(u.id), u]));
return (
<Combobox
label={label}
placeholder={placeholder}
multiple={false}
loading={loading}
error={error ? t("common.loadError") : undefined}
onRetry={onRetry}
disabled={disabled}
selected={[]}
// The label carries both, so the client filter matches a name OR an email.
options={users.map((u) => ({
value: String(u.id),
label: u.display_name ? `${u.display_name} · ${u.email}` : u.email,
}))}
onChange={(next) => {
const u = next[0] != null ? byId.get(next[0]) : undefined;
if (u) onPick(u);
}}
renderOption={(o) => {
const u = byId.get(o.value);
return (
<span className="flex min-w-0 flex-col">
<span className="truncate">{u?.display_name || u?.email}</span>
{u?.display_name && <span className="truncate text-xs text-muted">{u.email}</span>}
</span>
);
}}
/>
);
}