fix(ui): a picker whose list failed says so, and takes no pick while one is being written
- Combobox gains `error` (said in the list, not "No matches") and a soft `disabled` (keeps the focus, announced, opens and changes nothing). - UserPicker passes both: the share dialog's recipient picker is off while a share is pending, and the share and quota pickers say when the user list failed to load. - ChannelPicker and the Plex playlist/collection pickers say a failed load too. - The channel menu's Tags row says loading / failed instead of "no tags yet".
This commit is contained in:
@@ -276,6 +276,33 @@ describe("Combobox", () => {
|
||||
// PageUp/PageDown (E11, owner request): the sidebar's channel filter and the Plex pickers run to
|
||||
// hundreds of rows, and floating-ui's list navigation has only the arrows. jsdom has no layout, so a
|
||||
// page is the fixed fallback of 10 rows; the measured page is pinned in lib/listPaging.
|
||||
// E28 review J1 + J2: a failed list said "No matches" (an error dressed as an empty result), and a
|
||||
// picker whose pick was still being written took another pick.
|
||||
describe("Combobox · honest states", () => {
|
||||
it("says the list failed to load, not that nothing matched", () => {
|
||||
wrap(
|
||||
<Combobox label="User" options={[]} selected={[]} onChange={vi.fn()} error="Load failed" />,
|
||||
);
|
||||
fireEvent.focus(screen.getByRole("combobox"));
|
||||
expect(screen.getByText("Load failed")).toBeTruthy();
|
||||
expect(screen.queryByText("common.noResults")).toBeNull();
|
||||
});
|
||||
|
||||
it("disabled: keeps the focus, opens nothing and picks nothing", () => {
|
||||
const onChange = vi.fn();
|
||||
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={onChange} disabled />);
|
||||
const field = screen.getByRole("combobox");
|
||||
field.focus();
|
||||
fireEvent.focus(field);
|
||||
expect(screen.queryByRole("option", { hidden: true })).toBeNull();
|
||||
fireEvent.keyDown(field, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(field.getAttribute("aria-disabled")).toBe("true");
|
||||
expect(document.activeElement).toBe(field); // not natively disabled: the focus stays put
|
||||
});
|
||||
});
|
||||
|
||||
describe("Combobox · paging", () => {
|
||||
const rows = (disabled: number[] = []) =>
|
||||
Array.from({ length: 25 }, (_, i) => ({ value: `Row ${i}`, disabled: disabled.includes(i) }));
|
||||
|
||||
@@ -52,6 +52,12 @@ export interface ComboboxProps {
|
||||
* the list server-side (then `options` arrives pre-filtered). */
|
||||
clientFilter?: boolean;
|
||||
loading?: boolean;
|
||||
/** 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;
|
||||
/** 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;
|
||||
/** Shown when there are no options to list. */
|
||||
emptyText?: string;
|
||||
/** Cap on rendered rows for a huge vocabulary; the rest hide behind the search. Default 50. */
|
||||
@@ -75,6 +81,8 @@ export default function Combobox({
|
||||
onQueryChange,
|
||||
clientFilter = true,
|
||||
loading = false,
|
||||
error,
|
||||
disabled = false,
|
||||
emptyText,
|
||||
maxVisible = 50,
|
||||
renderOption,
|
||||
@@ -102,6 +110,7 @@ export default function Combobox({
|
||||
const labelFor = (v: string) => selectedLabels?.[v] ?? labelCache.current[v] ?? v;
|
||||
|
||||
const setOpenAnd = (next: boolean) => {
|
||||
if (next && disabled) return;
|
||||
setOpen(next);
|
||||
onOpenChange?.(next);
|
||||
// Closing discards the typed query: the list is for "find and pick", and a leftover filter would
|
||||
@@ -145,6 +154,7 @@ export default function Combobox({
|
||||
const hiddenCount = filtered.length - visible.length;
|
||||
|
||||
const commit = (value: string) => {
|
||||
if (disabled) return;
|
||||
if (multiple) {
|
||||
onChange(
|
||||
selected.includes(value) ? selected.filter((x) => x !== value) : [...selected, value],
|
||||
@@ -183,13 +193,14 @@ export default function Combobox({
|
||||
className={clsx(
|
||||
fieldClass({ size: "free" }),
|
||||
"flex min-h-8 w-full cursor-text flex-wrap items-center gap-1 px-1.5 py-0.5 text-sm",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
{selected.map((v) => (
|
||||
<DismissibleChip
|
||||
key={v}
|
||||
className="max-w-40"
|
||||
onDismiss={() => onChange(selected.filter((x) => x !== v))}
|
||||
onDismiss={() => !disabled && onChange(selected.filter((x) => x !== v))}
|
||||
dismissLabel={t("common.removeNamed", { name: labelFor(v) })}
|
||||
>
|
||||
{labelFor(v)}
|
||||
@@ -209,13 +220,20 @@ 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 === "Backspace" && query === "" && selected.length > 0) {
|
||||
} else if (
|
||||
e.key === "Backspace" &&
|
||||
!disabled &&
|
||||
query === "" &&
|
||||
selected.length > 0
|
||||
) {
|
||||
// Backspace on an empty field removes the last chip (react-select behaviour).
|
||||
onChange(selected.slice(0, -1));
|
||||
}
|
||||
},
|
||||
})}
|
||||
value={query}
|
||||
readOnly={disabled}
|
||||
aria-disabled={disabled || undefined}
|
||||
aria-label={label}
|
||||
placeholder={
|
||||
selected.length === 0
|
||||
@@ -237,7 +255,11 @@ 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">
|
||||
{loading && filtered.length === 0 ? (
|
||||
{error && filtered.length === 0 ? (
|
||||
<p role="alert" className="px-2 py-1.5 text-xs text-danger">
|
||||
{error}
|
||||
</p>
|
||||
) : loading && filtered.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-xs text-muted">{t("common.loading")}</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-xs text-muted">
|
||||
|
||||
Reference in New Issue
Block a user