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
@@ -311,6 +311,34 @@ describe("Combobox · honest states", () => {
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", () => {
const onChange = vi.fn();
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 { useTranslation } from "react-i18next";
import { ChevronDown } from "lucide-react";
import { Button } from "./Button";
import { MenuCheck } from "./MenuCheck";
import { PopoverSurface, PopoverItem } from "./AnchoredPopover";
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
* result — E28 review J1). */
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
* field keeps the focus (a natively disabled one drops it to <body>), announced as unavailable. */
disabled?: boolean;
@@ -83,6 +87,7 @@ export default function Combobox({
clientFilter = true,
loading = false,
error,
onRetry,
disabled = false,
emptyText,
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
// footer flags how many more are reachable through the search.
const visible = filtered.slice(0, maxVisible);
const showError = error != null && filtered.length === 0;
const hiddenCount = filtered.length - visible.length;
const commit = (value: string) => {
@@ -221,6 +227,11 @@ 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 === "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 (
e.key === "Backspace" &&
!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). */}
<div ref={fade.ref} style={fade.style} className="max-h-64 overflow-y-auto no-scrollbar">
{error && filtered.length === 0 ? (
<p role="alert" className="px-2 py-1.5 text-xs text-danger">
{error}
</p>
{showError ? (
<div className="flex items-center gap-2 px-2 py-1.5">
<p role="alert" className="flex-1 text-xs text-danger">
{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 ? (
<p className="px-2 py-1.5 text-xs text-muted">{t("common.loading")}</p>
) : filtered.length === 0 ? (