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.
70 lines
2.2 KiB
TypeScript
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>
|
|
);
|
|
}}
|
|
/>
|
|
);
|
|
}
|