feat(ui): implement approved A3 hybrid navigation
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
Chip,
|
||||
Divider,
|
||||
Drawer,
|
||||
GlobalStyles,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
@@ -32,6 +33,7 @@ import MenuIcon from "@mui/icons-material/Menu";
|
||||
import MenuOpenIcon from "@mui/icons-material/MenuOpen";
|
||||
import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone";
|
||||
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
|
||||
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
|
||||
|
||||
import JobbjaktMark from "../assets/JobbjaktMark";
|
||||
import { PageHeader, type PageWidth } from "./PageLayout";
|
||||
@@ -137,7 +139,8 @@ export default function AppShell({
|
||||
}) {
|
||||
const { language, setLanguage, t } = useI18n();
|
||||
const notificationCountId = useId();
|
||||
const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down("md"));
|
||||
const mobileDrawerId = useId();
|
||||
const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down("sm"));
|
||||
const [desktopNavCollapsed, setDesktopNavCollapsed] = useState(() => {
|
||||
try {
|
||||
return window.localStorage.getItem(DESKTOP_SIDEBAR_KEY) === "1";
|
||||
@@ -149,9 +152,12 @@ export default function AppShell({
|
||||
const navCollapsed = !isMobile && desktopNavCollapsed;
|
||||
const [header, setHeader] = useState<HTMLElement | null>(null);
|
||||
const [headerHeight, setHeaderHeight] = useState<number>();
|
||||
const [bottomNavigation, setBottomNavigation] = useState<HTMLElement | null>(null);
|
||||
const [bottomNavigationHeight, setBottomNavigationHeight] = useState(0);
|
||||
const [secondaryMenu, setSecondaryMenu] = useState<{ anchor: HTMLElement; item: NavItem } | null>(null);
|
||||
const pageTitleRef = useRef<HTMLHeadingElement>(null);
|
||||
const pendingFocusPath = useRef<string | null>(null);
|
||||
const focusAfterDrawerNavigation = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile && drawerOpen) onToggleDrawer(false);
|
||||
@@ -166,6 +172,16 @@ export default function AppShell({
|
||||
return () => observer.disconnect();
|
||||
}, [header]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bottomNavigation) { setBottomNavigationHeight(0); return; }
|
||||
const measure = () => setBottomNavigationHeight(Math.ceil(bottomNavigation.getBoundingClientRect().height));
|
||||
measure();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(bottomNavigation);
|
||||
return () => observer.disconnect();
|
||||
}, [bottomNavigation]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(DESKTOP_SIDEBAR_KEY, desktopNavCollapsed ? "1" : "0");
|
||||
@@ -182,12 +198,16 @@ export default function AppShell({
|
||||
}, [pathname]);
|
||||
|
||||
const navigateFromShell = (to: string) => {
|
||||
setSecondaryMenu(null);
|
||||
if (isMobile && drawerOpen) {
|
||||
focusAfterDrawerNavigation.current = true;
|
||||
onToggleDrawer(false);
|
||||
}
|
||||
if (pathname === to) {
|
||||
pageTitleRef.current?.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
pendingFocusPath.current = to;
|
||||
setSecondaryMenu(null);
|
||||
onNavigate(to);
|
||||
};
|
||||
|
||||
@@ -218,6 +238,22 @@ export default function AppShell({
|
||||
() => activeNavItemTo(pathname, [...nav, ...navBottom]),
|
||||
[pathname, nav, navBottom],
|
||||
);
|
||||
const mobilePrimary = useMemo(() => {
|
||||
const visible: NavItem[] = [];
|
||||
const visit = (item: NavItem) => {
|
||||
if (item.hidden) return;
|
||||
visible.push(item); item.children?.forEach(visit);
|
||||
};
|
||||
[...nav, ...navBottom].forEach(visit);
|
||||
return ["/dashboard", "/jobs", "/discover", "/career"]
|
||||
.map(to => visible.find(item => item.to === to)).filter((item): item is NavItem => Boolean(item));
|
||||
}, [nav, navBottom]);
|
||||
const mobileActive = mobilePrimary.find(item => [item.to, ...(item.activeFor ?? [])]
|
||||
.some(to => pathname === to || pathname.startsWith(`${to}/`)))?.to;
|
||||
const mobileLabels: Record<string, string> = {
|
||||
"/dashboard": t("dashboard"), "/jobs": t("navigationApplications"),
|
||||
"/discover": t("navigationDiscover"), "/career": t("career"),
|
||||
};
|
||||
const desktopNavigationRef = useRef<HTMLDivElement>(null);
|
||||
const mobileNavigationRef = useRef<HTMLDivElement>(null);
|
||||
// New destinations can extend the existing scrollable list below its initial viewport.
|
||||
@@ -364,6 +400,7 @@ export default function AppShell({
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", minHeight: "100vh" }}>
|
||||
<GlobalStyles styles={{ ":root": { "--app-bottom-nav-height": `${isMobile ? Math.max(72, bottomNavigationHeight) : 0}px` } }} />
|
||||
<Box component="span" id={notificationCountId} role="status" aria-live="polite" aria-atomic="true" sx={visuallyHidden}>
|
||||
{notificationsCount === undefined ? t("notificationsUnreadCountUnavailable") : t("notificationsUnreadCount", { count: notificationsCount })}
|
||||
</Box>
|
||||
@@ -403,6 +440,7 @@ export default function AppShell({
|
||||
aria-label={t("openNavigation")}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={drawerOpen}
|
||||
aria-controls={mobileDrawerId}
|
||||
onClick={() => onToggleDrawer(true)}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }}
|
||||
>
|
||||
@@ -621,9 +659,14 @@ export default function AppShell({
|
||||
onClose={() => onToggleDrawer(false)}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
slotProps={{
|
||||
paper: { "aria-label": t("navigation") },
|
||||
paper: { id: mobileDrawerId, "aria-label": t("navigation") },
|
||||
transition: { onEntered: (node: HTMLElement) => {
|
||||
if (node === document.activeElement || !node.contains(document.activeElement)) node.querySelector<HTMLElement>('[role="button"]')?.focus();
|
||||
}, onExited: () => {
|
||||
if (focusAfterDrawerNavigation.current) {
|
||||
focusAfterDrawerNavigation.current = false;
|
||||
pageTitleRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
} },
|
||||
}}
|
||||
sx={{
|
||||
@@ -648,7 +691,7 @@ export default function AppShell({
|
||||
"--app-shell-header-height": `${headerHeight ?? (isMobile ? 124 : 76)}px`,
|
||||
p: "var(--app-page-gutter)",
|
||||
// Keep the usual feedback lane stable before a delayed toast arrives.
|
||||
pb: "calc(var(--app-page-gutter) + max(96px, var(--app-toast-clearance, 0px)) + env(safe-area-inset-bottom, 0px))",
|
||||
pb: "calc(var(--app-page-gutter) + var(--app-bottom-nav-height, 0px) + max(96px, var(--app-toast-clearance, 0px)) + env(safe-area-inset-bottom, 0px))",
|
||||
bgcolor: "background.default",
|
||||
minHeight: "100vh",
|
||||
minWidth: 0,
|
||||
@@ -672,6 +715,25 @@ export default function AppShell({
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
{isMobile ? <Box component="nav" ref={setBottomNavigation} aria-label={t("navigationPrimary")}
|
||||
sx={{ position: "fixed", bottom: 0, insetInline: 0, zIndex: theme => theme.zIndex.appBar,
|
||||
display: "flex", gap: 0.5, minHeight: 72, p: 1, px: 0.5, pb: "max(8px, env(safe-area-inset-bottom, 0px))",
|
||||
bgcolor: "background.paper", borderTop: "1px solid", borderColor: "divider" }}>
|
||||
{mobilePrimary.map(item => <Button key={item.to} onClick={() => navigateFromShell(item.to)}
|
||||
aria-current={mobileActive === item.to ? "page" : undefined}
|
||||
sx={{ flex: "1 1 auto", minWidth: 44, minHeight: 56, p: 0.25, gap: 0.25, flexDirection: "column", fontSize: 11, letterSpacing: 0,
|
||||
lineHeight: 1.25, overflowWrap: "anywhere", boxShadow: "none", color: mobileActive === item.to ? "primary.main" : "text.secondary",
|
||||
bgcolor: mobileActive === item.to ? "action.selected" : "transparent" }}>
|
||||
{item.icon}<span>{mobileLabels[item.to]}</span>
|
||||
</Button>)}
|
||||
<Button onClick={() => onToggleDrawer(true)} aria-haspopup="dialog" aria-controls={mobileDrawerId}
|
||||
aria-expanded={drawerOpen} aria-current={!mobileActive ? "page" : undefined}
|
||||
sx={{ flex: "1 1 auto", minWidth: 44, minHeight: 56, p: 0.25, gap: 0.25, flexDirection: "column", fontSize: 11, letterSpacing: 0,
|
||||
lineHeight: 1.25, overflowWrap: "anywhere", boxShadow: "none", color: !mobileActive ? "primary.main" : "text.secondary",
|
||||
bgcolor: !mobileActive ? "action.selected" : "transparent" }}>
|
||||
<MoreHorizIcon fontSize="small" /><span>{t("navigationMore")}</span>
|
||||
</Button>
|
||||
</Box> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user