fix(ui): a picker whose list failed says so, and takes no pick while one is being written

- Combobox gains `error` (said in the list, not "No matches") and a soft `disabled` (keeps the
  focus, announced, opens and changes nothing).
- UserPicker passes both: the share dialog's recipient picker is off while a share is pending, and
  the share and quota pickers say when the user list failed to load.
- ChannelPicker and the Plex playlist/collection pickers say a failed load too.
- The channel menu's Tags row says loading / failed instead of "no tags yet".
This commit is contained in:
2026-09-27 14:47:46 +02:00
parent 70f98b352f
commit 17bb14fb18
10 changed files with 102 additions and 6 deletions
+25 -3
View File
@@ -52,6 +52,12 @@ export interface ComboboxProps {
* the list server-side (then `options` arrives pre-filtered). */
clientFilter?: boolean;
loading?: boolean;
/** The options failed to load: said in the list instead of "No matches" (an error is not an empty
* result — E28 review J1). */
error?: string;
/** Takes no pick (e.g. the last one is still being written, J2). Soft, like `FocusSafeButton`: the
* field keeps the focus (a natively disabled one drops it to <body>), announced as unavailable. */
disabled?: boolean;
/** Shown when there are no options to list. */
emptyText?: string;
/** Cap on rendered rows for a huge vocabulary; the rest hide behind the search. Default 50. */
@@ -75,6 +81,8 @@ export default function Combobox({
onQueryChange,
clientFilter = true,
loading = false,
error,
disabled = false,
emptyText,
maxVisible = 50,
renderOption,
@@ -102,6 +110,7 @@ export default function Combobox({
const labelFor = (v: string) => selectedLabels?.[v] ?? labelCache.current[v] ?? v;
const setOpenAnd = (next: boolean) => {
if (next && disabled) return;
setOpen(next);
onOpenChange?.(next);
// Closing discards the typed query: the list is for "find and pick", and a leftover filter would
@@ -145,6 +154,7 @@ export default function Combobox({
const hiddenCount = filtered.length - visible.length;
const commit = (value: string) => {
if (disabled) return;
if (multiple) {
onChange(
selected.includes(value) ? selected.filter((x) => x !== value) : [...selected, value],
@@ -183,13 +193,14 @@ export default function Combobox({
className={clsx(
fieldClass({ size: "free" }),
"flex min-h-8 w-full cursor-text flex-wrap items-center gap-1 px-1.5 py-0.5 text-sm",
disabled && "cursor-not-allowed opacity-50",
)}
>
{selected.map((v) => (
<DismissibleChip
key={v}
className="max-w-40"
onDismiss={() => onChange(selected.filter((x) => x !== v))}
onDismiss={() => !disabled && onChange(selected.filter((x) => x !== v))}
dismissLabel={t("common.removeNamed", { name: labelFor(v) })}
>
{labelFor(v)}
@@ -209,13 +220,20 @@ export default function Combobox({
if (e.key === "Enter" && open && idx != null && visible[idx]) {
e.preventDefault(); // select the highlighted option, don't submit a surrounding form
if (!visible[idx].disabled) commit(visible[idx].value);
} else if (e.key === "Backspace" && query === "" && selected.length > 0) {
} else if (
e.key === "Backspace" &&
!disabled &&
query === "" &&
selected.length > 0
) {
// Backspace on an empty field removes the last chip (react-select behaviour).
onChange(selected.slice(0, -1));
}
},
})}
value={query}
readOnly={disabled}
aria-disabled={disabled || undefined}
aria-label={label}
placeholder={
selected.length === 0
@@ -237,7 +255,11 @@ export default function Combobox({
>
{/* The scroll box owns the height cap AND the scroll-fade mask (ref+style on the same element). */}
<div ref={fade.ref} style={fade.style} className="max-h-64 overflow-y-auto no-scrollbar">
{loading && filtered.length === 0 ? (
{error && filtered.length === 0 ? (
<p role="alert" className="px-2 py-1.5 text-xs text-danger">
{error}
</p>
) : loading && filtered.length === 0 ? (
<p className="px-2 py-1.5 text-xs text-muted">{t("common.loading")}</p>
) : filtered.length === 0 ? (
<p className="px-2 py-1.5 text-xs text-muted">