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)
This commit is contained in:
Lucas Berger
2026-06-09 21:45:59 -04:00
parent 458d6e4fef
commit 1de4aa5a3e
3 changed files with 444 additions and 23 deletions
+96 -28
View File
@@ -19,25 +19,42 @@ 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 }: AppNavProps) {
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} />
return (
<PhoneNav
currentUserColor={currentUserColor}
currentUserName={currentUserName}
onOpenSettings={onOpenSettings}
/>
)
}
return <DesktopNav members={members} />
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)'
@@ -69,43 +86,50 @@ function PhoneNav({
FamilySync
</span>
{/* User color avatar — aria-label + title per reviewer note */}
{/* 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,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
cursor: 'default',
// Ensure 44px tap area with padding
minWidth: '44px',
minHeight: '44px',
padding: '6px',
boxSizing: 'border-box',
}}
aria-label={displayName}
title={displayName}
role="img"
>
<div
style={{
width: '100%',
height: '100%',
borderRadius: '50%',
background: color,
}}
aria-hidden="true"
/>
</div>
</button>
</header>
)
}
/** Tablet/Desktop: 240px left sidebar — app name + nav links + color legend */
function DesktopNav({ members }: { members: LegendMember[] }) {
/** 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',
@@ -183,6 +207,50 @@ function DesktopNav({ members }: { members: LegendMember[] }) {
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>
)
}
+344
View File
@@ -0,0 +1,344 @@
/**
* SettingsSheet — master notifications toggle (D-09).
*
* A bottom sheet opened by tapping the user avatar in AppNav.
* Contains a single master on/off toggle for all FamilySync push notifications.
*
* UI-SPEC §Surface 2:
* - Bottom sheet, role="dialog", aria-modal, zIndex 301 (backdrop 300)
* - Heading "Settings" + X close button
* - Section label "Notifications" (uppercase, muted)
* - Bell icon + toggle row
* - Permission-denied hint when Notification.permission === 'denied'
*
* Toggle behavior:
* on → off: DELETE subscription + localStorage.notificationsEnabled='0'
* off → on (permission granted): silently subscribe (no OS dialog)
* off → on (permission default): triggers tap-gated subscribe() (OS dialog)
* off → on (permission denied): no-op, shows permission-denied hint inline
*
* Accessibility: role="switch", aria-checked, 44px touch targets, Escape closes.
* Security: T-05-24 — all copy is plain-text JSX children, no dangerouslySetInnerHTML.
*/
import { useEffect, useRef, useState } from 'react'
import { X, Bell, AlertCircle, Loader2 } from 'lucide-react'
import { usePushSubscription } from '../hooks/usePushSubscription.js'
interface SettingsSheetProps {
isOpen: boolean
onClose: () => void
}
export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
const { subscribe, permission, isSubscribed, setEnabled } = usePushSubscription()
const [isTogglingOn, setIsTogglingOn] = useState(false)
const closeButtonRef = useRef<HTMLButtonElement>(null)
// Compute initial toggle on/off state per UI-SPEC toggle initial state rule:
// on when notificationsEnabled !== '0' AND permission === 'granted' AND isSubscribed
const isOn = permission === 'granted' && isSubscribed
// Escape key listener (CreateListSheet pattern)
useEffect(() => {
if (!isOpen) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [isOpen, onClose])
// Focus close button on open (a11y)
useEffect(() => {
if (isOpen && closeButtonRef.current) {
closeButtonRef.current.focus()
}
}, [isOpen])
if (!isOpen) return null
const handleToggle = async () => {
if (permission === 'denied') return // no-op — show hint below
if (isOn) {
// on → off: unsubscribe
await setEnabled(false)
} else if (permission === 'granted') {
// off → on, permission already granted: silent subscribe
setIsTogglingOn(true)
try {
await setEnabled(true)
} finally {
setIsTogglingOn(false)
}
} else {
// off → on, permission 'default': needs tap-gated subscribe with OS dialog
// The toggle click IS the tap gesture — call subscribe() directly here.
setIsTogglingOn(true)
try {
const registration = await navigator.serviceWorker?.ready
if (registration) {
await subscribe(registration)
}
} catch {
// Permission denied by OS or error — permission state will update reactively
} finally {
setIsTogglingOn(false)
}
}
}
const isDisabled = permission === 'denied'
return (
<>
{/* Backdrop */}
<div
onClick={onClose}
aria-hidden="true"
style={{
position: 'fixed',
inset: 0,
background: 'var(--color-overlay, rgba(0,0,0,0.32))',
zIndex: 300,
}}
/>
{/* Sheet */}
<div
role="dialog"
aria-modal="true"
aria-label="Settings"
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
background: 'var(--color-surface-raised, #ffffff)',
borderRadius: '12px 12px 0 0',
boxShadow: '0 -4px 24px rgba(0,0,0,0.15)',
padding: 'var(--space-6, 24px)',
zIndex: 301,
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
maxWidth: '480px',
margin: '0 auto',
}}
>
{/* Heading row */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 'var(--space-6, 24px)',
}}
>
<h2
style={{
margin: 0,
fontSize: 'var(--text-heading-size, 18px)',
fontWeight: 600,
lineHeight: 'var(--text-heading-line-height, 1.25)',
color: 'var(--color-text-primary, #111318)',
}}
>
Settings
</h2>
<button
ref={closeButtonRef}
onClick={onClose}
aria-label="Close settings"
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
minWidth: '44px',
minHeight: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--color-text-secondary, #5c6472)',
borderRadius: 'var(--space-1, 4px)',
}}
>
<X size={20} aria-hidden="true" />
</button>
</div>
{/* Section label */}
<div
style={{
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-muted, #9CA3AF)',
textTransform: 'uppercase',
letterSpacing: '0.06em',
marginBottom: 'var(--space-2, 8px)',
}}
>
Notifications
</div>
{/* Toggle row */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-3, 12px)',
padding: 'var(--space-2, 8px) 0',
}}
>
<Bell
size={20}
aria-hidden="true"
style={{
color: 'var(--color-text-secondary, #5c6472)',
flexShrink: 0,
}}
/>
{/* Label column */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
color: 'var(--color-text-primary, #111318)',
lineHeight: 1.4,
}}
>
FamilySync Notifications
</div>
<div
style={{
fontSize: 'var(--text-label-size, 13px)',
color: 'var(--color-text-secondary, #5c6472)',
lineHeight: 1.4,
marginTop: '2px',
}}
>
Reminders, event changes, list updates
</div>
</div>
{/* Toggle switch or spinner */}
{isTogglingOn ? (
<Loader2
size={20}
aria-hidden="true"
style={{
color: 'var(--color-text-secondary, #5c6472)',
animation: 'spin 1s linear infinite',
flexShrink: 0,
}}
/>
) : (
<button
role="switch"
aria-checked={isOn}
aria-label={isOn ? 'FamilySync Notifications, on' : 'FamilySync Notifications, off'}
onClick={() => { void handleToggle() }}
disabled={isDisabled}
style={{
// 44px touch target
minWidth: '44px',
minHeight: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'none',
border: 'none',
cursor: isDisabled ? 'default' : 'pointer',
padding: 0,
flexShrink: 0,
opacity: isDisabled ? 0.5 : 1,
}}
>
{/* Toggle track */}
<div
style={{
width: '44px',
height: '24px',
borderRadius: '12px',
background: isOn
? 'var(--color-member-0, #4A90D9)'
: 'var(--color-border, #E2E4E9)',
position: 'relative',
transition: 'background 0.15s ease',
pointerEvents: 'none',
}}
>
{/* Toggle thumb */}
<div
style={{
position: 'absolute',
top: '2px',
left: isOn ? '22px' : '2px',
width: '20px',
height: '20px',
borderRadius: '50%',
background: '#ffffff',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
transition: 'left 0.15s ease',
}}
/>
</div>
</button>
)}
</div>
{/* Permission-denied hint — only when OS permission === 'denied' */}
{permission === 'denied' && (
<div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 'var(--space-2, 8px)',
marginTop: 'var(--space-2, 8px)',
padding: 'var(--space-3, 12px)',
background: 'var(--color-surface-dim, #F7F7F8)',
borderRadius: 'var(--space-1, 4px)',
}}
>
<AlertCircle
size={16}
aria-hidden="true"
style={{
color: 'var(--color-destructive, #DC2626)',
flexShrink: 0,
marginTop: '1px',
}}
/>
<div>
<span
style={{
fontSize: 'var(--text-label-size, 13px)',
color: 'var(--color-text-secondary, #5c6472)',
lineHeight: 1.4,
}}
>
Notifications are blocked in your browser settings.{' '}
</span>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
fontSize: 'var(--text-label-size, 13px)',
color: 'var(--color-focus-ring, #4A90D9)',
textDecoration: 'underline',
fontFamily: 'inherit',
}}
>
How to enable
</button>
</div>
</div>
)}
</div>
</>
)
}
+9
View File
@@ -136,3 +136,12 @@
background-position: 200% 0;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}