feat: draw the calendar toolbar's own words and task list in its skeleton

This commit is contained in:
Linus Rath
2026-11-02 19:42:52 +01:00
parent c06b4b5e28
commit e47eceb242
5 changed files with 227 additions and 20 deletions
+5 -3
View File
@@ -138,17 +138,19 @@
@supports (height: round(down, 1.5px, 1px)) and (height: 1cqh) {
.sk-cal-week,
.sk-cal-day {
--sk-fit: round(down, (100cqh - var(--sk-head)) / var(--boot-cal-hours, 12), 1px);
/* clientHeight, a whole number of pixels */
--sk-pane: round(nearest, 100cqh, 1px);
--sk-fit: round(down, (var(--sk-pane) - var(--sk-head)) / var(--boot-cal-hours, 12), 1px);
--sk-hour: max(var(--sk-fit), clamp(0px, (var(--sk-hour-floor) - var(--sk-fit)) * 1000, var(--sk-hour-min)));
/* The scroll the grid opens at: an hour before now, at most to the end. */
--sk-shift: clamp(
0px,
var(--boot-cal-scroll, -1) * var(--sk-hour-min) - 8px,
var(--boot-cal-hours, 12) * var(--sk-hour) - (100cqh - var(--sk-head))
var(--boot-cal-hours, 12) * var(--sk-hour) - (var(--sk-pane) - var(--sk-head))
);
}
.sk-cal-month {
--sk-week: max(var(--sk-week-min), round(down, (100cqh - var(--sk-head)) / 6, 1px));
--sk-week: max(var(--sk-week-min), round(down, (round(nearest, 100cqh, 1px) - var(--sk-head)) / 6, 1px));
}
}
.sk-cal-hours {
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { renderToString } from 'react-dom/server';
import { hydrateRoot } from 'react-dom/client';
import { RouteSkeleton } from '../route-skeleton';
import { CalendarSkeleton, CalendarViewSkeleton } from '../calendar-skeleton';
import type { BootSurface } from '@/lib/boot-skeleton/surfaces';
/**
@@ -57,3 +58,46 @@ describe('page skeletons hydrate without a mismatch', () => {
}
});
});
describe('the calendar skeleton with its words', () => {
const text = {
title: 'Calendar', today: 'Today', search: 'Search events', tasks: 'Tasks',
views: { month: 'Month', week: 'Week', day: 'Day', agenda: 'Agenda', tasks: 'Tasks' },
taskFilters: ['All', 'Pending', 'Completed', 'Overdue'] as [string, string, string, string],
newTask: 'New task', quickAdd: 'Add a task',
};
it('hydrates without a mismatch and reads out only its status', async () => {
const element = <CalendarSkeleton label="Loading..." labels={LABELS} text={text} />;
const html = renderToString(element);
const container = document.createElement('div');
container.innerHTML = html;
document.body.appendChild(container);
const errors: unknown[] = [];
const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => { errors.push(args); });
await act(async () => {
hydrateRoot(container, element, { onRecoverableError: (e) => { errors.push(e); } });
});
consoleError.mockRestore();
expect(errors).toEqual([]);
expect(container.innerHTML).toBe(html);
const region = container.querySelector('[data-boot-skeleton="calendar"]')!;
expect(region.textContent).toContain('Search events');
const walker = document.createTreeWalker(region, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (!node.textContent?.trim()) continue;
const el = node.parentElement!;
expect(el.closest('[aria-hidden="true"]') || el.closest('[role="status"]'), el.outerHTML).not.toBeNull();
}
});
it('draws one view, with its status, for the loaded page', () => {
const container = document.createElement('div');
container.innerHTML = renderToString(<CalendarViewSkeleton view="week" label="Loading calendars" text={text} />);
expect(container.querySelectorAll('[role="status"]')).toHaveLength(1);
expect(container.querySelector('.sk-cal-week')).not.toBeNull();
expect(container.querySelector('.sk-cal-month')).toBeNull();
expect(container.querySelectorAll('button, [tabindex]')).toHaveLength(0);
});
});
+68 -16
View File
@@ -53,6 +53,12 @@ export interface CalendarSkeletonText {
views?: Record<CalendarViewMode, string>;
/** calendar.tasks.label: the sidebar's tasks entry. */
tasks?: string;
/** calendar.tasks.filter_all, _pending, _completed, _overdue */
taskFilters?: [string, string, string, string];
/** calendar.tasks.create */
newTask?: string;
/** calendar.tasks.quick_add_placeholder */
quickAdd?: string;
}
const MODES: CalendarViewMode[] = ["month", "week", "day", "agenda", "tasks"];
@@ -71,6 +77,9 @@ export function calendarSkeletonText(t: (key: string) => string): CalendarSkelet
tasks: t("views.tasks"),
},
tasks: t("tasks.label"),
taskFilters: [t("tasks.filter_all"), t("tasks.filter_pending"), t("tasks.filter_completed"), t("tasks.filter_overdue")],
newTask: t("tasks.create"),
quickAdd: t("tasks.quick_add_placeholder"),
};
}
@@ -152,6 +161,16 @@ function Line({ className, bar, w }: { className: string; bar: string; w: string
);
}
/** The toolbar's title: a date range (a placeholder), or "Tasks" in the tasks view. */
function Title({ tasks, w }: { tasks?: string; w: string }) {
return (
<>
<Bar className={cn("h-3", tasks && "boot-cal-tasks:hidden")} w={w} />
{tasks ? <span className="hidden boot-cal-tasks:inline truncate">{tasks}</span> : null}
</>
);
}
/** CalendarToolbar: the 57px desktop and tablet row, the two-row phone header. */
function ToolbarSkeleton({ text }: { text?: CalendarSkeletonText }) {
const views = text?.views;
@@ -174,7 +193,9 @@ function ToolbarSkeleton({ text }: { text?: CalendarSkeletonText }) {
<ToolbarIcon className={NOT_TASKS}>
<ChevronRight />
</ToolbarIcon>
<Line className="ms-2 h-6 min-w-0" bar="h-3.5" w="9rem" />
<span className="flex items-center ms-2 h-6 min-w-0 text-base font-semibold">
<Title tasks={views?.tasks} w="9rem" />
</span>
<div className="flex-1" />
<span
className={cn(
@@ -211,11 +232,9 @@ function ToolbarSkeleton({ text }: { text?: CalendarSkeletonText }) {
<ChevronLeft />
</PhoneIcon>
{/* Below 400px the title takes a line of its own. */}
<Line
className="flex-1 justify-center h-5 min-w-0 max-[25rem]:order-first max-[25rem]:basis-full max-[25rem]:ps-1 max-[25rem]:justify-start"
bar="h-3"
w="5rem"
/>
<span className="flex flex-1 items-center justify-center h-5 min-w-0 text-sm font-semibold max-[25rem]:order-first max-[25rem]:basis-full max-[25rem]:ps-1 max-[25rem]:justify-start">
<Title tasks={views?.tasks} w="5rem" />
</span>
<PhoneIcon className={NOT_TASKS}>
<ChevronRight />
</PhoneIcon>
@@ -476,22 +495,42 @@ function AgendaSkeleton({ className }: { className: string }) {
}
/** The tasks view: TaskToolbar and TaskListView. */
function TasksSkeleton({ className }: { className: string }) {
function TasksSkeleton({ className, text }: ViewProps) {
const filters = text?.taskFilters;
return (
<div aria-hidden="true" className={cn("flex-col flex-1 min-h-0 overflow-hidden", className)}>
<div className="flex items-center gap-2 px-4 min-h-14 box-content border-b border-border flex-wrap">
{/* The filter starts at "all" on every load (stores/task-store.ts). */}
<div className="flex border border-border rounded-md overflow-hidden">
{repeat(4, () => (
<Line className="h-7 px-3" bar="h-2" w="2.5rem" />
{repeat(4, (i) => (
<span
className={cn(
"flex items-center h-7 px-3 text-xs font-medium",
i === 0 ? "bg-primary text-primary-foreground" : "text-muted-foreground",
)}
>
{filters ? filters[i] : <Bar className="h-2" w="2.5rem" />}
</span>
))}
</div>
<div className="flex-1" />
<Block className="h-9" w="6.5rem" />
{text?.newTask ? (
<span className="flex items-center h-9 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium shadow-sm">
<Plus className="w-4 h-4 me-1" />
{text.newTask}
</span>
) : (
<Block className="h-9" w="6.5rem" />
)}
</div>
<div className="px-4 py-2 border-b border-border">
<div className="flex items-center gap-2 h-5">
<Plus className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<Bar className="h-2.5" w="10rem" />
{text?.quickAdd ? (
<span className="text-sm text-muted-foreground truncate">{text.quickAdd}</span>
) : (
<Bar className="h-2.5" w="10rem" />
)}
</div>
</div>
<div className="divide-y divide-border">
@@ -509,7 +548,12 @@ function TasksSkeleton({ className }: { className: string }) {
);
}
const VIEWS: Record<CalendarViewMode, (props: { className: string }) => ReactNode> = {
interface ViewProps {
className: string;
text?: CalendarSkeletonText;
}
const VIEWS: Record<CalendarViewMode, (props: ViewProps) => ReactNode> = {
month: MonthSkeleton,
week: WeekSkeleton,
day: DaySkeleton,
@@ -523,15 +567,23 @@ const VIEWS: Record<CalendarViewMode, (props: { className: string }) => ReactNod
* with `label` as its status); without, the one `data-boot-calendar-view`
* names.
*/
export function CalendarViewSkeleton({ view, label }: { view?: CalendarViewMode; label?: string }) {
export function CalendarViewSkeleton({
view,
label,
text,
}: {
view?: CalendarViewMode;
label?: string;
text?: CalendarSkeletonText;
}) {
return (
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden" data-calendar-view-skeleton={view ?? ""} aria-busy="true">
{label ? <span role="status" className="sr-only">{label}</span> : null}
{view
? VIEWS[view]({ className: "flex" })
? VIEWS[view]({ className: "flex", text })
: (Object.keys(VIEWS) as CalendarViewMode[]).map((v) => {
const View = VIEWS[v];
return <View key={v} className={VIEW_SHOWN[v]} />;
return <View key={v} className={VIEW_SHOWN[v]} text={text} />;
})}
</div>
);
@@ -552,7 +604,7 @@ export function CalendarSkeletonBody({ embedded = false, text }: { embedded?: bo
<ToolbarSkeleton text={text} />
<div className="relative flex flex-1 overflow-hidden isolate">
<div className="flex flex-1 min-w-0">
<CalendarViewSkeleton />
<CalendarViewSkeleton text={text} />
</div>
{/* The floating create button of a phone. */}
<span className="md:hidden absolute bottom-4 end-4 z-40 flex items-center justify-center h-14 w-14 rounded-full shadow-lg bg-primary text-primary-foreground">
@@ -0,0 +1,109 @@
import { render } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Calendar } from '@/lib/jmap/types';
import { useCalendarStore } from '@/stores/calendar-store';
import { useAuthStore } from '@/stores/auth-store';
import { useSettingsStore } from '@/stores/settings-store';
// The calendar page before its data is there: the boot skeleton while the
// session is restored, then the view's grid in the same shapes while the
// calendars load (components/boot-skeleton/calendar-skeleton.tsx).
vi.mock('@/hooks/use-media-query', () => ({
useIsMobile: () => false,
useIsDesktop: () => true,
useIsTablet: () => false,
useMediaQuery: () => false,
}));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null }));
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
vi.mock('@/hooks/use-refresh-gesture', () => ({ useRefreshGesture: () => ({ indicator: null }) }));
vi.mock('@/hooks/use-calendar-push-refresh', () => ({ useCalendarPushRefresh: () => {} }));
vi.mock('@/hooks/use-account-principal-emails', () => ({ useAccountPrincipalEmails: () => [] }));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ demoMode: false }) }));
import { CalendarApp } from '../calendar-app';
vi.setConfig({ testTimeout: 30000 });
const RIGHTS = {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
};
const calendars = [{
id: 'cal', name: 'Personal', color: '#123456', isDefault: true, sortOrder: 0, description: null,
isSubscribed: true, isVisible: true, includeInAvailability: 'all', defaultAlertsWithTime: null,
defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, myRights: RIGHTS,
}] as unknown as Calendar[];
const client = {
getAccountId: () => 'acc',
getMaxParticipantsPerEvent: () => null,
supportsCalendars: () => true,
queryCalendarEvents: vi.fn().mockResolvedValue([]),
getCalendarEvent: vi.fn().mockResolvedValue(null),
getCalendarTasks: vi.fn().mockResolvedValue([]),
};
const noop = () => vi.fn().mockResolvedValue(undefined);
beforeEach(() => {
useSettingsStore.setState({ enableCalendarTasks: true, proInterface: false } as never);
useCalendarStore.setState({
calendars: [], events: [], supportsCalendar: false, selectedCalendarIds: [], viewMode: 'week',
selectedDate: new Date(2026, 10, 4), isLoading: false, isLoadingEvents: false, error: null,
fetchCalendars: noop(), fetchEvents: noop(), refreshAllSubscriptions: noop(),
} as never);
});
const bootSkeleton = (root: HTMLElement) => root.querySelector('[data-boot-skeleton="calendar"]');
const viewSkeleton = (root: HTMLElement) => root.querySelector('[data-calendar-view-skeleton]');
describe('CalendarApp before its data is there', () => {
it('draws the calendar skeleton while the sign-in is restored', () => {
// Still checking: no redirect to the login page yet.
useAuthStore.setState({ isAuthenticated: false, client: null, isLoading: true, checkAuth: noop() } as never);
const { container } = render(<CalendarApp />);
const skeleton = bootSkeleton(container);
expect(skeleton).not.toBeNull();
expect(skeleton?.getAttribute('aria-busy')).toBe('true');
expect(skeleton?.querySelector('[role="status"]')?.textContent).toBe('common.loading');
// The toolbar's words, from the calendar's own messages.
expect(skeleton?.textContent).toContain('views.today');
});
it('draws the skeleton while the session has not said the account has calendars', () => {
useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc' } as never);
const { container } = render(<CalendarApp />);
expect(bootSkeleton(container)).not.toBeNull();
expect(container.querySelector('main')).toBeNull();
});
it('draws the grid of the view in the skeleton shapes while the calendars load', () => {
useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc' } as never);
useCalendarStore.setState({ supportsCalendar: true, isLoading: true, viewMode: 'month' } as never);
const { container } = render(<CalendarApp />);
// The real chrome is up; only the grid waits.
expect(bootSkeleton(container)).toBeNull();
expect(container.querySelector('main')).not.toBeNull();
const grid = viewSkeleton(container);
expect(grid?.getAttribute('data-calendar-view-skeleton')).toBe('month');
expect(grid?.querySelector('[role="status"]')?.textContent).toBe('status.loading_calendars');
expect(container.querySelector('.animate-spin')).toBeNull();
});
it('draws the real grid, without a spinner over it, while the first events load', () => {
useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc' } as never);
useCalendarStore.setState({
supportsCalendar: true, calendars, selectedCalendarIds: ['cal'], isLoadingEvents: true,
} as never);
const { container } = render(<CalendarApp />);
expect(viewSkeleton(container)).toBeNull();
expect(container.querySelector('[role="grid"]')).not.toBeNull();
expect(container.querySelector('.animate-spin')).toBeNull();
expect(Array.from(container.querySelectorAll('[role="status"]')).some((s) => s.textContent === 'status.loading_events')).toBe(true);
});
});
+1 -1
View File
@@ -1868,7 +1868,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
// The calendars are loading: the view's grid in the skeleton's shapes,
// where the real one is about to be drawn.
if (isLoading && calendars.length === 0) {
return <CalendarViewSkeleton view={normalizedViewMode} label={t("status.loading_calendars")} />;
return <CalendarViewSkeleton view={normalizedViewMode} label={t("status.loading_calendars")} text={skeletonText} />;
}
const viewContent = (() => {