diff --git a/frontend/src/components/ChannelPicker.tsx b/frontend/src/components/ChannelPicker.tsx index 4ee631fb..495a5161 100644 --- a/frontend/src/components/ChannelPicker.tsx +++ b/frontend/src/components/ChannelPicker.tsx @@ -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} /> diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index d10fafce..28c83c76 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -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) => { diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx index 77068885..963391fa 100644 --- a/frontend/src/components/ShareDialog.tsx +++ b/frontend/src/components/ShareDialog.tsx @@ -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); diff --git a/frontend/src/components/UserPicker.tsx b/frontend/src/components/UserPicker.tsx index afd73e57..3d4589f6 100644 --- a/frontend/src/components/UserPicker.tsx +++ b/frontend/src/components/UserPicker.tsx @@ -19,6 +19,7 @@ export default function UserPicker({ placeholder, loading, error, + onRetry, disabled, }: { users: readonly U[]; @@ -28,6 +29,8 @@ export default function UserPicker({ 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({ 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. diff --git a/frontend/src/components/downloadAdmin.tsx b/frontend/src/components/downloadAdmin.tsx index 44d6ef92..402b7cea 100644 --- a/frontend/src/components/downloadAdmin.tsx +++ b/frontend/src/components/downloadAdmin.tsx @@ -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")} diff --git a/frontend/src/components/ui/Combobox.dom.test.tsx b/frontend/src/components/ui/Combobox.dom.test.tsx index 541dd7ef..192e7033 100644 --- a/frontend/src/components/ui/Combobox.dom.test.tsx +++ b/frontend/src/components/ui/Combobox.dom.test.tsx @@ -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( + , + ); + 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(); diff --git a/frontend/src/components/ui/Combobox.tsx b/frontend/src/components/ui/Combobox.tsx index 5b1eebfc..f672cb5c 100644 --- a/frontend/src/components/ui/Combobox.tsx +++ b/frontend/src/components/ui/Combobox.tsx @@ -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 ), 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). */}
- {error && filtered.length === 0 ? ( -

- {error} -

+ {showError ? ( +
+

+ {error} + {onRetry && {t("common.retryWithEnter")}} +

+ {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). + + )} +
) : loading && filtered.length === 0 ? (

{t("common.loading")}

) : filtered.length === 0 ? ( diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index e3bbd448..ea7acbc9 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json index 837726e7..c6e5e35b 100644 --- a/frontend/src/i18n/locales/hu/common.json +++ b/frontend/src/i18n/locales/hu/common.json @@ -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",