feat(pickers): a list that failed to load offers Retry

A picker whose options failed to load said so, but the only way back was closing its dialog.
Combobox takes `onRetry`: a Retry beside the message for the pointer, and Enter in the field for
the keyboard (the panel is a listbox, which may hold options only, so the alert names Enter for
assistive tech). Every caller with an error state passes its query's refetch: both user pickers,
the channel picker and the two Plex pickers.
This commit is contained in:
2026-09-27 17:47:01 +02:00
parent e0453fa07d
commit 52fb999af6
9 changed files with 73 additions and 4 deletions
@@ -41,6 +41,7 @@ export default function ChannelPicker({
onChange={(channelIds) => setFilters({ ...filters, channelIds })} onChange={(channelIds) => setFilters({ ...filters, channelIds })}
loading={channelsQuery.isLoading} loading={channelsQuery.isLoading}
error={channelsQuery.isError && !channelsQuery.data ? t("common.loadError") : undefined} error={channelsQuery.isError && !channelsQuery.data ? t("common.loadError") : undefined}
onRetry={() => void channelsQuery.refetch()}
emptyText={t("sidebar.noChannelMatch")} emptyText={t("sidebar.noChannelMatch")}
selectedLabels={selectedLabels} selectedLabels={selectedLabels}
/> />
+2
View File
@@ -373,6 +373,7 @@ function PlaylistPicker() {
placeholder={t("plex.playlist.pickerPlaceholder")} placeholder={t("plex.playlist.pickerPlaceholder")}
loading={playlistsQ.isLoading} loading={playlistsQ.isLoading}
error={playlistsQ.isError && !playlistsQ.data ? t("common.loadError") : undefined} error={playlistsQ.isError && !playlistsQ.data ? t("common.loadError") : undefined}
onRetry={() => void playlistsQ.refetch()}
emptyText={t("plex.playlist.none")} emptyText={t("plex.playlist.none")}
renderOption={(o) => renderOption={(o) =>
o.value === CREATE_PLAYLIST ? ( o.value === CREATE_PLAYLIST ? (
@@ -457,6 +458,7 @@ function CollectionPicker({
onOpenChange={setOpen} onOpenChange={setOpen}
loading={q.isFetching} loading={q.isFetching}
error={q.isError ? t("common.loadError") : undefined} error={q.isError ? t("common.loadError") : undefined}
onRetry={() => void q.refetch()}
placeholder={t("plex.filter.collectionSearch")} placeholder={t("plex.filter.collectionSearch")}
emptyText={t("plex.filter.collectionEmpty")} emptyText={t("plex.filter.collectionEmpty")}
onChange={(next) => { onChange={(next) => {
+1
View File
@@ -47,6 +47,7 @@ function UserShare({ job }: { job: DownloadJob }) {
users={list} users={list}
loading={recipients.isLoading} loading={recipients.isLoading}
error={recipients.isError} error={recipients.isError}
onRetry={() => void recipients.refetch()}
disabled={share.isPending} disabled={share.isPending}
onPick={(u) => { onPick={(u) => {
if (!share.isPending) share.mutate(u.email); if (!share.isPending) share.mutate(u.email);
+4
View File
@@ -19,6 +19,7 @@ export default function UserPicker<U extends PickableUser>({
placeholder, placeholder,
loading, loading,
error, error,
onRetry,
disabled, disabled,
}: { }: {
users: readonly U[]; users: readonly U[];
@@ -28,6 +29,8 @@ export default function UserPicker<U extends PickableUser>({
loading?: boolean; loading?: boolean;
/** The user list failed to load: the picker says so instead of "No matches". */ /** The user list failed to load: the picker says so instead of "No matches". */
error?: boolean; error?: boolean;
/** Load the user list again after `error`. */
onRetry?: () => void;
/** The last pick is still being acted on: no second one meanwhile. */ /** The last pick is still being acted on: no second one meanwhile. */
disabled?: boolean; disabled?: boolean;
}) { }) {
@@ -40,6 +43,7 @@ export default function UserPicker<U extends PickableUser>({
multiple={false} multiple={false}
loading={loading} loading={loading}
error={error ? t("common.loadError") : undefined} error={error ? t("common.loadError") : undefined}
onRetry={onRetry}
disabled={disabled} disabled={disabled}
selected={[]} selected={[]}
// The label carries both, so the client filter matches a name OR an email. // The label carries both, so the client filter matches a name OR an email.
@@ -150,6 +150,7 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }
users={list} users={list}
loading={users.isLoading} loading={users.isLoading}
error={users.isError} error={users.isError}
onRetry={() => void users.refetch()}
onPick={(u) => onPick({ id: u.id, email: u.email })} onPick={(u) => onPick({ id: u.id, email: u.email })}
label={t("downloads.admin.quotaPickPlaceholder")} label={t("downloads.admin.quotaPickPlaceholder")}
placeholder={t("downloads.admin.quotaPickPlaceholder")} placeholder={t("downloads.admin.quotaPickPlaceholder")}
@@ -311,6 +311,34 @@ describe("Combobox · honest states", () => {
expect(screen.queryByText("common.noResults")).toBeNull(); expect(screen.queryByText("common.noResults")).toBeNull();
}); });
// A failed list with no way back but closing its dialog was a dead end (E28 review, owner go).
it("offers a Retry on the error, by click and by Enter in the field, keeping the focus", () => {
const onRetry = vi.fn();
wrap(
<Combobox
label="User"
options={[]}
selected={[]}
onChange={vi.fn()}
error="Load failed"
onRetry={onRetry}
/>,
);
const field = screen.getByRole("combobox");
field.focus();
fireEvent.focus(field);
// The listbox holds no button for assistive tech; the alert says how (Enter) instead.
expect(screen.getByRole("alert", { hidden: true }).textContent).toContain(
"common.retryWithEnter",
);
const retry = screen.getByText("common.retry");
expect(fireEvent.mouseDown(retry)).toBe(false); // the press does not take the focus
fireEvent.click(retry);
fireEvent.keyDown(field, { key: "Enter" });
expect(onRetry).toHaveBeenCalledTimes(2);
expect(document.activeElement).toBe(field);
});
it("disabled: keeps the focus, opens nothing and picks nothing", () => { it("disabled: keeps the focus, opens nothing and picks nothing", () => {
const onChange = vi.fn(); const onChange = vi.fn();
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={onChange} disabled />); wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={onChange} disabled />);
+34 -4
View File
@@ -2,6 +2,7 @@ import clsx from "clsx";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ChevronDown } from "lucide-react"; import { ChevronDown } from "lucide-react";
import { Button } from "./Button";
import { MenuCheck } from "./MenuCheck"; import { MenuCheck } from "./MenuCheck";
import { PopoverSurface, PopoverItem } from "./AnchoredPopover"; import { PopoverSurface, PopoverItem } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks"; import { useAnchoredPopover } from "./anchoredPopoverHooks";
@@ -56,6 +57,9 @@ export interface ComboboxProps {
/** The options failed to load: said in the list instead of "No matches" (an error is not an empty /** The options failed to load: said in the list instead of "No matches" (an error is not an empty
* result — E28 review J1). */ * result — E28 review J1). */
error?: string; error?: string;
/** Loads the options again after `error`: a Retry beside the message, and Enter in the field. A
* failed list with no way back but closing the dialog was a dead end. */
onRetry?: () => void;
/** Takes no pick (e.g. the last one is still being written, J2). Soft, like `FocusSafeButton`: the /** 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. */ * field keeps the focus (a natively disabled one drops it to <body>), announced as unavailable. */
disabled?: boolean; disabled?: boolean;
@@ -83,6 +87,7 @@ export default function Combobox({
clientFilter = true, clientFilter = true,
loading = false, loading = false,
error, error,
onRetry,
disabled = false, disabled = false,
emptyText, emptyText,
maxVisible = 50, maxVisible = 50,
@@ -152,6 +157,7 @@ export default function Combobox({
// Options arrive count-desc (facet order), so the capped default view is the most-common values; a // Options arrive count-desc (facet order), so the capped default view is the most-common values; a
// footer flags how many more are reachable through the search. // footer flags how many more are reachable through the search.
const visible = filtered.slice(0, maxVisible); const visible = filtered.slice(0, maxVisible);
const showError = error != null && filtered.length === 0;
const hiddenCount = filtered.length - visible.length; const hiddenCount = filtered.length - visible.length;
const commit = (value: string) => { const commit = (value: string) => {
@@ -221,6 +227,11 @@ export default function Combobox({
if (e.key === "Enter" && open && idx != null && visible[idx]) { if (e.key === "Enter" && open && idx != null && visible[idx]) {
e.preventDefault(); // select the highlighted option, don't submit a surrounding form e.preventDefault(); // select the highlighted option, don't submit a surrounding form
if (!visible[idx].disabled) commit(visible[idx].value); if (!visible[idx].disabled) commit(visible[idx].value);
} else if (e.key === "Enter" && open && showError && onRetry) {
// The focus stays in the field (the list is aria-activedescendant), so the Retry is
// reached from here, the way an option is.
e.preventDefault();
onRetry();
} else if ( } else if (
e.key === "Backspace" && e.key === "Backspace" &&
!disabled && !disabled &&
@@ -256,10 +267,29 @@ export default function Combobox({
> >
{/* The scroll box owns the height cap AND the scroll-fade mask (ref+style on the same element). */} {/* 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"> <div ref={fade.ref} style={fade.style} className="max-h-64 overflow-y-auto no-scrollbar">
{error && filtered.length === 0 ? ( {showError ? (
<p role="alert" className="px-2 py-1.5 text-xs text-danger"> <div className="flex items-center gap-2 px-2 py-1.5">
{error} <p role="alert" className="flex-1 text-xs text-danger">
</p> {error}
{onRetry && <span className="sr-only"> {t("common.retryWithEnter")}</span>}
</p>
{onRetry && (
// For the pointer only: this panel is a listbox, which may hold options and nothing
// else, so the keyboard's Retry is Enter in the field (said in the alert above).
<Button
variant="outline"
size="xs"
type="button"
tabIndex={-1}
aria-hidden
// Keep the focus in the field: a press that moved it out would close the list.
onMouseDown={(e) => e.preventDefault()}
onClick={onRetry}
>
{t("common.retry")}
</Button>
)}
</div>
) : loading && filtered.length === 0 ? ( ) : loading && filtered.length === 0 ? (
<p className="px-2 py-1.5 text-xs text-muted">{t("common.loading")}</p> <p className="px-2 py-1.5 text-xs text-muted">{t("common.loading")}</p>
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
+1
View File
@@ -20,6 +20,7 @@
"demoSharedNotice": "You're in the shared demo account. Everything you do here — playlists, hidden videos, settings — is communal and may be changed by other demo visitors at the same time.", "demoSharedNotice": "You're in the shared demo account. Everything you do here — playlists, hidden videos, settings — is communal and may be changed by other demo visitors at the same time.",
"backToTop": "Back to top", "backToTop": "Back to top",
"retry": "Retry", "retry": "Retry",
"retryWithEnter": "Press Enter to try again.",
"loadError": "Couldn't load this — check your connection and try again", "loadError": "Couldn't load this — check your connection and try again",
"seeAll": "See all", "seeAll": "See all",
"scrollLeft": "Scroll left", "scrollLeft": "Scroll left",
+1
View File
@@ -20,6 +20,7 @@
"demoSharedNotice": "Egy közös demo fiókban vagy. Minden, amit itt csinálsz — lejátszási listák, elrejtett videók, beállítások — közös, és más demo látogatók egyszerre módosíthatják.", "demoSharedNotice": "Egy közös demo fiókban vagy. Minden, amit itt csinálsz — lejátszási listák, elrejtett videók, beállítások — közös, és más demo látogatók egyszerre módosíthatják.",
"backToTop": "Vissza a tetejére", "backToTop": "Vissza a tetejére",
"retry": "Újra", "retry": "Újra",
"retryWithEnter": "Újrapróbáláshoz nyomd meg az Entert.",
"loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra", "loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra",
"seeAll": "Összes", "seeAll": "Összes",
"scrollLeft": "Görgetés balra", "scrollLeft": "Görgetés balra",