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 })}
|
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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 />);
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user