Files
familysync/apps/pwa/src/components/AppNav.tsx
T
Lucas Berger 1de4aa5a3e feat(05-08): SettingsSheet (master toggle D-09) + AppNav avatar promoted to button
- Create SettingsSheet.tsx: bottom sheet (role=dialog, z:301, Escape+backdrop-close)
  with FamilySync Notifications toggle (role=switch, aria-checked, 44px target)
  wired to usePushSubscription setEnabled + permission state
  and inline permission-denied hint (AlertCircle + 'How to enable') when denied
- Promote PhoneNav avatar div to button with onOpenSettings onClick + aria-label
- Add onOpenSettings prop to DesktopNav; add avatar button at sidebar bottom
- Thread onOpenSettings through AppNavProps
- Add @keyframes spin to tokens.css (fixes missing keyframe used by SettingsSheet + SyncStateToast)
2026-06-09 21:45:59 -04:00

257 lines
7.3 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 } 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
}
export function AppNav({ members = [], currentUserColor, currentUserName, onOpenSettings }: 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}
/>
)
}
/** 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,
}: {
members: LegendMember[]
currentUserColor?: string
currentUserName?: string
onOpenSettings?: () => void
}) {
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>
</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>
)
}