feat(filters): the any/all switch sits on the panel above its picker
ui/MatchToggle replaces the Combobox popover footer (Feed tags, topics, languages; Plex collections) and PlexSidebar's own toggle (genres, actors): shown from two picks, one wording. A closed picker had hidden that "all" was on.
This commit is contained in:
@@ -205,31 +205,6 @@ describe("Combobox", () => {
|
||||
expect(onChange).toHaveBeenCalledWith(["id1"]); // single-select replaces
|
||||
});
|
||||
|
||||
it("shows the any/all match footer only at 2+ selections and reports a mode change", () => {
|
||||
// The per-category any/all toggle: meaningless below 2 selections (any === all), so it appears
|
||||
// only at >= 2, and clicking 'all' reports the mode change out.
|
||||
const onMatchModeChange = vi.fn();
|
||||
const props = {
|
||||
label: "Topic",
|
||||
options: OPTIONS,
|
||||
onChange: vi.fn(),
|
||||
matchMode: "or" as const,
|
||||
onMatchModeChange,
|
||||
};
|
||||
// One selection: no footer.
|
||||
const { rerender } = wrap(<Combobox {...props} selected={["Drama"]} />);
|
||||
fireEvent.focus(screen.getByRole("combobox"));
|
||||
expect(screen.queryByText("sidebar.all")).toBeNull();
|
||||
|
||||
// Two selections: footer appears with an any/all segment.
|
||||
rerender(<FloatingTree>{<Combobox {...props} selected={["Drama", "Action"]} />}</FloatingTree>);
|
||||
fireEvent.focus(screen.getByRole("combobox"));
|
||||
const all = screen.getByText("sidebar.all");
|
||||
expect(all).toBeTruthy();
|
||||
fireEvent.click(all);
|
||||
expect(onMatchModeChange).toHaveBeenCalledWith("and");
|
||||
});
|
||||
|
||||
it("single-select: picking replaces the value and closes the list", () => {
|
||||
const onChange = vi.fn();
|
||||
wrap(
|
||||
|
||||
@@ -61,11 +61,6 @@ export interface ComboboxProps {
|
||||
/** Chip/label text for a selected value whose option is not in the current (e.g. server-narrowed)
|
||||
* list — the collection picker passes the stored title here so a reloaded filter still names itself. */
|
||||
selectedLabels?: Record<string, string>;
|
||||
/** Optional "any/all" match segment in the popover footer (like SortControl's direction segment):
|
||||
* "or" = a match on ANY selected value, "and" = must match ALL. Provide BOTH to show it; it appears
|
||||
* only once 2+ values are selected (below that, any and all are the same). */
|
||||
matchMode?: "or" | "and";
|
||||
onMatchModeChange?: (mode: "or" | "and") => void;
|
||||
}
|
||||
|
||||
export default function Combobox({
|
||||
@@ -84,8 +79,6 @@ export default function Combobox({
|
||||
maxVisible = 50,
|
||||
renderOption,
|
||||
selectedLabels,
|
||||
matchMode,
|
||||
onMatchModeChange,
|
||||
}: ComboboxProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -299,32 +292,6 @@ export default function Combobox({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Any/all match segment (like SortControl's direction footer) — only meaningful, so only shown,
|
||||
once 2+ values are picked. mousedown-preventDefault keeps the input focused so the list stays
|
||||
open in the combobox's focus model. */}
|
||||
{matchMode != null && onMatchModeChange && selected.length >= 2 && (
|
||||
<div className="mt-1 flex items-center gap-2 border-t border-border px-1 pt-2">
|
||||
<span className="text-2xs text-muted">{t("common.matchMode")}</span>
|
||||
<div className="ml-auto flex gap-1">
|
||||
{(["or", "and"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
aria-pressed={matchMode === m}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onMatchModeChange(m)}
|
||||
className={`rounded-md border px-2 py-0.5 text-2xs transition ${
|
||||
matchMode === m
|
||||
? "border-accent bg-accent/15 text-accent"
|
||||
: "border-border text-muted hover:border-accent/50 hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{t(m === "or" ? "sidebar.any" : "sidebar.all")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
|
||||
|
||||
import MatchToggle from "./MatchToggle";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// The any/all switch sits on the panel above its picker (the owner, 2026-09-27): shown from two
|
||||
// picks — with one, any and all are the same filter — as one named segment, the current mode pressed.
|
||||
describe("MatchToggle", () => {
|
||||
it("draws nothing below two picks", () => {
|
||||
const { container } = render(<MatchToggle picked={1} value="any" onChange={() => {}} />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("from two picks: a named any/all group with the current mode pressed, reporting the pick", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<MatchToggle picked={2} value="any" onChange={onChange} />);
|
||||
const group = screen.getByRole("group", { name: "common.matchMode" });
|
||||
expect(group.querySelector('[aria-pressed="true"]')?.textContent).toBe("common.match.any");
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.match.all" }));
|
||||
expect(onChange).toHaveBeenCalledWith("all");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
|
||||
export type MatchMode = "any" | "all";
|
||||
|
||||
// The any/all switch of a multi-value filter (tags, topics, languages, genres, collections, actors):
|
||||
// a captioned segment on the panel, right ABOVE its picker — never in the picker's popover, where a
|
||||
// closed picker hid that "all" was on (the owner, 2026-09-27; DESIGN "Action placement": the state
|
||||
// that shapes the results stays in sight). With fewer than two picks any and all are the same filter,
|
||||
// so it draws nothing then. The panel's captioned toggle is the one 24px segment (DESIGN log, S3g).
|
||||
export default function MatchToggle({
|
||||
picked,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
/** How many values the picker holds; the switch shows from two. */
|
||||
picked: number;
|
||||
value: MatchMode;
|
||||
onChange: (mode: MatchMode) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (picked < 2) return null;
|
||||
return (
|
||||
<div className={className ?? "mb-1.5"}>
|
||||
<SegmentedToggle
|
||||
size="sm"
|
||||
label={t("common.matchMode")}
|
||||
ariaLabel={t("common.matchMode")}
|
||||
options={[
|
||||
{ value: "any", label: t("common.match.any") },
|
||||
{ value: "all", label: t("common.match.all") },
|
||||
]}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user