mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
UI fixes
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { AppShell, Drawer } from '@mantine/core';
|
import { AppShell, Box, Drawer, Group, Text } from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -26,6 +26,14 @@ const BADGE_POLL_MS = 60_000;
|
|||||||
|
|
||||||
const HEADER_HEIGHT = 116;
|
const HEADER_HEIGHT = 116;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Horizontal inset of the header chrome. `AppHeader` adds its own `px="lg"`
|
||||||
|
* inside this, so the nav strip below needs the sum to line up with the
|
||||||
|
* controls above it — it used to start 20px to their left.
|
||||||
|
*/
|
||||||
|
const CHROME_PAD_X = 32;
|
||||||
|
const NAV_STRIP_PAD_X = CHROME_PAD_X + 20;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A desk left unlocked with a license-review or medical-record screen open is
|
* A desk left unlocked with a license-review or medical-record screen open is
|
||||||
* the actual threat model here, not a slow token. 15 minutes of no mouse,
|
* the actual threat model here, not a slow token. 15 minutes of no mouse,
|
||||||
@@ -144,7 +152,9 @@ export function BackofficeLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
header={{ height: isSidebar ? 74 : HEADER_HEIGHT }}
|
// The top layout drops its nav strip on small screens — the drawer is
|
||||||
|
// the nav there — so the header shrinks back to a single row with it.
|
||||||
|
header={{ height: isSidebar ? 74 : { base: 74, sm: HEADER_HEIGHT } }}
|
||||||
navbar={
|
navbar={
|
||||||
isSidebar
|
isSidebar
|
||||||
? {
|
? {
|
||||||
@@ -162,13 +172,28 @@ export function BackofficeLayout() {
|
|||||||
<AppShell.Header
|
<AppShell.Header
|
||||||
style={{
|
style={{
|
||||||
background: "var(--mantine-color-body)",
|
background: "var(--mantine-color-body)",
|
||||||
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
borderBottom: "1px solid var(--mantine-color-default-border)",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ height: 74, flexShrink: 0, padding: "0 32px" }}>
|
<div
|
||||||
|
style={{ height: 74, flexShrink: 0, padding: `0 ${CHROME_PAD_X}px` }}
|
||||||
|
>
|
||||||
<AppHeader
|
<AppHeader
|
||||||
|
brand={
|
||||||
|
isSidebar ? undefined : (
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<BrandMark size={28} />
|
||||||
|
<Text fw={700} size="sm" lh={1.1} visibleFrom="xs">
|
||||||
|
{t('app.name')}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Nothing to toggle on a desktop top bar; on mobile it opens the
|
||||||
|
// drawer below.
|
||||||
|
burgerHiddenFrom={isSidebar ? undefined : 'sm'}
|
||||||
onToggleNav={toggleNav}
|
onToggleNav={toggleNav}
|
||||||
onToggleSidebar={isSidebar ? handleToggleCollapse : toggleNav}
|
onToggleSidebar={isSidebar ? handleToggleCollapse : toggleNav}
|
||||||
navOpened={opened}
|
navOpened={opened}
|
||||||
@@ -182,13 +207,14 @@ export function BackofficeLayout() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isSidebar && (
|
{!isSidebar && (
|
||||||
<div
|
<Box
|
||||||
|
visibleFrom="sm"
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
padding: '0 32px',
|
padding: `0 ${NAV_STRIP_PAD_X}px`,
|
||||||
height: 42,
|
height: 42,
|
||||||
borderTop: '1px solid var(--mantine-color-gray-1)',
|
borderTop: '1px solid var(--mantine-color-default-border)',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -199,7 +225,7 @@ export function BackofficeLayout() {
|
|||||||
activePath={location.pathname}
|
activePath={location.pathname}
|
||||||
onNavigate={go}
|
onNavigate={go}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</AppShell.Header>
|
</AppShell.Header>
|
||||||
|
|
||||||
@@ -210,7 +236,7 @@ export function BackofficeLayout() {
|
|||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
transition: "width 200ms ease",
|
transition: "width 200ms ease",
|
||||||
background: "var(--mantine-color-body)",
|
background: "var(--mantine-color-body)",
|
||||||
borderRight: "1px solid var(--mantine-color-gray-2)",
|
borderRight: "1px solid var(--mantine-color-default-border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AppSidebar
|
<AppSidebar
|
||||||
@@ -238,30 +264,28 @@ export function BackofficeLayout() {
|
|||||||
{/* Mobile nav: a proper Drawer (sized, backdrop, closes on outside
|
{/* Mobile nav: a proper Drawer (sized, backdrop, closes on outside
|
||||||
click) instead of AppShell's full-width mobile navbar. Mirrors the
|
click) instead of AppShell's full-width mobile navbar. Mirrors the
|
||||||
landing page's mobile menu. */}
|
landing page's mobile menu. */}
|
||||||
{isSidebar && (
|
<Drawer
|
||||||
<Drawer
|
opened={opened}
|
||||||
opened={opened}
|
onClose={closeNav}
|
||||||
onClose={closeNav}
|
hiddenFrom="sm"
|
||||||
hiddenFrom="sm"
|
size="75%"
|
||||||
size="75%"
|
padding={0}
|
||||||
padding={0}
|
withCloseButton={false}
|
||||||
withCloseButton={false}
|
>
|
||||||
>
|
<AppSidebar
|
||||||
<AppSidebar
|
navItems={sections}
|
||||||
navItems={sections}
|
collapsed={false}
|
||||||
collapsed={false}
|
activePath={location.pathname}
|
||||||
activePath={location.pathname}
|
onToggleCollapse={handleToggleCollapse}
|
||||||
onToggleCollapse={handleToggleCollapse}
|
onNavigate={(item) => {
|
||||||
onNavigate={(item) => {
|
go(item);
|
||||||
go(item);
|
closeNav();
|
||||||
closeNav();
|
}}
|
||||||
}}
|
brandName={t('app.name')}
|
||||||
brandName={t('app.name')}
|
brandSubtitle={t('app.authority')}
|
||||||
brandSubtitle={t('app.authority')}
|
brandLogo={<BrandMark size={32} />}
|
||||||
brandLogo={<BrandMark size={32} />}
|
/>
|
||||||
/>
|
</Drawer>
|
||||||
</Drawer>
|
|
||||||
)}
|
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
IconLogout,
|
IconLogout,
|
||||||
IconUserCircle,
|
IconUserCircle,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||||
import { ColorSchemeToggle } from './ColorSchemeToggle';
|
import { ColorSchemeToggle } from './ColorSchemeToggle';
|
||||||
@@ -36,6 +37,17 @@ interface AppHeaderProps {
|
|||||||
supportedLanguages: readonly string[];
|
supportedLanguages: readonly string[];
|
||||||
onNotificationsClick?: () => void;
|
onNotificationsClick?: () => void;
|
||||||
notificationCount?: number;
|
notificationCount?: number;
|
||||||
|
/**
|
||||||
|
* Rendered at the far left. The sidebar layout carries the brand in the
|
||||||
|
* sidebar itself; the top-bar layout has no sidebar, so it passes the brand
|
||||||
|
* here rather than leaving the chrome unbranded.
|
||||||
|
*/
|
||||||
|
brand?: ReactNode;
|
||||||
|
/**
|
||||||
|
* Breakpoint from which the burger is hidden. The top-bar layout only needs
|
||||||
|
* it on small screens, where the drawer replaces the nav strip.
|
||||||
|
*/
|
||||||
|
burgerHiddenFrom?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppHeader({
|
export function AppHeader({
|
||||||
@@ -50,17 +62,21 @@ export function AppHeader({
|
|||||||
supportedLanguages,
|
supportedLanguages,
|
||||||
onNotificationsClick,
|
onNotificationsClick,
|
||||||
notificationCount,
|
notificationCount,
|
||||||
|
brand,
|
||||||
|
burgerHiddenFrom,
|
||||||
}: AppHeaderProps) {
|
}: AppHeaderProps) {
|
||||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group h="100%" px="lg" justify="space-between" wrap="nowrap">
|
<Group h="100%" px="lg" justify="space-between" wrap="nowrap">
|
||||||
<Group gap="md" wrap="nowrap">
|
<Group gap="md" wrap="nowrap">
|
||||||
|
{brand}
|
||||||
{/* Hamburger — styled like user-management Top.tsx */}
|
{/* Hamburger — styled like user-management Top.tsx */}
|
||||||
{/* The Burger itself owns the click so the control is a real, keyboard
|
{/* The Burger itself owns the click so the control is a real, keyboard
|
||||||
reachable <button>; the Box is chrome only. It previously wrapped a
|
reachable <button>; the Box is chrome only. It previously wrapped a
|
||||||
no-op button, which no keyboard user could operate. */}
|
no-op button, which no keyboard user could operate. */}
|
||||||
<Box
|
<Box
|
||||||
|
hiddenFrom={burgerHiddenFrom}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Badge, Group, Menu, UnstyledButton, rem } from '@mantine/core';
|
import { Badge, Group, Menu, UnstyledButton, rem } from '@mantine/core';
|
||||||
|
import { forwardRef } from 'react';
|
||||||
import { IconChevronDown } from '@tabler/icons-react';
|
import { IconChevronDown } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { NavItem } from './AppSidebar';
|
import type { NavItem } from './AppSidebar';
|
||||||
@@ -23,7 +24,9 @@ interface AppTopNavProps {
|
|||||||
* scrolling strip, so with twenty-odd of them most were off-screen and the
|
* scrolling strip, so with twenty-odd of them most were off-screen and the
|
||||||
* grouping that the sidebar already had was thrown away. Here each section
|
* grouping that the sidebar already had was thrown away. Here each section
|
||||||
* collapses to a single labelled dropdown, which fits and keeps the same
|
* collapses to a single labelled dropdown, which fits and keeps the same
|
||||||
* information architecture as the sidebar.
|
* information architecture as the sidebar. Sections that still do not fit
|
||||||
|
* scroll horizontally rather than dropping off the edge — under ~1100px the
|
||||||
|
* last one or two were simply unreachable.
|
||||||
*/
|
*/
|
||||||
export function AppTopNav({ navItems, activePath, onNavigate }: AppTopNavProps) {
|
export function AppTopNav({ navItems, activePath, onNavigate }: AppTopNavProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -36,6 +39,7 @@ export function AppTopNav({ navItems, activePath, onNavigate }: AppTopNavProps)
|
|||||||
wrap="nowrap"
|
wrap="nowrap"
|
||||||
role="navigation"
|
role="navigation"
|
||||||
aria-label={t('nav.primary', 'Primary')}
|
aria-label={t('nav.primary', 'Primary')}
|
||||||
|
style={{ flex: 1, minWidth: 0, overflowX: 'auto', scrollbarWidth: 'none' }}
|
||||||
>
|
>
|
||||||
{sections.map((section, index) => {
|
{sections.map((section, index) => {
|
||||||
// An unlabelled leading block (Dashboard) is a plain link, not a menu.
|
// An unlabelled leading block (Dashboard) is a plain link, not a menu.
|
||||||
@@ -148,40 +152,52 @@ interface TopNavButtonProps {
|
|||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TopNavButton({
|
/**
|
||||||
label,
|
* `Menu.Target` positions its dropdown against the ref it passes to its child,
|
||||||
active,
|
* so a plain function component here left every section menu anchored at the
|
||||||
badge,
|
* top-left of the viewport, covering the header instead of opening under the
|
||||||
soon,
|
* button that was clicked. The rest props carry Menu's own click and aria
|
||||||
withChevron,
|
* handling onto the real button.
|
||||||
onClick,
|
*/
|
||||||
}: TopNavButtonProps) {
|
const TopNavButton = forwardRef<HTMLButtonElement, TopNavButtonProps>(
|
||||||
return (
|
function TopNavButton(
|
||||||
<UnstyledButton
|
{ label, active, badge, soon, withChevron, onClick, ...others },
|
||||||
onClick={onClick}
|
ref,
|
||||||
style={{
|
) {
|
||||||
display: 'flex',
|
return (
|
||||||
alignItems: 'center',
|
<UnstyledButton
|
||||||
gap: rem(6),
|
ref={ref}
|
||||||
padding: `0 ${rem(14)}`,
|
onClick={onClick}
|
||||||
height: '100%',
|
{...others}
|
||||||
borderBottom: '2px solid',
|
style={{
|
||||||
borderBottomColor: active ? 'var(--mantine-color-blue-6)' : 'transparent',
|
display: 'flex',
|
||||||
color: active ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-gray-6)',
|
alignItems: 'center',
|
||||||
fontWeight: active ? 600 : 500,
|
gap: rem(6),
|
||||||
fontSize: rem(14),
|
padding: `0 ${rem(14)}`,
|
||||||
whiteSpace: 'nowrap',
|
height: '100%',
|
||||||
opacity: soon ? 0.55 : 1,
|
flexShrink: 0,
|
||||||
marginBottom: -1,
|
borderBottom: '2px solid',
|
||||||
}}
|
borderBottomColor: active
|
||||||
>
|
? 'var(--mantine-color-blue-6)'
|
||||||
<span>{label}</span>
|
: 'transparent',
|
||||||
{badge !== null && badge !== undefined && (
|
color: active
|
||||||
<Badge size="xs" variant="filled" color="red" radius="sm">
|
? 'var(--mantine-color-blue-6)'
|
||||||
{badge}
|
: 'var(--mantine-color-gray-6)',
|
||||||
</Badge>
|
fontWeight: active ? 600 : 500,
|
||||||
)}
|
fontSize: rem(14),
|
||||||
{withChevron && <IconChevronDown size={14} stroke={2} />}
|
whiteSpace: 'nowrap',
|
||||||
</UnstyledButton>
|
opacity: soon ? 0.55 : 1,
|
||||||
);
|
marginBottom: -1,
|
||||||
}
|
}}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
{badge !== null && badge !== undefined && (
|
||||||
|
<Badge size="xs" variant="filled" color="red" radius="sm">
|
||||||
|
{badge}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{withChevron && <IconChevronDown size={14} stroke={2} />}
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user