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:
@@ -41,6 +41,7 @@ export default function ChannelPicker({
|
||||
onChange={(channelIds) => setFilters({ ...filters, channelIds })}
|
||||
loading={channelsQuery.isLoading}
|
||||
error={channelsQuery.isError && !channelsQuery.data ? t("common.loadError") : undefined}
|
||||
onRetry={() => void channelsQuery.refetch()}
|
||||
emptyText={t("sidebar.noChannelMatch")}
|
||||
selectedLabels={selectedLabels}
|
||||
/>
|
||||
|
||||
@@ -373,6 +373,7 @@ function PlaylistPicker() {
|
||||
placeholder={t("plex.playlist.pickerPlaceholder")}
|
||||
loading={playlistsQ.isLoading}
|
||||
error={playlistsQ.isError && !playlistsQ.data ? t("common.loadError") : undefined}
|
||||
onRetry={() => void playlistsQ.refetch()}
|
||||
emptyText={t("plex.playlist.none")}
|
||||
renderOption={(o) =>
|
||||
o.value === CREATE_PLAYLIST ? (
|
||||
@@ -457,6 +458,7 @@ function CollectionPicker({
|
||||
onOpenChange={setOpen}
|
||||
loading={q.isFetching}
|
||||
error={q.isError ? t("common.loadError") : undefined}
|
||||
onRetry={() => void q.refetch()}
|
||||
placeholder={t("plex.filter.collectionSearch")}
|
||||
emptyText={t("plex.filter.collectionEmpty")}
|
||||
onChange={(next) => {
|
||||
|
||||
@@ -47,6 +47,7 @@ function UserShare({ job }: { job: DownloadJob }) {
|
||||
users={list}
|
||||
loading={recipients.isLoading}
|
||||
error={recipients.isError}
|
||||
onRetry={() => void recipients.refetch()}
|
||||
disabled={share.isPending}
|
||||
onPick={(u) => {
|
||||
if (!share.isPending) share.mutate(u.email);
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function UserPicker<U extends PickableUser>({
|
||||
placeholder,
|
||||
loading,
|
||||
error,
|
||||
onRetry,
|
||||
disabled,
|
||||
}: {
|
||||
users: readonly U[];
|
||||
@@ -28,6 +29,8 @@ export default function UserPicker<U extends PickableUser>({
|
||||
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;
|
||||
}) {
|
||||
@@ -40,6 +43,7 @@ export default function UserPicker<U extends PickableUser>({
|
||||
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.
|
||||
|
||||
@@ -150,6 +150,7 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }
|
||||
users={list}
|
||||
loading={users.isLoading}
|
||||
error={users.isError}
|
||||
onRetry={() => void users.refetch()}
|
||||
onPick={(u) => onPick({ id: u.id, email: u.email })}
|
||||
label={t("downloads.admin.quotaPickPlaceholder")}
|
||||
placeholder={t("downloads.admin.quotaPickPlaceholder")}
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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.",
|
||||
"backToTop": "Back to top",
|
||||
"retry": "Retry",
|
||||
"retryWithEnter": "Press Enter to try again.",
|
||||
"loadError": "Couldn't load this — check your connection and try again",
|
||||
"seeAll": "See all",
|
||||
"scrollLeft": "Scroll left",
|
||||
|
||||
@@ -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.",
|
||||
"backToTop": "Vissza a tetejére",
|
||||
"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",
|
||||
"seeAll": "Összes",
|
||||
"scrollLeft": "Görgetés balra",
|
||||
|
||||
Reference in New Issue
Block a user