From 5ab8666d4a593b1ecb15c9e445e5d43c776e15fd Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 2 Oct 2026 06:47:20 -0500 Subject: [PATCH] add overflow menu to system tabs on mobile --- web/e2e/specs/system.spec.ts | 142 +++++++-------- web/public/locales/en/views/system.json | 2 + .../components/filter/CameraGroupSelector.tsx | 167 ++---------------- web/src/components/mobile/OverflowStrip.tsx | 166 +++++++++++++++++ web/src/pages/System.tsx | 93 ++++++---- web/src/views/live/LiveDashboardView.tsx | 2 +- 6 files changed, 313 insertions(+), 259 deletions(-) create mode 100644 web/src/components/mobile/OverflowStrip.tsx diff --git a/web/e2e/specs/system.spec.ts b/web/e2e/specs/system.spec.ts index bd90ee2bfe..e5d45d03bd 100644 --- a/web/e2e/specs/system.spec.ts +++ b/web/e2e/specs/system.spec.ts @@ -6,42 +6,71 @@ * RestartDialog cancel flow. */ -import { test, expect } from "../fixtures/frigate-test"; +import { test, expect, FrigateApp } from "../fixtures/frigate-test"; import { expectBodyInteractive, waitForBodyInteractive, } from "../helpers/overlay-interaction"; +// On mobile the tabs sit in an OverflowStrip, which keeps an inert copy of +// every tab for measurement and hides the ones that do not fit behind a kebab. +// The selected tab always stays in the strip. + +function tab(frigateApp: FrigateApp, name: string) { + return frigateApp.page + .locator(`[aria-label="Select ${name}" i]:not([inert] *)`) + .first(); +} + +async function selectTab(frigateApp: FrigateApp, name: string) { + const kebab = frigateApp.page.getByLabel("Show all tabs"); + + if (frigateApp.isMobile && (await kebab.isVisible())) { + await kebab.click(); + await frigateApp.page + .locator(`[aria-label="Select ${name}" i]:not([inert] *)`) + .last() + .click(); + return; + } + + await tab(frigateApp, name).click(); +} + +async function expectTabActive(frigateApp: FrigateApp, name: string) { + await expect(tab(frigateApp, name)).toHaveAttribute("data-state", "on", { + timeout: 5_000, + }); +} + test.describe("System — tabs @medium", () => { test("general tab is active by default via #general hash", async ({ frigateApp, }) => { await frigateApp.goto("/system#general"); - await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute( + await expect(tab(frigateApp, "general")).toHaveAttribute( "data-state", "on", { timeout: 15_000 }, ); - await expect(frigateApp.page.getByLabel("Select storage")).toBeVisible(); - await expect(frigateApp.page.getByLabel("Select cameras")).toBeVisible(); + if (!frigateApp.isMobile) { + await expect(tab(frigateApp, "storage")).toBeVisible(); + await expect(tab(frigateApp, "cameras")).toBeVisible(); + } }); test("Storage tab activates and deactivates General", async ({ frigateApp, }) => { await frigateApp.goto("/system#general"); - await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute( + await expect(tab(frigateApp, "general")).toHaveAttribute( "data-state", "on", { timeout: 15_000 }, ); - await frigateApp.page.getByLabel("Select storage").click(); - await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute( - "data-state", - "on", - { timeout: 5_000 }, - ); - await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute( + await selectTab(frigateApp, "storage"); + await expectTabActive(frigateApp, "storage"); + await expect(tab(frigateApp, "general")).toHaveAttribute( "data-state", "off", ); @@ -49,24 +78,20 @@ test.describe("System — tabs @medium", () => { test("Cameras tab activates", async ({ frigateApp }) => { await frigateApp.goto("/system#general"); - await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute( + await expect(tab(frigateApp, "general")).toHaveAttribute( "data-state", "on", { timeout: 15_000 }, ); - await frigateApp.page.getByLabel("Select cameras").click(); - await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute( - "data-state", - "on", - { timeout: 5_000 }, - ); + await selectTab(frigateApp, "cameras"); + await expectTabActive(frigateApp, "cameras"); }); test("general tab shows version and last-refreshed", async ({ frigateApp, }) => { await frigateApp.goto("/system#general"); - await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute( + await expect(tab(frigateApp, "general")).toHaveAttribute( "data-state", "on", { timeout: 15_000 }, @@ -87,17 +112,13 @@ test.describe("System — tabs @medium", () => { frigateApp, }) => { await frigateApp.goto("/system#general"); - await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute( + await expect(tab(frigateApp, "general")).toHaveAttribute( "data-state", "on", { timeout: 15_000 }, ); - await frigateApp.page.getByLabel("Select storage").click(); - await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute( - "data-state", - "on", - { timeout: 5_000 }, - ); + await selectTab(frigateApp, "storage"); + await expectTabActive(frigateApp, "storage"); // On desktop, tab buttons render text labels so the word "storage" // always appears in #pageRoot after switching. On mobile, tabs are // icon-only, so we verify the general-tab content disappears instead @@ -112,25 +133,23 @@ test.describe("System — tabs @medium", () => { } else { // Mobile: tab activation (data-state "on") already asserted above. // Additionally confirm general tab is no longer the active tab. - await expect( - frigateApp.page.getByLabel("Select general"), - ).toHaveAttribute("data-state", "off", { timeout: 5_000 }); + await expect(tab(frigateApp, "general")).toHaveAttribute( + "data-state", + "off", + { timeout: 5_000 }, + ); } }); test("cameras tab renders each configured camera", async ({ frigateApp }) => { await frigateApp.goto("/system#general"); - await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute( + await expect(tab(frigateApp, "general")).toHaveAttribute( "data-state", "on", { timeout: 15_000 }, ); - await frigateApp.page.getByLabel("Select cameras").click(); - await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute( - "data-state", - "on", - { timeout: 5_000 }, - ); + await selectTab(frigateApp, "cameras"); + await expectTabActive(frigateApp, "cameras"); // Cameras tab lists every camera from config/stats. The default // mock has front_door, backyard, garage. for (const cam of ["front_door", "backyard", "garage"]) { @@ -151,17 +170,13 @@ test.describe("System — tabs @medium", () => { config: { semantic_search: { enabled: true } }, }); await frigateApp.goto("/system#general"); - await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute( + await expect(tab(frigateApp, "general")).toHaveAttribute( "data-state", "on", { timeout: 15_000 }, ); - const enrichTab = frigateApp.page.getByLabel(/select enrichments/i).first(); - await expect(enrichTab).toBeVisible({ timeout: 5_000 }); - await enrichTab.click(); - await expect(enrichTab).toHaveAttribute("data-state", "on", { - timeout: 5_000, - }); + await selectTab(frigateApp, "enrichments"); + await expectTabActive(frigateApp, "enrichments"); }); }); @@ -223,31 +238,27 @@ test.describe("System — mobile @medium @mobile", () => { test("tabs render at mobile viewport", async ({ frigateApp }) => { await frigateApp.goto("/system#general"); - await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({ + await expect(tab(frigateApp, "general")).toBeVisible({ timeout: 15_000, }); }); test("switching tabs works at mobile viewport", async ({ frigateApp }) => { await frigateApp.goto("/system#general"); - await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute( + await expect(tab(frigateApp, "general")).toHaveAttribute( "data-state", "on", { timeout: 15_000 }, ); - await frigateApp.page.getByLabel("Select storage").click(); - await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute( - "data-state", - "on", - { timeout: 5_000 }, - ); + await selectTab(frigateApp, "storage"); + await expectTabActive(frigateApp, "storage"); }); test("header controls leave the logo uncovered on a narrow phone", async ({ frigateApp, }) => { await frigateApp.goto("/system#general"); - await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute( + await expect(tab(frigateApp, "general")).toHaveAttribute( "data-state", "on", { timeout: 15_000 }, @@ -255,9 +266,9 @@ test.describe("System — mobile @medium @mobile", () => { await frigateApp.page.setViewportSize({ width: 320, height: 740 }); const logo = frigateApp.page.locator("svg.fill-current").first(); - const tabs = frigateApp.page - .locator("[data-radix-scroll-area-viewport]") - .filter({ has: frigateApp.page.getByLabel("Select general") }); + const kebab = frigateApp.page.getByLabel("Show all tabs"); + await expect(kebab).toBeVisible(); + const tabs = kebab.locator(".."); const refreshed = frigateApp.page.getByText(/Just now|ago/); const logoBox = await logo.boundingBox(); @@ -267,20 +278,9 @@ test.describe("System — mobile @medium @mobile", () => { expect(tabsBox!.x + tabsBox!.width).toBeLessThanOrEqual(logoBox!.x + 1); expect(refreshedBox!.x).toBeGreaterThanOrEqual(logoBox!.x + logoBox!.width); - // the clipped tabs stay reachable by scrolling - const overflow = await tabs.evaluate((el) => ({ - scroll: el.scrollWidth, - client: el.clientWidth, - })); - expect(overflow.scroll).toBeGreaterThan(overflow.client); - await tabs.evaluate((el) => { - el.scrollLeft = el.scrollWidth; - }); - await frigateApp.page.getByLabel("Select cameras").click(); - await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute( - "data-state", - "on", - { timeout: 5_000 }, - ); + // the tabs that do not fit stay reachable through the kebab + await selectTab(frigateApp, "cameras"); + await expectTabActive(frigateApp, "cameras"); + await expect(frigateApp.page.getByLabel("Show less")).toHaveCount(0); }); }); diff --git a/web/public/locales/en/views/system.json b/web/public/locales/en/views/system.json index ad52d48d57..f4fb03e47d 100644 --- a/web/public/locales/en/views/system.json +++ b/web/public/locales/en/views/system.json @@ -14,6 +14,8 @@ }, "title": "System", "metrics": "System metrics", + "showAllTabs": "Show all tabs", + "showLessTabs": "Show less", "health": { "title": "Health", "notices": { diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index 2ebd6207b1..2537dadfe4 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -8,18 +8,8 @@ import { isDesktop, isMobile } from "react-device-detect"; import useSWR from "swr"; import { MdHome } from "react-icons/md"; import { Button, buttonVariants } from "../ui/button"; -import { - useCallback, - useContext, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; -import { AnimatePresence, motion } from "framer-motion"; -import { HiDotsHorizontal } from "react-icons/hi"; -import { IoClose } from "react-icons/io5"; +import { useCallback, useContext, useEffect, useMemo, useState } from "react"; +import OverflowStrip from "../mobile/OverflowStrip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { LuPencil, LuPlus } from "react-icons/lu"; import { @@ -156,80 +146,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { const [addGroup, setAddGroup] = useState(false); - // mobile overflow reveal - the group strip sits left of the logo and is - // clipped (not scrollable) when there are too many groups, so render only - // the buttons that fully fit and surface a kebab next to the last visible - // one that expands a panel revealing all of them - - const [expanded, setExpanded] = useState(false); - // null => all buttons fit, render them all with no kebab; a number => only - // that many fit alongside the kebab - const [visibleCount, setVisibleCount] = useState(null); - const wrapperRef = useRef(null); - const measureRef = useRef(null); - - useLayoutEffect(() => { - if (isDesktop) { - return; - } - - const wrapper = wrapperRef.current; - const measure = measureRef.current; - - if (!wrapper || !measure) { - return; - } - - const gap = 8; // gap-2 between buttons in the strip - const wrapperGap = 4; // gap-1 between the strip and the kebab - - const compute = () => { - const buttons = Array.from(measure.children) as HTMLElement[]; - - if (buttons.length === 0) { - return; - } - - // the trailing child of the measurement row is a kebab clone - const kebab = buttons[buttons.length - 1]; - const groupButtons = buttons.slice(0, -1); - const available = wrapper.clientWidth; - const fullWidth = - groupButtons.reduce((sum, el) => sum + el.offsetWidth, 0) + - Math.max(groupButtons.length - 1, 0) * gap; - - if (fullWidth <= available) { - setVisibleCount(null); - return; - } - - const budget = available - kebab.offsetWidth - wrapperGap; - let used = 0; - let count = 0; - - for (const el of groupButtons) { - const next = (count === 0 ? 0 : gap) + el.offsetWidth; - - if (used + next <= budget) { - used += next; - count += 1; - } else { - break; - } - } - - setVisibleCount(Math.max(count, 1)); - }; - - compute(); - - const observer = new ResizeObserver(compute); - observer.observe(wrapper); - - return () => observer.disconnect(); - }, [groups, isAdmin]); - - const groupButtons = (afterSelect?: () => void) => { + const groupButtons = () => { const buttons = [ - )} - - {/* invisible row used only to measure natural button widths so we - can render exactly the buttons that fully fit */} -
-
- {groupButtons()} - -
-
- - {expanded && ( -
setExpanded(false)} - /> - )} - - {expanded && ( - -
- {groupButtons(() => setExpanded(false))} - -
-
- )} -
-
+ name == group) + 1} + showAllLabel={t("group.showAll")} + showLessLabel={t("group.showLess")} + /> )} ); diff --git a/web/src/components/mobile/OverflowStrip.tsx b/web/src/components/mobile/OverflowStrip.tsx new file mode 100644 index 0000000000..9403b8a627 --- /dev/null +++ b/web/src/components/mobile/OverflowStrip.tsx @@ -0,0 +1,166 @@ +import { ReactNode, useLayoutEffect, useRef, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { HiDotsHorizontal } from "react-icons/hi"; +import { IoClose } from "react-icons/io5"; +import { Button } from "../ui/button"; +import { cn } from "@/lib/utils"; + +type OverflowStripProps = { + className?: string; + items: ReactNode[]; + activeIndex?: number; + gapClassName?: string; + showAllLabel: string; + showLessLabel: string; +}; + +// Renders only the items that fully fit and surfaces a kebab next to the last +// visible one. The kebab expands a panel over the nearest positioned ancestor +// that reveals every item. +export default function OverflowStrip({ + className, + items, + activeIndex = 0, + gapClassName = "gap-2", + showAllLabel, + showLessLabel, +}: OverflowStripProps) { + const [expanded, setExpanded] = useState(false); + // null => all items fit, render them all with no kebab; a number => only + // that many fit alongside the kebab + const [visibleCount, setVisibleCount] = useState(null); + const wrapperRef = useRef(null); + const measureRef = useRef(null); + + useLayoutEffect(() => { + const wrapper = wrapperRef.current; + const measure = measureRef.current; + + if (!wrapper || !measure) { + return; + } + + const wrapperGap = 4; // gap-1 between the strip and the kebab + + const compute = () => { + const children = Array.from(measure.children) as HTMLElement[]; + + if (children.length === 0) { + return; + } + + // the trailing child of the measurement row is a kebab clone + const kebab = children[children.length - 1]; + const start = children[0].offsetLeft; + const ends = children + .slice(0, -1) + .map((el) => el.offsetLeft + el.offsetWidth - start); + const available = wrapper.clientWidth; + + if (ends[ends.length - 1] <= available) { + setVisibleCount(null); + return; + } + + const budget = available - kebab.offsetWidth - wrapperGap; + const count = ends.filter((end) => end <= budget).length; + + setVisibleCount(Math.max(count, 1)); + }; + + compute(); + + const observer = new ResizeObserver(compute); + observer.observe(wrapper); + + return () => observer.disconnect(); + }, [items.length, gapClassName]); + + // a selected item past the cut takes the last visible slot + const visibleItems = + visibleCount == null + ? items + : activeIndex >= visibleCount + ? [...items.slice(0, visibleCount - 1), items[activeIndex]] + : items.slice(0, visibleCount); + + return ( +
+
+ {visibleItems} +
+ {visibleCount != null && ( + + )} + + {/* invisible row used only to measure natural item widths so we can + render exactly the items that fully fit */} +
+
+ {items} + +
+
+ + {expanded && ( +
setExpanded(false)} + /> + )} + + {expanded && ( + + {/* a tap on any item bubbles up and collapses the panel */} +
setExpanded(false)} + > + {items} + +
+
+ )} +
+
+ ); +} diff --git a/web/src/pages/System.tsx b/web/src/pages/System.tsx index ae65bb5a5f..82b8c73c80 100644 --- a/web/src/pages/System.tsx +++ b/web/src/pages/System.tsx @@ -1,8 +1,10 @@ import useSWR from "swr"; import { FrigateStats } from "@/types/stats"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { ReactNode, useEffect, useMemo, useRef, useState } from "react"; import TimeAgo from "@/components/dynamic/TimeAgo"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { Toggle } from "@/components/ui/toggle"; +import OverflowStrip from "@/components/mobile/OverflowStrip"; import { isDesktop, isMobile } from "react-device-detect"; import GeneralMetrics from "@/views/system/GeneralMetrics"; import StorageMetrics from "@/views/system/StorageMetrics"; @@ -36,6 +38,14 @@ const allMetrics = [ ] as const; type SystemMetric = (typeof allMetrics)[number]; +const metricIcons: Record = { + health: , + general: , + enrichments: , + storage: , + cameras: , +}; + function System() { const { t } = useTranslation(["views/system"]); const { data: config } = useSWR("config", { @@ -98,43 +108,66 @@ function System() { {isMobile && ( )} - -
- { - if (value) { - setPageToggle(value); - } - }} // don't allow the severity to be unselected - > - {Object.values(metrics).map((item) => ( - + ( + setPageToggle(item)} aria-label={t("selectItem", { ns: "common", item: t(item + ".title"), })} > - {item == "health" && } - {item == "general" && } - {item == "enrichments" && } - {item == "storage" && } - {item == "cameras" && } - {isDesktop && ( -
{t(item + ".title")}
- )} -
+ {metricIcons[item]} + ))} -
- + activeIndex={metrics.indexOf(pageToggle)} + gapClassName="gap-0.5" + showAllLabel={t("showAllTabs")} + showLessLabel={t("showLessTabs")} + />
-
+ ) : ( + +
+ { + if (value) { + setPageToggle(value); + } + }} // don't allow the severity to be unselected + > + {Object.values(metrics).map((item) => ( + + {metricIcons[item]} +
{t(item + ".title")}
+
+ ))} +
+ +
+
+ )}
{pageToggle == "health" && ( diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx index ab0cc2ca2d..6f91ed7b70 100644 --- a/web/src/views/live/LiveDashboardView.tsx +++ b/web/src/views/live/LiveDashboardView.tsx @@ -419,7 +419,7 @@ export default function LiveDashboardView({ {isMobile && (
-
+
{(!cameraGroup || cameraGroup == "default" || isMobileOnly) && (