Files
familysync/apps/pwa/src/components/AppNav.tsx
T
Lucas Berger 7808426a2f feat(10-04): AdminPage + /admin route + conditional nav entries + e2e spec
- AdminPage: Admin Settings heading, MEMBERS section (avatar+status+action), SHARED CALENDAR radio group + two-tap Save + empty state
- App.tsx: /admin route gated by meQuery.data.user.isAdmin (loading gate prevents flash), SetupBanner mounted above content, BottomTabBar + AppNav receive isAdmin
- AppNav.tsx: ShieldCheck Admin nav entry rendered only when isAdmin=true (D-03 UX gating)
- BottomTabBar.tsx: ShieldCheck Admin tab rendered only when isAdmin=true (D-03 UX gating)
- e2e/admin.spec.ts: 5 assertions across 3 profiles (15 total tests) — admin sees nav+page+members, non-admin: no nav entry + /admin redirects to /calendar
- All 15 e2e tests pass (iphone/pixel/desktop); production build clean
2026-06-13 15:16:11 -04:00

277 lines
7.8 KiB
TypeScript

/**
* AppNav — top navigation bar (phone) / left sidebar (tablet/desktop).
*
* UI-SPEC §AppNav:
* - Phone: 48px top bar, app name "FamilySync" left, user color swatch right
* - Tablet/Desktop: 240px left sidebar, app name + ColorLegend
* - Accent colors NOT on chrome (60/30/10 split)
*
* Reviewer note (UI-SPEC §Post-Verification):
* - Phone-nav avatar/color swatch needs aria-label + title (icon-only affordance)
* - Grid is primary focal point; AppNav is secondary chrome
*/
import { NavLink } from 'react-router';
import { CalendarDays, List, ShieldCheck } from 'lucide-react';
import { ColorLegend, type LegendMember } from './ColorLegend.js';
interface AppNavProps {
members?: LegendMember[];
currentUserColor?: string;
currentUserName?: string;
/** Called when the user avatar is tapped — opens the Settings sheet. */
onOpenSettings?: () => void;
/** When true, renders the Admin nav entry (ShieldCheck). UX gating only (D-03). */
isAdmin?: boolean;
}
export function AppNav({
members = [],
currentUserColor,
currentUserName,
onOpenSettings,
isAdmin = false,
}: AppNavProps) {
const isMobile = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
if (isMobile) {
return (
<PhoneNav
currentUserColor={currentUserColor}
currentUserName={currentUserName}
onOpenSettings={onOpenSettings}
/>
);
}
return (
<DesktopNav
members={members}
currentUserColor={currentUserColor}
currentUserName={currentUserName}
onOpenSettings={onOpenSettings}
isAdmin={isAdmin}
/>
);
}
/** Phone: 48px top bar — app name left, user avatar right */
function PhoneNav({
currentUserColor,
currentUserName,
onOpenSettings,
}: {
currentUserColor?: string;
currentUserName?: string;
onOpenSettings?: () => void;
}) {
const displayName = currentUserName ?? 'User';
const color = currentUserColor ?? 'var(--color-member-0)';
return (
<header
style={{
height: '48px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 var(--space-4)',
background: 'var(--color-surface)',
borderBottom: '1px solid var(--color-border)',
flexShrink: 0,
fontFamily: 'var(--font-family-base)',
}}
>
{/* App name */}
<span
style={{
fontSize: 'var(--text-display-size)',
fontWeight: 'var(--text-display-weight)',
lineHeight: 'var(--text-display-line-height)',
color: 'var(--color-text-primary)',
letterSpacing: '-0.01em',
}}
>
FamilySync
</span>
{/* User avatar — promoted to button to open Settings sheet (D-09) */}
<button
onClick={onOpenSettings}
aria-label={`${displayName} — open settings`}
style={{
background: 'none',
border: 'none',
cursor: onOpenSettings ? 'pointer' : 'default',
minWidth: '44px',
minHeight: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 0,
borderRadius: 'var(--space-1, 4px)',
flexShrink: 0,
}}
>
<div
style={{
width: '32px',
height: '32px',
borderRadius: '50%',
background: color,
}}
aria-hidden="true"
/>
</button>
</header>
);
}
/** Tablet/Desktop: 240px left sidebar — app name + nav links + color legend + avatar */
function DesktopNav({
members,
currentUserColor,
currentUserName,
onOpenSettings,
isAdmin = false,
}: {
members: LegendMember[];
currentUserColor?: string;
currentUserName?: string;
onOpenSettings?: () => void;
isAdmin?: boolean;
}) {
const navLinkStyle = ({ isActive }: { isActive: boolean }): React.CSSProperties => ({
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: 'var(--space-2) var(--space-3, 12px)',
borderRadius: 'var(--space-1, 4px)',
textDecoration: 'none',
fontSize: 'var(--text-body-size, 15px)',
fontWeight: isActive ? 600 : 400,
color: isActive ? 'var(--color-member-0)' : 'var(--color-text-primary)',
background: isActive
? 'color-mix(in srgb, var(--color-member-0) 10%, transparent)'
: 'transparent',
minHeight: '44px',
transition: 'color 0.1s ease, background 0.1s ease',
});
return (
<nav
style={{
width: '240px',
flexShrink: 0,
background: 'var(--color-surface)',
borderRight: '1px solid var(--color-border)',
display: 'flex',
flexDirection: 'column',
padding: 'var(--space-6) var(--space-4)',
fontFamily: 'var(--font-family-base)',
overflowY: 'auto',
}}
aria-label="Main navigation"
>
{/* App name */}
<div
style={{
fontSize: 'var(--text-display-size)',
fontWeight: 'var(--text-display-weight)',
lineHeight: 'var(--text-display-line-height)',
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-6)',
letterSpacing: '-0.01em',
}}
>
FamilySync
</div>
{/* Section nav links */}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 'var(--space-1, 4px)',
marginBottom: 'var(--space-6)',
}}
>
<NavLink to="/calendar" style={navLinkStyle} aria-label="Calendar">
<CalendarDays size={18} aria-hidden="true" />
Calendar
</NavLink>
<NavLink to="/lists" style={navLinkStyle} aria-label="Lists">
<List size={18} aria-hidden="true" />
Lists
</NavLink>
{/* Admin entry — only when isAdmin=true (UX gating, D-03) */}
{isAdmin && (
<NavLink to="/admin" style={navLinkStyle} aria-label="Admin settings">
<ShieldCheck size={18} aria-hidden="true" />
Admin
</NavLink>
)}
</div>
{/* Color legend */}
<div
style={{
fontSize: 'var(--text-label-size)',
fontWeight: 600,
color: 'var(--color-text-muted)',
marginBottom: 'var(--space-2)',
textTransform: 'uppercase',
letterSpacing: '0.06em',
}}
>
Calendars
</div>
<ColorLegend members={members} />
{/* User avatar — at bottom of sidebar, opens Settings sheet (D-09) */}
<div style={{ marginTop: 'auto', paddingTop: 'var(--space-6)' }}>
<button
onClick={onOpenSettings}
aria-label={`${currentUserName ?? 'User'} — open settings`}
style={{
background: 'none',
border: 'none',
cursor: onOpenSettings ? 'pointer' : 'default',
minWidth: '44px',
minHeight: '44px',
display: 'flex',
alignItems: 'center',
gap: 'var(--space-3, 12px)',
padding: 'var(--space-2) var(--space-3, 12px)',
borderRadius: 'var(--space-1, 4px)',
width: '100%',
textAlign: 'left',
}}
>
<div
style={{
width: '32px',
height: '32px',
borderRadius: '50%',
background: currentUserColor ?? 'var(--color-member-0)',
flexShrink: 0,
}}
aria-hidden="true"
/>
<span
style={{
fontSize: 'var(--text-body-size, 15px)',
color: 'var(--color-text-primary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{currentUserName ?? 'User'}
</span>
</button>
</div>
</nav>
);
}