feat(02-05): ColorLegend, AppNav, ViewToolbar, SkeletonCalendar, EmptyState; retire EventProof
- ColorLegend: per-member color swatches (12px circle, label) + always-visible Family row - AppNav: phone 48px top bar (avatar with aria-label/title) + tablet/desktop 240px sidebar with ColorLegend - ViewToolbar: Today/prev/next + Day/Week/Month/Agenda view switcher; 44px min-height; active state uses surface tint not accent - SkeletonCalendar: shimmer month (6x7 grid) and agenda (4 date-group blocks) variants; aria-busy=true - EmptyState: CalendarDays icon + 'Nothing here' heading + body copy per UI-SPEC - CalendarShell: full phone/desktop layout with AppNav + ViewToolbar + ColorLegend chrome - CalendarShell: state branches — loading→SkeletonCalendar, empty→EmptyState, error→'Couldn't load events' + Retry button (refetchQueries) - EventProof.tsx deleted; legacy types removed from client.ts - CalendarShell.test.tsx: updated to waitFor ScheduleXCalendar after data loads - All 36 tests pass, tsc clean, vite build clean (490kB)
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* SkeletonCalendar — shimmer loading placeholder.
|
||||
*
|
||||
* UI-SPEC §SkeletonCalendar:
|
||||
* - Month variant: 6×7 grid of rounded rect placeholders, animated shimmer
|
||||
* - Agenda variant: 4 date-group blocks, 2–3 rows each, varying widths (60–90%)
|
||||
* - No spinner; shimmer only (matches Fantastical-style)
|
||||
* - aria-busy="true", aria-label="Loading calendar" on root
|
||||
*
|
||||
* Shimmer keyframe declared in tokens.css (@keyframes shimmer).
|
||||
* The shimmer animation uses the gradient from tokens.css:
|
||||
* background: linear-gradient(90deg, --color-surface-dim, --color-border-subtle, --color-surface-dim)
|
||||
*/
|
||||
|
||||
type SkeletonVariant = 'month' | 'agenda'
|
||||
|
||||
interface SkeletonCalendarProps {
|
||||
variant?: SkeletonVariant
|
||||
}
|
||||
|
||||
/** Shimmer inline style — gradient + animation referencing the keyframe in tokens.css */
|
||||
const shimmerStyle: React.CSSProperties = {
|
||||
background:
|
||||
'linear-gradient(90deg, var(--color-surface-dim), var(--color-border-subtle), var(--color-surface-dim))',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'shimmer 1.5s infinite',
|
||||
borderRadius: 'var(--space-1)',
|
||||
}
|
||||
|
||||
export function SkeletonCalendar({ variant = 'month' }: SkeletonCalendarProps) {
|
||||
return (
|
||||
<div
|
||||
aria-busy="true"
|
||||
aria-label="Loading calendar"
|
||||
style={{
|
||||
padding: 'var(--space-4)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{variant === 'month' ? <MonthSkeleton /> : <AgendaSkeleton />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 6×7 month grid of shimmer cells */
|
||||
function MonthSkeleton() {
|
||||
return (
|
||||
<div>
|
||||
{/* Day-of-week header row */}
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||
gap: 'var(--space-1)',
|
||||
marginBottom: 'var(--space-2)',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 7 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
...shimmerStyle,
|
||||
height: '16px',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 6 rows × 7 columns */}
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||
gap: 'var(--space-1)',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 42 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
height: '80px',
|
||||
...shimmerStyle,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 4 date-group blocks, 2–3 event rows each, varying widths */
|
||||
function AgendaSkeleton() {
|
||||
// Each group has: a date header + 2–3 event rows with varying widths
|
||||
const groups = [
|
||||
{ rows: [80, 65, 90] },
|
||||
{ rows: [75, 60] },
|
||||
{ rows: [85, 70, 65] },
|
||||
{ rows: [60, 80] },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)' }}>
|
||||
{groups.map((group, gi) => (
|
||||
<div key={gi}>
|
||||
{/* Date group header placeholder */}
|
||||
<div
|
||||
style={{
|
||||
...shimmerStyle,
|
||||
height: '20px',
|
||||
width: '40%',
|
||||
marginBottom: 'var(--space-3)',
|
||||
}}
|
||||
/>
|
||||
{/* Event rows */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
||||
{group.rows.map((widthPct, ri) => (
|
||||
<div
|
||||
key={ri}
|
||||
style={{
|
||||
...shimmerStyle,
|
||||
height: '44px',
|
||||
width: `${widthPct}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user