mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 00:13:09 +00:00
feat: draw the calendar toolbar's own words and task list in its skeleton
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 = (() => {
|
||||
|
||||
Reference in New Issue
Block a user