fix(ui): complete P5-00 shell readiness gates
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
TextField,
|
||||
@@ -135,21 +136,22 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
|
||||
|
||||
<List sx={{ mt: 1 }}>
|
||||
{allItems.length === 0 ? (
|
||||
<Box sx={{ px: 2, py: 3 }}>
|
||||
<ListItem sx={{ px: 2, py: 3 }}>
|
||||
<Typography sx={{ color: "text.secondary" }}>{t("noMatchingCommands")}</Typography>
|
||||
</Box>
|
||||
</ListItem>
|
||||
) : (
|
||||
allItems.map((item) => (
|
||||
<ListItemButton
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
item.action();
|
||||
onClose();
|
||||
}}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
<ListItemText primary={item.label} secondary={item.hint} />
|
||||
</ListItemButton>
|
||||
<ListItem key={item.id} disablePadding>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
item.action();
|
||||
onClose();
|
||||
}}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
<ListItemText primary={item.label} secondary={item.hint} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
|
||||
@@ -13,6 +13,8 @@ export const translations = {
|
||||
trash: "Trash",
|
||||
settings: "Settings",
|
||||
openNavigation: "Open navigation",
|
||||
navigation: "Navigation",
|
||||
applicationVersion: "Application version {version}",
|
||||
expandSidebar: "Expand sidebar",
|
||||
collapseSidebar: "Collapse sidebar",
|
||||
addJob: "Add Job",
|
||||
@@ -2464,6 +2466,8 @@ export const translations = {
|
||||
trash: "Papirkurv",
|
||||
settings: "Innstillinger",
|
||||
openNavigation: "Åpne navigasjon",
|
||||
navigation: "Navigasjon",
|
||||
applicationVersion: "Applikasjonsversjon {version}",
|
||||
expandSidebar: "Utvid sidemenyen",
|
||||
collapseSidebar: "Skjul sidemenyen",
|
||||
addJob: "Legg til jobb",
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
ListSubheader,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Toolbar,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import type { Theme } from "@mui/material/styles";
|
||||
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import MenuOpenIcon from "@mui/icons-material/MenuOpen";
|
||||
@@ -118,7 +120,7 @@ export default function AppShell({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { language, setLanguage, t } = useI18n();
|
||||
const isMobile = useMediaQuery("(max-width:767.95px)");
|
||||
const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down("md"));
|
||||
const [desktopNavCollapsed, setDesktopNavCollapsed] = useState(() => {
|
||||
try {
|
||||
return window.localStorage.getItem(DESKTOP_SIDEBAR_KEY) === "1";
|
||||
@@ -127,6 +129,22 @@ export default function AppShell({
|
||||
}
|
||||
});
|
||||
const drawerWidth = isMobile ? 254 : desktopNavCollapsed ? 84 : 254;
|
||||
const navCollapsed = !isMobile && desktopNavCollapsed;
|
||||
const [header, setHeader] = useState<HTMLElement | null>(null);
|
||||
const [headerHeight, setHeaderHeight] = useState<number>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile && drawerOpen) onToggleDrawer(false);
|
||||
}, [isMobile, drawerOpen, onToggleDrawer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!header || typeof ResizeObserver === "undefined") return;
|
||||
const measure = () => setHeaderHeight(Math.ceil(header.getBoundingClientRect().height));
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(header);
|
||||
return () => observer.disconnect();
|
||||
}, [header]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
@@ -159,15 +177,15 @@ export default function AppShell({
|
||||
);
|
||||
|
||||
const renderNavList = (groups: Array<[string, NavItem[]]>) => (
|
||||
<Box sx={{ px: desktopNavCollapsed ? 0.75 : 1.25, pt: 1 }}>
|
||||
<Box sx={{ px: navCollapsed ? 0.75 : 1.25, pt: 1 }}>
|
||||
{groups.map(([section, rows]) => (
|
||||
<Box key={section || "_"} sx={{ mb: desktopNavCollapsed ? 1 : 1.25 }}>
|
||||
{section && !desktopNavCollapsed ? (
|
||||
<Box key={section || "_"} sx={{ mb: navCollapsed ? 1 : 1.25 }}>
|
||||
{section && !navCollapsed ? (
|
||||
<Typography variant="caption" sx={{ px: 1.25, color: SIDEBAR_TEXT_MUTED, fontWeight: 600, textTransform: "uppercase" }}>
|
||||
{section}
|
||||
</Typography>
|
||||
) : null}
|
||||
<List sx={{ px: desktopNavCollapsed ? 0.25 : 0.75, pt: desktopNavCollapsed ? 0.25 : 0.75 }}>
|
||||
<List sx={{ px: navCollapsed ? 0.25 : 0.75, pt: navCollapsed ? 0.25 : 0.75 }}>
|
||||
{rows.map((item) => {
|
||||
const selected = item.to === activeTo;
|
||||
return (
|
||||
@@ -175,12 +193,14 @@ export default function AppShell({
|
||||
<ListItemButton
|
||||
selected={selected}
|
||||
onClick={() => onNavigate(item.to)}
|
||||
title={desktopNavCollapsed ? item.label : undefined}
|
||||
aria-label={item.label}
|
||||
aria-current={selected ? "page" : undefined}
|
||||
title={navCollapsed ? item.label : undefined}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
minHeight: 44,
|
||||
px: desktopNavCollapsed ? 1 : 1.5,
|
||||
justifyContent: desktopNavCollapsed ? "center" : "flex-start",
|
||||
px: navCollapsed ? 1 : 1.5,
|
||||
justifyContent: navCollapsed ? "center" : "flex-start",
|
||||
border: "1px solid transparent",
|
||||
color: SIDEBAR_TEXT_MUTED,
|
||||
"&:hover": { backgroundColor: "var(--palette-navigation-hover)", color: SIDEBAR_TEXT },
|
||||
@@ -193,14 +213,14 @@ export default function AppShell({
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: desktopNavCollapsed ? 0 : 36, justifyContent: "center", color: selected ? SIDEBAR_SELECTED_ICON : SIDEBAR_TEXT_MUTED }}>
|
||||
<ListItemIcon sx={{ minWidth: navCollapsed ? 0 : 36, justifyContent: "center", color: selected ? SIDEBAR_SELECTED_ICON : SIDEBAR_TEXT_MUTED }}>
|
||||
{item.badgeCount && item.badgeCount > 0 ? (
|
||||
<Badge color="error" badgeContent={item.badgeCount > 99 ? "99+" : item.badgeCount}>
|
||||
{item.icon}
|
||||
</Badge>
|
||||
) : item.icon}
|
||||
</ListItemIcon>
|
||||
{!desktopNavCollapsed ? <ListItemText primary={item.label} primaryTypographyProps={{ fontWeight: 600 }} /> : null}
|
||||
{!navCollapsed ? <ListItemText primary={item.label} primaryTypographyProps={{ fontWeight: 600 }} /> : null}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
@@ -213,10 +233,10 @@ export default function AppShell({
|
||||
|
||||
const drawerContent = (
|
||||
<Box sx={{ "--app-focus-color": "var(--palette-focus-inverse)", height: "100%", display: "flex", flexDirection: "column", backgroundColor: SIDEBAR_BG }}>
|
||||
<Box sx={{ px: desktopNavCollapsed ? 1.5 : 2.25, py: desktopNavCollapsed ? 2 : 2.5, display: "flex", justifyContent: desktopNavCollapsed ? "center" : "flex-start" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, justifyContent: desktopNavCollapsed ? "center" : "flex-start" }}>
|
||||
<Box sx={{ px: navCollapsed ? 1.5 : 2.25, py: navCollapsed ? 2 : 2.5, display: "flex", justifyContent: navCollapsed ? "center" : "flex-start" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, justifyContent: navCollapsed ? "center" : "flex-start" }}>
|
||||
<JobbjaktMark style={{ width: 30, height: 30, flexShrink: 0 }} />
|
||||
{!desktopNavCollapsed ? (
|
||||
{!navCollapsed ? (
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: SIDEBAR_SELECTED_TEXT }}>
|
||||
Jobbjakt
|
||||
@@ -270,6 +290,7 @@ export default function AppShell({
|
||||
return (
|
||||
<Box sx={{ display: "flex", minHeight: "100vh" }}>
|
||||
<AppBar
|
||||
ref={setHeader}
|
||||
position="fixed"
|
||||
color="inherit"
|
||||
elevation={0}
|
||||
@@ -277,7 +298,7 @@ export default function AppShell({
|
||||
borderBottom: `1px solid ${muiTheme.vars.palette.grey[300]}`,
|
||||
backgroundColor: muiTheme.vars.palette.background.default,
|
||||
backgroundImage: "none",
|
||||
zIndex: muiTheme.zIndex.drawer + 1,
|
||||
zIndex: isMobile ? muiTheme.zIndex.appBar : muiTheme.zIndex.drawer + 1,
|
||||
})}
|
||||
>
|
||||
<Toolbar
|
||||
@@ -299,6 +320,8 @@ export default function AppShell({
|
||||
size="small"
|
||||
color="secondary"
|
||||
aria-label={t("openNavigation")}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={drawerOpen}
|
||||
onClick={() => onToggleDrawer(true)}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }}
|
||||
>
|
||||
@@ -311,7 +334,6 @@ export default function AppShell({
|
||||
Jobbjakt
|
||||
</Typography>
|
||||
</Box>
|
||||
{buildBadge}
|
||||
</Box>
|
||||
|
||||
<StackLanguageToggle language={language} setLanguage={setLanguage} />
|
||||
@@ -452,6 +474,12 @@ export default function AppShell({
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
||||
transformOrigin={{ vertical: "top", horizontal: "right" }}
|
||||
>
|
||||
{isMobile && buildVersion ? (
|
||||
<ListSubheader disableSticky role="presentation" sx={{ maxWidth: 280, py: 1, lineHeight: 1.5, overflowWrap: "anywhere" }}>
|
||||
<Typography variant="body2">{t("applicationVersion", { version: buildVersion })}</Typography>
|
||||
{buildCommit ? <Typography variant="caption">commit {buildCommit}</Typography> : null}
|
||||
</ListSubheader>
|
||||
) : null}
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setUserMenuAnchor(null);
|
||||
@@ -484,7 +512,7 @@ export default function AppShell({
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
display: { xs: "none", md: "block" },
|
||||
display: isMobile ? "none" : "block",
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
[`& .MuiDrawer-paper`]: {
|
||||
@@ -498,17 +526,23 @@ export default function AppShell({
|
||||
}}
|
||||
open
|
||||
>
|
||||
<Toolbar sx={{ minHeight: { xs: 68, md: 76 } }} />
|
||||
<Box aria-hidden sx={{ height: headerHeight ?? { xs: 68, md: 76 }, flexShrink: 0 }} />
|
||||
{drawerContent}
|
||||
</Drawer>
|
||||
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={drawerOpen}
|
||||
open={isMobile && drawerOpen}
|
||||
onClose={() => onToggleDrawer(false)}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
slotProps={{
|
||||
paper: { "aria-label": t("navigation") },
|
||||
transition: { onEntered: (node: HTMLElement) => {
|
||||
if (node === document.activeElement) node.querySelector<HTMLElement>('[role="button"]')?.focus();
|
||||
} },
|
||||
}}
|
||||
sx={{
|
||||
display: { xs: "block", md: "none" },
|
||||
display: isMobile ? "block" : "none",
|
||||
[`& .MuiDrawer-paper`]: {
|
||||
width: drawerWidth,
|
||||
borderRight: `1px solid ${SIDEBAR_BORDER}`,
|
||||
@@ -525,13 +559,15 @@ export default function AppShell({
|
||||
sx={{
|
||||
flex: 1,
|
||||
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))",
|
||||
bgcolor: "background.default",
|
||||
minHeight: "100vh",
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ mx: "auto", maxWidth: pageLayout ? `var(--app-page-width-${pageLayout})` : 1320, width: "100%", minWidth: 0 }}>
|
||||
<Toolbar sx={{ minHeight: { xs: isMobile ? 124 : 68, md: 76 } }} />
|
||||
<Box aria-hidden sx={{ height: headerHeight ?? { xs: isMobile ? 124 : 68, md: 76 } }} />
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75, mb: "var(--app-section-gap)", minWidth: 0 }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Breadcrumbs sx={{ color: "text.secondary", mb: 0.5, '& .MuiBreadcrumbs-ol': { flexWrap: 'wrap' } }}>
|
||||
|
||||
@@ -45,3 +45,15 @@ test("[PRES-E07 PRES-J04] the capture command opens review without creating a jo
|
||||
await waitFor(() => expect(screen.getByRole("textbox")).toHaveValue(""));
|
||||
expect(screen.queryByText("Nordlys AS - Platform engineer")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("[P5-00 PRES-E07] command and empty results use valid list semantics", async () => {
|
||||
render(<I18nProvider><QuickCommandDialog open onClose={jest.fn()} onNavigate={jest.fn()} onOpenAddJob={jest.fn()} /></I18nProvider>);
|
||||
const list = screen.getByRole("list");
|
||||
expect(screen.getAllByRole("listitem")).toHaveLength(6);
|
||||
expect(Array.from(list.children).every(item => item.tagName === "LI")).toBe(true);
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "no-such-result" } });
|
||||
mockedApi.get.mockResolvedValue({ data: [] } as never);
|
||||
await screen.findByText("No matching commands or records.");
|
||||
expect(Array.from(list.children).every(item => item.tagName === "LI")).toBe(true);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, { useState } from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { CssVarsProvider } from "@mui/material/styles";
|
||||
import AppShell from "./layout/AppShell";
|
||||
import { getTheme } from "./theme";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import { translations } from "./i18n/translations";
|
||||
import { ToastProvider, useToast } from "./toast";
|
||||
|
||||
jest.mock("@mui/material/useMediaQuery", () => ({ __esModule: true, default: () => true }));
|
||||
|
||||
for (const language of ["en", "nb"] as const) {
|
||||
test(`[P5-00 PRES-E07 E08 A03] ${language} mobile names/current route ignore desktop collapse and retain metadata`, () => {
|
||||
localStorage.setItem("uiLanguage", language);
|
||||
localStorage.setItem("appShellDesktopSidebarCollapsed", "1");
|
||||
const t = translations[language];
|
||||
function Shell() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return <AppShell pageTitle={t.settings} breadcrumbs={[]} pathname="/settings"
|
||||
nav={[{ to: "/dashboard", label: t.dashboard, icon: <span /> }, { to: "/settings", label: t.settings, icon: <span /> }, { to: "/admin/users", label: "Hidden admin", icon: <span />, hidden: true }]}
|
||||
navBottom={[]} drawerOpen={open} onToggleDrawer={setOpen} onNavigate={jest.fn()}
|
||||
user={{ userName: "Åse" }} buildMetadata={{ version: "long-version-æøå", commitSha: "abc1234" }}>
|
||||
<div>Existing page</div>
|
||||
</AppShell>;
|
||||
}
|
||||
render(<CssVarsProvider theme={getTheme("light") as any}><I18nProvider><Shell /></I18nProvider></CssVarsProvider>);
|
||||
expect(screen.queryByTestId("admin-version-badge")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: t.user }));
|
||||
expect(screen.getByRole("menu")).toHaveTextContent("long-version-æøå");
|
||||
expect(screen.getByRole("menu")).toHaveTextContent("abc1234");
|
||||
fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" });
|
||||
fireEvent.click(screen.getByRole("button", { name: t.openNavigation }));
|
||||
const drawer = screen.getByRole("dialog", { name: t.navigation });
|
||||
expect(within(drawer).getByRole("button", { name: t.dashboard })).toHaveTextContent(t.dashboard);
|
||||
expect(within(drawer).getByRole("button", { name: t.settings })).toHaveAttribute("aria-current", "page");
|
||||
expect(within(drawer).queryByText("Hidden admin")).not.toBeInTheDocument();
|
||||
expect(localStorage.getItem("appShellDesktopSidebarCollapsed")).toBe("1");
|
||||
});
|
||||
}
|
||||
|
||||
function ToastTrigger({ action }: { action?: () => void }) {
|
||||
const { toast } = useToast();
|
||||
return <button onClick={() => toast("Downloaded æøå", "success", action ? { label: "Undo", onClick: action } : undefined)}>Notify</button>;
|
||||
}
|
||||
|
||||
test("[P5-00 PRES-E08 S05] toast retains its full existing display duration and dismiss action", () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
render(<ToastProvider><ToastTrigger /></ToastProvider>);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Notify" }));
|
||||
act(() => jest.advanceTimersByTime(3499));
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Downloaded æøå");
|
||||
act(() => jest.advanceTimersByTime(1));
|
||||
// Flush the existing exit transition after React observes the timeout.
|
||||
act(() => jest.advanceTimersByTime(300));
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Notify" }));
|
||||
fireEvent.click(within(screen.getByRole("alert")).getByRole("button"));
|
||||
act(() => jest.advanceTimersByTime(300));
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
} finally { jest.useRealTimers(); }
|
||||
});
|
||||
|
||||
test("[P5-00 PRES-E08] toast action is explicit and executes once", () => {
|
||||
const action = jest.fn();
|
||||
render(<ToastProvider><ToastTrigger action={action} /></ToastProvider>);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Notify" }));
|
||||
expect(action).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Undo" }));
|
||||
expect(action).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -518,11 +518,14 @@ export const getTheme = (_mode: "light" | "dark") => {
|
||||
},
|
||||
MuiTooltip: {
|
||||
styleOverrides: {
|
||||
tooltip: {
|
||||
tooltip: ({ theme }: any) => ({
|
||||
backgroundColor: theme.vars.palette.text.primary,
|
||||
color: theme.vars.palette.background.paper,
|
||||
borderRadius: 8,
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
},
|
||||
}),
|
||||
arrow: ({ theme }: any) => ({ color: theme.vars.palette.text.primary }),
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo, useState } from "react";
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Alert, Button, Snackbar } from "@mui/material";
|
||||
import { Alert, Button, GlobalStyles, Snackbar } from "@mui/material";
|
||||
|
||||
type Severity = "success" | "info" | "warning" | "error";
|
||||
|
||||
@@ -24,6 +24,19 @@ const ToastContext = createContext<ToastApi | null>(null);
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [t, setT] = useState<Toast>({ open: false, message: "", severity: "info" });
|
||||
const [element, setElement] = useState<HTMLDivElement | null>(null);
|
||||
const [clearance, setClearance] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!element) { setClearance(0); return; }
|
||||
// Reserve the actual wrapped message height, bottom offset and a 24px gap.
|
||||
const measure = () => setClearance(Math.ceil(element.getBoundingClientRect().height) + 48);
|
||||
measure();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [element]);
|
||||
|
||||
const toast = useCallback(
|
||||
(message: string, severity: Severity = "info", action?: { label: string; onClick: () => void }) => {
|
||||
@@ -42,12 +55,19 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
<GlobalStyles styles={{
|
||||
":root": { "--app-toast-clearance": `${clearance}px` },
|
||||
// Native focus/scrollIntoView must also leave the feedback lane clear.
|
||||
html: { scrollPaddingBottom: "calc(max(96px, var(--app-toast-clearance, 0px)) + env(safe-area-inset-bottom, 0px))" },
|
||||
}} />
|
||||
{children}
|
||||
<Snackbar
|
||||
ref={setElement}
|
||||
open={t.open}
|
||||
autoHideDuration={3500}
|
||||
onClose={() => setT((p) => ({ ...p, open: false }))}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
||||
sx={{ bottom: { xs: "calc(8px + env(safe-area-inset-bottom, 0px))", sm: "calc(24px + env(safe-area-inset-bottom, 0px))" } }}
|
||||
>
|
||||
<Alert
|
||||
onClose={() => setT((p) => ({ ...p, open: false }))}
|
||||
|
||||
Reference in New Issue
Block a user