adaptively size bottom bar nav targets to 48px when they fit, else compact

icon size now targets the standardized 48×48px mobile touch target (Material Design 3 / Android 48dp bottom-nav minimum)
This commit is contained in:
Josh Hawkins 2026-06-04 10:27:38 -05:00
parent 8e878cd9c4
commit cb2568a9c3
3 changed files with 83 additions and 10 deletions

View File

@ -82,9 +82,13 @@ import { MdCategory } from "react-icons/md";
type GeneralSettingsProps = { type GeneralSettingsProps = {
className?: string; className?: string;
large?: boolean;
}; };
export default function GeneralSettings({ className }: GeneralSettingsProps) { export default function GeneralSettings({
className,
large,
}: GeneralSettingsProps) {
const { t } = useTranslation(["common", "views/settings"]); const { t } = useTranslation(["common", "views/settings"]);
const { getLocaleDocUrl } = useDocDomain(); const { getLocaleDocUrl } = useDocDomain();
const { data: profile } = useSWR("profile"); const { data: profile } = useSWR("profile");
@ -225,10 +229,13 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
isDesktop isDesktop
? "cursor-pointer rounded-lg bg-secondary text-secondary-foreground hover:bg-muted" ? "cursor-pointer rounded-lg bg-secondary text-secondary-foreground hover:bg-muted"
: "text-secondary-foreground", : "text-secondary-foreground",
large && "size-12",
className, className,
)} )}
> >
<LuSettings className="size-5 md:m-[6px]" /> <LuSettings
className={cn("md:m-[6px]", large ? "size-6" : "size-5")}
/>
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipPortal> <TooltipPortal>

View File

@ -4,7 +4,14 @@ import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateStats } from "@/types/stats"; import { FrigateStats } from "@/types/stats";
import { useEmbeddingsReindexProgress, useFrigateStats } from "@/api/ws"; import { useEmbeddingsReindexProgress, useFrigateStats } from "@/api/ws";
import { useContext, useEffect, useMemo } from "react"; import {
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import useStats from "@/hooks/use-stats"; import useStats from "@/hooks/use-stats";
import GeneralSettings from "../menu/GeneralSettings"; import GeneralSettings from "../menu/GeneralSettings";
import useNavigation from "@/hooks/use-navigation"; import useNavigation from "@/hooks/use-navigation";
@ -21,8 +28,47 @@ import { useTranslation } from "react-i18next";
function Bottombar() { function Bottombar() {
const navItems = useNavigation("secondary"); const navItems = useNavigation("secondary");
// Render 48px touch targets when they fit with even spacing, otherwise fall
// back to the compact size. Measured against the live bar width and icon
// count (which varies with enabled nav items and the status alert).
const containerRef = useRef<HTMLDivElement | null>(null);
const [large, setLarge] = useState(false);
useLayoutEffect(() => {
const el = containerRef.current;
if (!el) {
return;
}
const TARGET = 48; // standard bottom-nav touch target (px)
const MIN_GAP = 8; // minimum spacing between targets (px)
const compute = () => {
const count = el.children.length;
if (count === 0) {
return;
}
const needed = count * TARGET + Math.max(count - 1, 0) * MIN_GAP;
setLarge(needed <= el.clientWidth);
};
compute();
const resize = new ResizeObserver(compute);
resize.observe(el);
// recompute when items are added/removed (e.g. the status alert appears)
const mutation = new MutationObserver(compute);
mutation.observe(el, { childList: true });
return () => {
resize.disconnect();
mutation.disconnect();
};
}, [navItems]);
return ( return (
<div <div
ref={containerRef}
className={cn( className={cn(
"absolute inset-x-4 bottom-0 flex h-16 flex-row items-center justify-between", "absolute inset-x-4 bottom-0 flex h-16 flex-row items-center justify-between",
isMobile && isMobile &&
@ -32,18 +78,25 @@ function Bottombar() {
)} )}
> >
{navItems.map((item) => ( {navItems.map((item) => (
<NavItem key={item.id} className="p-2" item={item} Icon={item.icon} /> <NavItem
key={item.id}
large={large}
className="p-2"
item={item}
Icon={item.icon}
/>
))} ))}
<GeneralSettings className="p-2" /> <GeneralSettings large={large} className="p-2" />
<StatusAlertNav className="p-2" /> <StatusAlertNav large={large} className="p-2" />
</div> </div>
); );
} }
type StatusAlertNavProps = { type StatusAlertNavProps = {
className?: string; className?: string;
large?: boolean;
}; };
function StatusAlertNav({ className }: StatusAlertNavProps) { function StatusAlertNav({ className, large }: StatusAlertNavProps) {
const { t } = useTranslation(["views/system"]); const { t } = useTranslation(["views/system"]);
const { data: initialStats } = useSWR<FrigateStats>("stats", { const { data: initialStats } = useSWR<FrigateStats>("stats", {
revalidateOnFocus: false, revalidateOnFocus: false,
@ -105,8 +158,18 @@ function StatusAlertNav({ className }: StatusAlertNavProps) {
return ( return (
<Drawer> <Drawer>
<DrawerTrigger asChild> <DrawerTrigger asChild>
<div className="p-2"> <div
<IoIosWarning className="size-5 text-danger md:m-[6px]" /> className={cn(
"flex flex-col items-center justify-center p-2",
large && "size-12",
)}
>
<IoIosWarning
className={cn(
"text-danger md:m-[6px]",
large ? "size-6" : "size-5",
)}
/>
</div> </div>
</DrawerTrigger> </DrawerTrigger>
<DrawerContent <DrawerContent

View File

@ -27,6 +27,7 @@ type NavItemProps = {
item: NavData; item: NavData;
Icon: IconType; Icon: IconType;
onClick?: () => void; onClick?: () => void;
large?: boolean;
}; };
export default function NavItem({ export default function NavItem({
@ -34,6 +35,7 @@ export default function NavItem({
item, item,
Icon, Icon,
onClick, onClick,
large,
}: NavItemProps) { }: NavItemProps) {
const { t } = useTranslation(["common"]); const { t } = useTranslation(["common"]);
if (item.enabled == false) { if (item.enabled == false) {
@ -48,11 +50,12 @@ export default function NavItem({
cn( cn(
"flex flex-col items-center justify-center rounded-lg p-[6px]", "flex flex-col items-center justify-center rounded-lg p-[6px]",
className, className,
large && "size-12",
variants[item.variant ?? "primary"][isActive ? "active" : "inactive"], variants[item.variant ?? "primary"][isActive ? "active" : "inactive"],
) )
} }
> >
<Icon className="size-5" /> <Icon className={large ? "size-6" : "size-5"} />
</NavLink> </NavLink>
); );