diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index 1b90444f..2000f08b 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -12,7 +12,7 @@ import { type MediaSourceKind } from "../lib/mediaSource"; import { useDebounced } from "../lib/useDebounced"; import FilterRail from "./FilterRail"; import { type PanelItem } from "./ui/PanelGroups"; -import SegmentedToggle from "./ui/SegmentedToggle"; +import MatchToggle from "./ui/MatchToggle"; import FilterChipGroup from "./ui/FilterChipGroup"; import Combobox, { type ComboboxOption } from "./ui/Combobox"; import SavedViewsPopover from "./SavedViewsPopover"; @@ -43,31 +43,6 @@ const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[ { key: "long", min: 120 * 60, max: null }, ]; -// The any/all combine toggle for a multi-value filter (genres, actors) — a thin binding of the shared -// SegmentedToggle to the Plex match vocabulary (same look as the feed rail's tag MATCH toggle). -function MatchToggle({ - mode, - onChange, -}: { - mode: "any" | "all"; - onChange: (m: "any" | "all") => void; -}) { - const { t } = useTranslation(); - return ( - - ); -} - export default function PlexSidebar({ layout, setLayout, collapsed, onToggleCollapse }: Props) { const { t } = useTranslation(); // Scope / watch-state / sort / expanded filters live in PlexProvider (shared with PlexBrowse and @@ -131,14 +106,12 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl onRemove={() => patch({ directors: filters.directors.filter((x) => x !== d) })} /> ))} - {filters.actors.length > 1 && ( -
- patch({ actorMode: m })} - /> -
- )} + patch({ actorMode: m })} + /> {filters.actors.map((a) => ( - {filters.genres.length > 1 && ( -
- patch({ genreMode: m })} - /> -
- )} + patch({ genreMode: m })} + /> onModeChange(m === "and" ? "all" : "any")} - onChange={(next) => { - // Carry only the selected ids' titles forward (drop titles for de-selected ones). Collapse an - // empty map to undefined so a clear-all key-matches the pristine state (an empty {} still hashes - // differently from an absent key), mirroring the chip-row remove handler. - const nextTitles: Record = {}; - for (const id of next) nextTitles[id] = labelMap[id] ?? titles?.[id] ?? id; - onChange(next, next.length ? nextTitles : undefined); - }} - /> + <> + + { + // Carry only the selected ids' titles forward (drop titles for de-selected ones). Collapse an + // empty map to undefined so a clear-all key-matches the pristine state (an empty {} still hashes + // differently from an absent key), mirroring the chip-row remove handler. + const nextTitles: Record = {}; + for (const id of next) nextTitles[id] = labelMap[id] ?? titles?.[id] ?? id; + onChange(next, next.length ? nextTitles : undefined); + }} + /> + ); } diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index abdbc506..cdfe128b 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -17,6 +17,7 @@ import ChannelPicker from "./ChannelPicker"; import DateFilterSelect from "./DateFilterSelect"; import YearFilter from "./YearFilter"; import Combobox, { type ComboboxOption } from "./ui/Combobox"; +import MatchToggle from "./ui/MatchToggle"; import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; import { useChannelsActions } from "./channelsContext"; import { useMe } from "../lib/useMe"; @@ -101,7 +102,8 @@ export default function Sidebar({ tags: [...filters.tags.filter((id) => !inList.has(id)), ...next.map(Number)], }); } - // Each tag category (language/topic/other) has its OWN any/all match, set from its combobox footer. + // Each tag category (language/topic/other) has its OWN any/all match, set from the switch above its + // picker (ui/MatchToggle); the store keeps the older or/and words. const setTagMode = (category: TagCategory, mode: "or" | "and") => setFilters({ ...filters, tagModes: { ...filters.tagModes, [category]: mode } }); @@ -125,18 +127,23 @@ export default function Sidebar({ } }; return ( - setTagMode("other", m)} - /> + <> + setTagMode("other", m === "all" ? "and" : "or")} + /> + + ); } @@ -165,27 +172,37 @@ export default function Sidebar({ return ; case "language": return ( - setTagSelection(languages, next)} - emptyText={t("sidebar.noMatchingTags")} - matchMode={filters.tagModes.language} - onMatchModeChange={(m) => setTagMode("language", m)} - /> + <> + setTagMode("language", m === "all" ? "and" : "or")} + /> + setTagSelection(languages, next)} + emptyText={t("sidebar.noMatchingTags")} + /> + ); case "topic": return ( - setTagSelection(topics, next)} - emptyText={t("sidebar.noMatchingTags")} - matchMode={filters.tagModes.topic} - onMatchModeChange={(m) => setTagMode("topic", m)} - /> + <> + setTagMode("topic", m === "all" ? "and" : "or")} + /> + setTagSelection(topics, next)} + emptyText={t("sidebar.noMatchingTags")} + /> + ); case "tags": // The widget's own "Manage" trigger is its title (see the PanelItem onTitleClick below), so the diff --git a/frontend/src/components/ui/Combobox.dom.test.tsx b/frontend/src/components/ui/Combobox.dom.test.tsx index b62a5c3d..9daac7fd 100644 --- a/frontend/src/components/ui/Combobox.dom.test.tsx +++ b/frontend/src/components/ui/Combobox.dom.test.tsx @@ -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(); - fireEvent.focus(screen.getByRole("combobox")); - expect(screen.queryByText("sidebar.all")).toBeNull(); - - // Two selections: footer appears with an any/all segment. - rerender({}); - 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( diff --git a/frontend/src/components/ui/Combobox.tsx b/frontend/src/components/ui/Combobox.tsx index de80f27e..812c702f 100644 --- a/frontend/src/components/ui/Combobox.tsx +++ b/frontend/src/components/ui/Combobox.tsx @@ -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; - /** 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({ )} - {/* 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 && ( -
- {t("common.matchMode")} -
- {(["or", "and"] as const).map((m) => ( - - ))} -
-
- )} ); diff --git a/frontend/src/components/ui/MatchToggle.dom.test.tsx b/frontend/src/components/ui/MatchToggle.dom.test.tsx new file mode 100644 index 00000000..7ad73312 --- /dev/null +++ b/frontend/src/components/ui/MatchToggle.dom.test.tsx @@ -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( {}} />); + 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(); + 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"); + }); +}); diff --git a/frontend/src/components/ui/MatchToggle.tsx b/frontend/src/components/ui/MatchToggle.tsx new file mode 100644 index 00000000..4e198efc --- /dev/null +++ b/frontend/src/components/ui/MatchToggle.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index bbf438c6..f7e40201 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -34,6 +34,10 @@ }, "moreRefine": "{{count}} more — refine with search", "matchMode": "Match", + "match": { + "any": "Any", + "all": "All" + }, "removeNamed": "Remove {{name}}", "resetNamed": "Reset {{name}}", "dnd": { diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index ad5882d8..ab172c69 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -92,10 +92,6 @@ "1y": "1 year" }, "contentRating": "Age rating", - "match": { - "any": "Any", - "all": "All" - }, "dir": { "desc": "↓ Descending", "asc": "↑ Ascending" @@ -103,7 +99,6 @@ "collection": "Collection", "collectionSearch": "Search collections…", "collectionEmpty": "No collections", - "matchLabel": "Match", "studio": "Studio", "playable": "Playback", "playableOpt": { diff --git a/frontend/src/i18n/locales/en/sidebar.json b/frontend/src/i18n/locales/en/sidebar.json index 57dc5ad8..897bf525 100644 --- a/frontend/src/i18n/locales/en/sidebar.json +++ b/frontend/src/i18n/locales/en/sidebar.json @@ -14,8 +14,6 @@ "pin": "Pin panel open", "unpin": "Unpin panel", "openPanel": "Open {{title}}", - "any": "Any", - "all": "All", "from": "From", "to": "To", "clearDates": "clear dates", diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json index 3619dd93..a86084b4 100644 --- a/frontend/src/i18n/locales/hu/common.json +++ b/frontend/src/i18n/locales/hu/common.json @@ -34,6 +34,10 @@ }, "moreRefine": "még {{count}} — finomíts kereséssel", "matchMode": "Egyezés", + "match": { + "any": "Bármelyik", + "all": "Mind" + }, "removeNamed": "{{name}}: eltávolítás", "resetNamed": "{{name}}: alaphelyzet", "dnd": { diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 1144d0ad..0f92d39b 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -92,10 +92,6 @@ "1y": "1 év" }, "contentRating": "Korhatár", - "match": { - "any": "Bármelyik", - "all": "Mind" - }, "dir": { "desc": "↓ Csökkenő", "asc": "↑ Növekvő" @@ -103,7 +99,6 @@ "collection": "Kollekció", "collectionSearch": "Kollekciók keresése…", "collectionEmpty": "Nincs kollekció", - "matchLabel": "Egyezés", "studio": "Stúdió", "playable": "Lejátszás", "playableOpt": { diff --git a/frontend/src/i18n/locales/hu/sidebar.json b/frontend/src/i18n/locales/hu/sidebar.json index 0e9b58f6..d8825fcc 100644 --- a/frontend/src/i18n/locales/hu/sidebar.json +++ b/frontend/src/i18n/locales/hu/sidebar.json @@ -14,8 +14,6 @@ "pin": "Panel rögzítése nyitva", "unpin": "Panel rögzítésének feloldása", "openPanel": "{{title}} megnyitása", - "any": "Bármelyik", - "all": "Összes", "from": "Ettől", "to": "Eddig", "clearDates": "dátumok törlése",