mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 00:14:12 +00:00
feat: show the page's skeleton on Lite pages while their language loads
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Page skeletons (components/boot-skeleton).
|
||||
*
|
||||
* The server draws every variant of a skeleton; these variants show the one
|
||||
* that matches the attributes the boot script set on <html> before the first
|
||||
* paint (lib/boot-skeleton/boot-script.ts). Part of the first stylesheet, so
|
||||
* nothing here arrives late.
|
||||
*/
|
||||
|
||||
/* Mountain View shell. Its geometry only applies on desktop, so pair it with
|
||||
lg: in markup (lg:mv:hidden), except the phone bottom bar, which follows
|
||||
the layout at every width. */
|
||||
@custom-variant mv (&:where([data-interface-layout="mountain-view"] *));
|
||||
|
||||
/* Mail layout (effectiveMailLayout: Mountain View and simple mode are focus). */
|
||||
@custom-variant boot-split (&:where([data-boot-mail-layout="split"] *));
|
||||
@custom-variant boot-horizontal (&:where([data-boot-mail-layout="horizontal"] *));
|
||||
@custom-variant boot-focus (&:where([data-boot-mail-layout="focus"] *));
|
||||
|
||||
/* A message, thread, contact, settings page or admin page is open. */
|
||||
@custom-variant boot-open (&:where([data-boot-pane] *));
|
||||
@custom-variant boot-thread (&:where([data-boot-pane="thread"] *));
|
||||
|
||||
/* The Pro interface takes this page over on a desktop. */
|
||||
@custom-variant boot-pro (&:where([data-boot-pro="on"] *));
|
||||
|
||||
/* Nothing stored that checkAuth could restore: draw the login card. */
|
||||
@custom-variant boot-out (&:where([data-boot-auth="out"] *));
|
||||
|
||||
/* Calendar view. */
|
||||
@custom-variant boot-cal-month (&:where([data-boot-calendar-view="month"] *));
|
||||
@custom-variant boot-cal-week (&:where([data-boot-calendar-view="week"] *));
|
||||
@custom-variant boot-cal-day (&:where([data-boot-calendar-view="day"] *));
|
||||
@custom-variant boot-cal-agenda (&:where([data-boot-calendar-view="agenda"] *));
|
||||
@custom-variant boot-cal-tasks (&:where([data-boot-calendar-view="tasks"] *));
|
||||
@custom-variant boot-cal-collapsed (&:where([data-boot-collapsed~="calendar"] *));
|
||||
|
||||
/* Files. */
|
||||
@custom-variant boot-files-grid (&:where([data-boot-files-view="grid"] *));
|
||||
@custom-variant boot-files-details (&:where([data-boot-files-details="on"] *));
|
||||
|
||||
/* What the rail showed last time (bulwark-boot-hints). Without hints every
|
||||
app is drawn: the rail of a server with all of them. */
|
||||
@custom-variant boot-no-calendar (&:where([data-boot-rail]:not([data-boot-rail~="calendar"]) *));
|
||||
@custom-variant boot-no-contacts (&:where([data-boot-rail]:not([data-boot-rail~="contacts"]) *));
|
||||
@custom-variant boot-no-files (&:where([data-boot-rail]:not([data-boot-rail~="files"]) *));
|
||||
@custom-variant boot-rail-admin (&:where([data-boot-rail~="admin"] *));
|
||||
@custom-variant boot-rail-a11y (&:where([data-boot-rail~="a11y"] *));
|
||||
@custom-variant boot-rail-quota (&:where([data-boot-rail~="quota"] *));
|
||||
|
||||
/* A placeholder shape. A slow opacity pulse rather than a moving shimmer:
|
||||
opacity runs on the compositor, so the skeleton costs the main thread
|
||||
nothing while the app's JS is still starting up. */
|
||||
.sk-shape {
|
||||
background-color: var(--color-muted);
|
||||
animation: sk-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes sk-pulse {
|
||||
50% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion: no pulse at all (the global rule only shortens it). */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root:not([data-motion="full"]) .sk-shape {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
:root[data-motion="reduce"] .sk-shape {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* More contrast: shapes that stand out from the panels they sit on. */
|
||||
@media (prefers-contrast: more) {
|
||||
:root:not([data-contrast="normal"]) .sk-shape {
|
||||
background-color: color-mix(in oklab, var(--color-foreground) 28%, transparent);
|
||||
}
|
||||
}
|
||||
:root[data-contrast="more"] .sk-shape {
|
||||
background-color: color-mix(in oklab, var(--color-foreground) 28%, transparent);
|
||||
}
|
||||
|
||||
/* Forced colours drop backgrounds; keep the shapes as system grey. */
|
||||
@media (forced-colors: active) {
|
||||
.sk-shape {
|
||||
forced-color-adjust: none;
|
||||
background-color: GrayText;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
@import "./theme-inline.css";
|
||||
@import "./accessibility.css";
|
||||
@import "./boot-skeleton.css";
|
||||
|
||||
/* The app's font stack replaces Tailwind's default --font-sans, so the
|
||||
font-sans utility and body use the same stack, and a theme's
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { act } from 'react';
|
||||
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 type { BootSurface } from '@/lib/boot-skeleton/surfaces';
|
||||
|
||||
/**
|
||||
* Every page skeleton is rendered by the server and then hydrated by the same
|
||||
* component on the client. They must produce identical markup, or React throws
|
||||
* the server HTML away and the page flashes.
|
||||
*/
|
||||
|
||||
const SURFACES: BootSurface[] = [
|
||||
'mail', 'calendar', 'contacts', 'files', 'apps', 'settings', 'pro', 'admin', 'admin-login', 'login', 'setup',
|
||||
];
|
||||
|
||||
const LABELS = { mail: 'Mail', calendar: 'Calendar', contacts: 'Contacts', files: 'Files', settings: 'Settings' };
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('page skeletons hydrate without a mismatch', () => {
|
||||
it.each(SURFACES)('%s', async (surface) => {
|
||||
const element = <RouteSkeleton surface={surface} label="Loading..." labels={LABELS} />;
|
||||
const html = renderToString(element);
|
||||
expect(html).toContain(`data-boot-skeleton="${surface === 'admin-login' ? 'login' : surface}"`);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it.each(SURFACES)('%s: one status message, busy, and nothing focusable', (surface) => {
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = renderToString(<RouteSkeleton surface={surface} label="Loading..." labels={LABELS} />);
|
||||
// A signed-in page also carries the login card for signed-out visitors,
|
||||
// hidden by CSS; count per region.
|
||||
for (const region of container.querySelectorAll('[data-boot-skeleton]')) {
|
||||
expect(region.getAttribute('aria-busy')).toBe('true');
|
||||
expect(region.querySelectorAll('[role="status"]').length).toBeLessThanOrEqual(1);
|
||||
}
|
||||
expect(container.querySelectorAll('[role="status"]').length).toBeGreaterThan(0);
|
||||
expect(container.querySelectorAll('a[href], button, input, select, textarea, [tabindex]')).toHaveLength(0);
|
||||
for (const shape of container.querySelectorAll('.sk-shape')) {
|
||||
expect(shape.closest('[aria-hidden="true"]'), shape.outerHTML).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { SkeletonRegion } from "./kit";
|
||||
|
||||
export interface AdminSkeletonProps {
|
||||
/** common.loading */
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Skeleton of the admin page. Stub: filled in per surface. */
|
||||
export function AdminSkeleton({ label, className }: AdminSkeletonProps) {
|
||||
return <SkeletonRegion surface="admin" label={label} className={className ?? "min-h-dvh bg-background"} />;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { AppShellSkeleton, type RailLabels } from "./shell";
|
||||
|
||||
export interface CalendarSkeletonProps {
|
||||
/** common.loading */
|
||||
label?: string;
|
||||
/** Rail labels for the phone bottom bar (sidebar.*). */
|
||||
labels?: RailLabels;
|
||||
}
|
||||
|
||||
/** Skeleton of the calendar page. Stub: filled in per surface. */
|
||||
export function CalendarSkeleton({ label, labels }: CalendarSkeletonProps) {
|
||||
return (
|
||||
<AppShellSkeleton surface="calendar" label={label} labels={labels} railBelow="md" active="calendar">
|
||||
<div className="flex-1 min-w-0" />
|
||||
</AppShellSkeleton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { AppShellSkeleton, type RailLabels } from "./shell";
|
||||
|
||||
export interface ContactsSkeletonProps {
|
||||
/** common.loading */
|
||||
label?: string;
|
||||
/** Rail labels for the phone bottom bar (sidebar.*). */
|
||||
labels?: RailLabels;
|
||||
}
|
||||
|
||||
/** Skeleton of the contacts page. Stub: filled in per surface. */
|
||||
export function ContactsSkeleton({ label, labels }: ContactsSkeletonProps) {
|
||||
return (
|
||||
<AppShellSkeleton surface="contacts" label={label} labels={labels} railBelow="md" active="contacts">
|
||||
<div className="flex-1 min-w-0" />
|
||||
</AppShellSkeleton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { AppShellSkeleton, type RailLabels } from "./shell";
|
||||
|
||||
export interface FilesSkeletonProps {
|
||||
/** common.loading */
|
||||
label?: string;
|
||||
/** Rail labels for the phone bottom bar (sidebar.*). */
|
||||
labels?: RailLabels;
|
||||
}
|
||||
|
||||
/** Skeleton of the files page. Stub: filled in per surface. */
|
||||
export function FilesSkeleton({ label, labels }: FilesSkeletonProps) {
|
||||
return (
|
||||
<AppShellSkeleton surface="files" label={label} labels={labels} railBelow="md" active="files">
|
||||
<div className="flex-1 min-w-0" />
|
||||
</AppShellSkeleton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* The page skeleton kit.
|
||||
*
|
||||
* Plain markup with no hooks, no store reads and no next-intl, so the same
|
||||
* component renders on the server (before any JS runs), during hydration and
|
||||
* inside a loaded page's own loading state, and Lite pages without a message
|
||||
* catalog can draw it too. Variants are picked by CSS from the `data-boot-*`
|
||||
* attributes the boot script sets on <html> (lib/boot-skeleton/boot-script.ts);
|
||||
* the classes behind them live in app/boot-skeleton.css.
|
||||
*/
|
||||
import { Fragment, type CSSProperties, type ReactNode } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
export type SkeletonSurface =
|
||||
| "mail"
|
||||
| "calendar"
|
||||
| "contacts"
|
||||
| "files"
|
||||
| "apps"
|
||||
| "settings"
|
||||
| "pro"
|
||||
| "admin"
|
||||
| "admin-login"
|
||||
| "login"
|
||||
| "setup";
|
||||
|
||||
interface RegionProps {
|
||||
/** Which page this is the skeleton of; tests and the probes look for it. */
|
||||
surface: SkeletonSurface;
|
||||
/**
|
||||
* The one polite "Loading" announcement for the whole page. Leave it out
|
||||
* only where no translation is at hand (Lite before its catalog loads).
|
||||
*/
|
||||
label?: string;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The outermost element of a page skeleton: marks the page busy and carries
|
||||
* the single status message. Everything visual inside is aria-hidden.
|
||||
*/
|
||||
export function SkeletonRegion({ surface, label, className, style, children }: RegionProps) {
|
||||
return (
|
||||
<div data-boot-skeleton={surface} aria-busy="true" className={className} style={style}>
|
||||
{label ? <span role="status" className="sr-only">{label}</span> : null}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ShapeProps {
|
||||
className?: string;
|
||||
/** Width as any CSS length, e.g. "60%" or "8rem". */
|
||||
w?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/** A placeholder for a line of text: give it a height (h-3, h-4) and a width. */
|
||||
export function Bar({ className, w, style }: ShapeProps) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("sk-shape block h-3 rounded", className)}
|
||||
style={w ? { ...style, width: w } : style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** A placeholder for an avatar or a round button: give it a size (w-8 h-8). */
|
||||
export function Circle({ className, style }: Omit<ShapeProps, "w">) {
|
||||
return <span aria-hidden="true" className={cn("sk-shape block shrink-0 rounded-full", className)} style={style} />;
|
||||
}
|
||||
|
||||
/** A placeholder for a box: a button, an input, a card. */
|
||||
export function Block({ className, w, style }: ShapeProps) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("sk-shape block rounded-md", className)}
|
||||
style={w ? { ...style, width: w } : style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Widths for placeholder text that look like text: varied, but the same on
|
||||
* every render (no randomness, so server and client markup agree).
|
||||
*/
|
||||
export function textWidth(i: number, min = 40, spread = 45): string {
|
||||
return `${min + ((i * 37 + 11) % spread)}%`;
|
||||
}
|
||||
|
||||
/** `count` items from `render`, keyed by index. */
|
||||
export function repeat(count: number, render: (i: number) => ReactNode): ReactNode[] {
|
||||
const out: ReactNode[] = [];
|
||||
for (let i = 0; i < count; i++) out.push(<Fragment key={i}>{render(i)}</Fragment>);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { SkeletonRegion } from "./kit";
|
||||
|
||||
export interface LoginSkeletonProps {
|
||||
/** common.loading */
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Skeleton of the login page. Stub: filled in per surface. */
|
||||
export function LoginSkeleton({ label, className }: LoginSkeletonProps) {
|
||||
return <SkeletonRegion surface="login" label={label} className={className ?? "min-h-dvh bg-background"} />;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { AppShellSkeleton, type RailLabels } from "./shell";
|
||||
|
||||
export interface MailSkeletonProps {
|
||||
/** common.loading */
|
||||
label?: string;
|
||||
/** Rail labels for the phone bottom bar (sidebar.*). */
|
||||
labels?: RailLabels;
|
||||
}
|
||||
|
||||
/** Skeleton of the mail page. Stub: filled in per surface. */
|
||||
export function MailSkeleton({ label, labels }: MailSkeletonProps) {
|
||||
return (
|
||||
<AppShellSkeleton surface="mail" label={label} labels={labels} railBelow="lg" active="mail" mvSearch>
|
||||
<div className="flex-1 min-w-0" />
|
||||
</AppShellSkeleton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { AppShellSkeleton, type RailLabels } from "./shell";
|
||||
|
||||
export interface PluginPageSkeletonProps {
|
||||
/** common.loading */
|
||||
label?: string;
|
||||
/** Rail labels for the phone bottom bar (sidebar.*). */
|
||||
labels?: RailLabels;
|
||||
}
|
||||
|
||||
/** Skeleton of the apps page. Stub: filled in per surface. */
|
||||
export function PluginPageSkeleton({ label, labels }: PluginPageSkeletonProps) {
|
||||
return (
|
||||
<AppShellSkeleton surface="apps" label={label} labels={labels} railBelow="md">
|
||||
<div className="flex-1 min-w-0" />
|
||||
</AppShellSkeleton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { SkeletonRegion } from "./kit";
|
||||
|
||||
export interface ProSkeletonProps {
|
||||
/** common.loading */
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Skeleton of the pro page. Stub: filled in per surface. */
|
||||
export function ProSkeleton({ label, className }: ProSkeletonProps) {
|
||||
return <SkeletonRegion surface="pro" label={label} className={className ?? "min-h-dvh bg-background"} />;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { BootSurface } from "@/lib/boot-skeleton/surfaces";
|
||||
import type { RailLabels } from "./shell";
|
||||
import { MailSkeleton } from "./mail-skeleton";
|
||||
import { CalendarSkeleton } from "./calendar-skeleton";
|
||||
import { ContactsSkeleton } from "./contacts-skeleton";
|
||||
import { FilesSkeleton } from "./files-skeleton";
|
||||
import { PluginPageSkeleton } from "./plugin-page-skeleton";
|
||||
import { SettingsSkeleton } from "./settings-skeleton";
|
||||
import { AdminSkeleton } from "./admin-skeleton";
|
||||
import { ProSkeleton } from "./pro-skeleton";
|
||||
import { LoginSkeleton } from "./login-skeleton";
|
||||
import { SetupSkeleton } from "./setup-skeleton";
|
||||
|
||||
interface RouteSkeletonProps {
|
||||
surface: BootSurface;
|
||||
label?: string;
|
||||
labels?: RailLabels;
|
||||
}
|
||||
|
||||
/**
|
||||
* The skeleton of a whole page, picked by surface. Used where the page is not
|
||||
* the one drawing it: a connector link (its destination), the protocol
|
||||
* handlers, a Lite page waiting for its message catalog, loading.tsx.
|
||||
*/
|
||||
export function RouteSkeleton({ surface, label, labels }: RouteSkeletonProps) {
|
||||
switch (surface) {
|
||||
case "mail":
|
||||
return <MailSkeleton label={label} labels={labels} />;
|
||||
case "calendar":
|
||||
return <CalendarSkeleton label={label} labels={labels} />;
|
||||
case "contacts":
|
||||
return <ContactsSkeleton label={label} labels={labels} />;
|
||||
case "files":
|
||||
return <FilesSkeleton label={label} labels={labels} />;
|
||||
case "apps":
|
||||
return <PluginPageSkeleton label={label} labels={labels} />;
|
||||
case "settings":
|
||||
return <SettingsSkeleton label={label} labels={labels} />;
|
||||
case "pro":
|
||||
return <ProSkeleton label={label} />;
|
||||
case "admin":
|
||||
return <AdminSkeleton label={label} />;
|
||||
case "admin-login":
|
||||
case "login":
|
||||
return <LoginSkeleton label={label} />;
|
||||
case "setup":
|
||||
return <SetupSkeleton label={label} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { AppShellSkeleton, type RailLabels } from "./shell";
|
||||
|
||||
export interface SettingsSkeletonProps {
|
||||
/** common.loading */
|
||||
label?: string;
|
||||
/** Rail labels for the phone bottom bar (sidebar.*). */
|
||||
labels?: RailLabels;
|
||||
}
|
||||
|
||||
/** Skeleton of the settings page. Stub: filled in per surface. */
|
||||
export function SettingsSkeleton({ label, labels }: SettingsSkeletonProps) {
|
||||
return (
|
||||
<AppShellSkeleton surface="settings" label={label} labels={labels} railBelow="lg" active="settings">
|
||||
<div className="flex-1 min-w-0" />
|
||||
</AppShellSkeleton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { SkeletonRegion } from "./kit";
|
||||
|
||||
export interface SetupSkeletonProps {
|
||||
/** common.loading */
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Skeleton of the setup page. Stub: filled in per surface. */
|
||||
export function SetupSkeleton({ label, className }: SetupSkeletonProps) {
|
||||
return <SkeletonRegion surface="setup" label={label} className={className ?? "min-h-dvh bg-background"} />;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* The app frame every signed-in page shares, as a skeleton: the page root, the
|
||||
* Mountain View top bar, the vertical rail and the phone bottom bar. Class
|
||||
* lists mirror the real ones (components/layout/navigation-rail.tsx,
|
||||
* components/layout/mv-top-bar.tsx and the roots of mail-app, calendar-app,
|
||||
* contacts-app, files-app, settings-app), so the frame doesn't move when the
|
||||
* page takes over.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Accessibility,
|
||||
Bell,
|
||||
CalendarEvent,
|
||||
Folder,
|
||||
Grid3x3,
|
||||
Keyboard,
|
||||
Mail,
|
||||
Menu,
|
||||
Settings,
|
||||
Shield,
|
||||
Users,
|
||||
} from "@/components/icons";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Bar, Circle, SkeletonRegion, type SkeletonSurface } from "./kit";
|
||||
import { LoginSkeleton } from "./login-skeleton";
|
||||
|
||||
export type RailApp = "mail" | "calendar" | "contacts" | "files" | "settings";
|
||||
|
||||
/** Translated rail labels for the phone bottom bar; bars are drawn where one is missing. */
|
||||
export type RailLabels = Partial<Record<RailApp | "admin", string>>;
|
||||
|
||||
const RAIL_BORDER = { borderRight: "1px solid rgba(128, 128, 128, 0.3)" } as const;
|
||||
const DIVIDER = { borderColor: "rgba(128, 128, 128, 0.3)" } as const;
|
||||
|
||||
/** Hidden when the last session's rail didn't show the app (bulwark-boot-hints). */
|
||||
const APP_HIDE: Record<"calendar" | "contacts" | "files", string> = {
|
||||
calendar: "boot-no-calendar:hidden",
|
||||
contacts: "boot-no-contacts:hidden",
|
||||
files: "boot-no-files:hidden",
|
||||
};
|
||||
|
||||
const APPS = [
|
||||
{ id: "mail", icon: Mail },
|
||||
{ id: "calendar", icon: CalendarEvent },
|
||||
{ id: "contacts", icon: Users },
|
||||
{ id: "files", icon: Folder },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The 56px vertical rail (NavigationRail collapsed). `className` decides at
|
||||
* which widths it shows.
|
||||
*/
|
||||
export function RailSkeleton({ active, className }: { active?: RailApp; className?: string }) {
|
||||
const item = (on: boolean) =>
|
||||
cn(
|
||||
"relative flex items-center justify-center w-10 h-10 rounded-md max-lg:min-h-[44px]",
|
||||
on ? "bg-primary/10 text-primary" : "text-muted-foreground",
|
||||
);
|
||||
const footerItem = "flex items-center justify-center w-10 h-10 rounded-md";
|
||||
return (
|
||||
<div aria-hidden="true" className={cn("w-14 bg-secondary flex-col flex-shrink-0", className)} style={RAIL_BORDER}>
|
||||
<div className="flex flex-col h-full items-center">
|
||||
<div className="flex flex-col flex-1 min-h-0 items-center gap-1 py-3 px-1">
|
||||
{APPS.map(({ id, icon: Icon }) => (
|
||||
<span key={id} className={cn(item(active === id), id !== "mail" && APP_HIDE[id])}>
|
||||
<Icon className={cn("w-[18px] h-[18px] flex-shrink-0", active === id && "text-primary")} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1 text-muted-foreground">
|
||||
<span className={cn(footerItem, "hidden boot-rail-admin:flex")}>
|
||||
<Shield className="w-[18px] h-[18px]" />
|
||||
</span>
|
||||
<span className={footerItem}>
|
||||
<Bell className="w-[18px] h-[18px]" />
|
||||
</span>
|
||||
<span className={cn(footerItem, "hidden boot-rail-a11y:flex")}>
|
||||
<Accessibility className="w-[18px] h-[18px]" />
|
||||
</span>
|
||||
<span className={cn(footerItem, active === "settings" && "bg-primary/10 text-primary")}>
|
||||
<Settings className="w-[18px] h-[18px]" />
|
||||
</span>
|
||||
<div className="w-8 border-t" style={DIVIDER} />
|
||||
<span className={footerItem}>
|
||||
<Keyboard className="w-[18px] h-[18px]" />
|
||||
</span>
|
||||
<Circle className="hidden boot-rail-quota:block w-8 h-8" />
|
||||
<span className="flex items-center justify-center w-10 h-10 pointer-coarse:w-11 pointer-coarse:h-11">
|
||||
<Circle className="w-8 h-8" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The phone/tablet bottom bar (NavigationRail horizontal). `className` decides
|
||||
* at which widths it shows.
|
||||
*/
|
||||
export function BottomBarSkeleton({
|
||||
active,
|
||||
labels,
|
||||
className,
|
||||
}: {
|
||||
active?: RailApp;
|
||||
labels?: RailLabels;
|
||||
className?: string;
|
||||
}) {
|
||||
const items: { id: RailApp; icon: typeof Mail }[] = [...APPS, { id: "settings", icon: Settings }];
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"items-center bg-background border-t border-border shrink-0 overflow-hidden pb-[calc(env(safe-area-inset-bottom)/2)]",
|
||||
"mv:min-h-16 mv:border-t-0 mv:bg-secondary mv:shadow-[0_-1px_3px_rgb(0_0_0/0.08)]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{items.map(({ id, icon: Icon }) => (
|
||||
<span
|
||||
key={id}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
||||
active === id ? "text-primary" : "text-muted-foreground",
|
||||
id !== "mail" && id !== "settings" && APP_HIDE[id],
|
||||
)}
|
||||
>
|
||||
<span className={cn("relative", active === id && "mv:min-w-14 mv:rounded-full mv:bg-primary/15 mv:px-4 mv:py-[3px]")}>
|
||||
<Icon className="w-5 h-5" />
|
||||
{active === id ? (
|
||||
<span className="mv:hidden absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
|
||||
) : null}
|
||||
</span>
|
||||
{labels?.[id] ? (
|
||||
<span className="text-[0.625rem] font-medium leading-tight truncate max-w-full">{labels[id]}</span>
|
||||
) : (
|
||||
<Bar className="h-2 my-px" w="2rem" />
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mountain View's 64px top bar (components/layout/mv-top-bar.tsx), desktop only. */
|
||||
export function MvTopBarSkeleton({ search = true, className }: { search?: boolean; className?: string }) {
|
||||
const round = "grid place-items-center w-12 h-12 rounded-full text-muted-foreground";
|
||||
return (
|
||||
<div aria-hidden="true" className={cn("items-center h-16 gap-2 ps-1 pe-4 shrink-0 bg-[var(--mv-canvas)]", className)}>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className={round}>
|
||||
<Menu className="w-5 h-5" />
|
||||
</span>
|
||||
<span className="flex items-center gap-2 pe-4 min-w-0">
|
||||
<Bar className="h-5" w="7rem" />
|
||||
</span>
|
||||
</div>
|
||||
{search ? (
|
||||
<div className="relative flex items-center flex-1 min-w-0 max-w-[720px]">
|
||||
<span className="block w-full h-[var(--mv-search-h,48px)] rounded-[var(--mv-search-radius,8px)] bg-[var(--mv-search-bg)]" />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex-1 min-w-0" />
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className={round}>
|
||||
<Bell className="w-5 h-5" />
|
||||
</span>
|
||||
<span className={round}>
|
||||
<Keyboard className="w-5 h-5" />
|
||||
</span>
|
||||
<span className={cn(round, "hidden boot-rail-admin:grid")}>
|
||||
<Shield className="w-5 h-5" />
|
||||
</span>
|
||||
<span className={round}>
|
||||
<Settings className="w-5 h-5" />
|
||||
</span>
|
||||
<span className={round}>
|
||||
<Grid3x3 className="w-5 h-5" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AppShellSkeletonProps {
|
||||
surface: SkeletonSurface;
|
||||
/** The page's own "Loading" string (common.loading). */
|
||||
label?: string;
|
||||
/** Rail item drawn as current. */
|
||||
active?: RailApp;
|
||||
/**
|
||||
* Below which width the vertical rail gives way to the bottom bar: mail and
|
||||
* settings switch at 64rem, calendar, contacts, files and plugin pages at 48rem.
|
||||
*/
|
||||
railBelow: "md" | "lg";
|
||||
labels?: RailLabels;
|
||||
/** Whether the Mountain View top bar holds the search field (mail does). */
|
||||
mvSearch?: boolean;
|
||||
/** Extra classes for the row under the top bar (Mountain View card margins etc.). */
|
||||
rowClassName?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page root plus frame. `children` go in the row next to the rail: the page's
|
||||
* own sidebar, list and panes.
|
||||
*
|
||||
* When nothing is stored that could sign the visitor back in, the boot script
|
||||
* marks the page `data-boot-auth="out"` and the login card is drawn instead:
|
||||
* that is where the app is about to send them.
|
||||
*/
|
||||
export function AppShellSkeleton({
|
||||
surface,
|
||||
label,
|
||||
active,
|
||||
railBelow,
|
||||
labels,
|
||||
mvSearch = false,
|
||||
rowClassName,
|
||||
children,
|
||||
}: AppShellSkeletonProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="hidden boot-out:block">
|
||||
<LoginSkeleton label={label} />
|
||||
</div>
|
||||
<SkeletonRegion
|
||||
surface={surface}
|
||||
label={label}
|
||||
className="flex flex-col overflow-hidden pt-[env(safe-area-inset-top)] h-dvh bg-background lg:mv:bg-[var(--mv-canvas)] boot-out:hidden"
|
||||
>
|
||||
<MvTopBarSkeleton search={mvSearch} className="hidden lg:mv:flex" />
|
||||
<div className={cn("flex flex-1 min-h-0 overflow-hidden", rowClassName)}>
|
||||
<RailSkeleton
|
||||
active={active}
|
||||
className={railBelow === "lg" ? "hidden lg:flex lg:mv:hidden" : "hidden md:flex lg:mv:hidden"}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
<BottomBarSkeleton
|
||||
active={active}
|
||||
labels={labels}
|
||||
className={railBelow === "lg" ? "flex lg:hidden" : "flex md:hidden"}
|
||||
/>
|
||||
</SkeletonRegion>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import { toUnicodeEmail } from "@/lib/idn";
|
||||
import { usePluginPages, pluginPageHref, isPluginPageActive } from "@/hooks/use-plugin-pages";
|
||||
import { NotificationBell } from "@/components/notifications/notification-center";
|
||||
import { useBottomControls } from "@/hooks/use-bottom-controls";
|
||||
import { writeRailHints, type RailHint } from "@/lib/boot-skeleton/hints";
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
@@ -348,6 +349,22 @@ export function NavigationRail({
|
||||
|
||||
const visibleItems = navItems.filter((item) => !item.hidden);
|
||||
|
||||
// Leave the next page load's skeleton a note of what this rail shows
|
||||
// (lib/boot-skeleton/hints.ts). Only once a session is up: before that the
|
||||
// rail is still missing the apps the server offers.
|
||||
const quotaShown = !!quota && quota.total > 0;
|
||||
useEffect(() => {
|
||||
if (!client) return;
|
||||
const items: RailHint[] = [];
|
||||
for (const item of navItems) if (item.id !== 'mail' && !item.hidden) items.push(item.id as RailHint);
|
||||
if (isStalwartAdmin) items.push('admin');
|
||||
if (showAccessibilityButton) items.push('a11y');
|
||||
if (quotaShown) items.push('quota');
|
||||
writeRailHints(items);
|
||||
// navItems is rebuilt every render; its inputs are listed instead.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [client, supportsCalendar, calendarEnabled, supportsContacts, contactsEnabled, supportsFiles, filesEnabled, isStalwartAdmin, showAccessibilityButton, quotaShown]);
|
||||
|
||||
const getIsActive = (href: string, itemId: string) => {
|
||||
if (activeAppId) return false;
|
||||
if (onNavigate) {
|
||||
|
||||
@@ -5,10 +5,16 @@ import { useTranslations } from 'next-intl';
|
||||
import { IntlProvider } from '../intl-provider';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
|
||||
const nav = vi.hoisted(() => ({ pathname: null as string | null }));
|
||||
vi.mock('next/navigation', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('next/navigation')>()),
|
||||
usePathname: () => nav.pathname,
|
||||
}));
|
||||
|
||||
// The Lite export leaves the catalog out of the [locale] layout, because the
|
||||
// prop was baked into every exported page and pushed the Stalwart zip past its
|
||||
// size limit. The provider then loads the catalog itself and must render
|
||||
// nothing until it has it: English text would flash on a German page, and the
|
||||
// no text until it has it (only the page's skeleton, which has none): English text would flash on a German page, and the
|
||||
// prerendered HTML has to match the client's first render.
|
||||
|
||||
vi.unmock('next-intl');
|
||||
@@ -20,12 +26,22 @@ function Cancel() {
|
||||
describe('IntlProvider without server messages (Lite)', () => {
|
||||
afterEach(() => {
|
||||
useLocaleStore.setState({ locale: '' });
|
||||
nav.pathname = null;
|
||||
});
|
||||
|
||||
it('prerenders nothing for a non-English locale', () => {
|
||||
expect(renderToString(<IntlProvider locale="de"><Cancel /></IntlProvider>)).toBe('');
|
||||
});
|
||||
|
||||
it('prerenders the page skeleton, without any text, while the catalog is missing', () => {
|
||||
nav.pathname = '/de/calendar';
|
||||
const html = renderToString(<IntlProvider locale="de"><Cancel /></IntlProvider>);
|
||||
expect(html).toContain('data-boot-skeleton="calendar"');
|
||||
expect(html).toContain('aria-busy="true"');
|
||||
expect(html).not.toContain('Cancel');
|
||||
expect(html).not.toContain('role="status"');
|
||||
});
|
||||
|
||||
it('prerenders English straight away from the bundled catalog', () => {
|
||||
expect(renderToString(<IntlProvider locale="en"><Cancel /></IntlProvider>)).toContain('Cancel');
|
||||
});
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { RouteSkeleton } from '@/components/boot-skeleton/route-skeleton';
|
||||
import { surfaceFromPath } from '@/lib/boot-skeleton/surfaces';
|
||||
import { locales } from '@/i18n/routing';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { getBrowserTimeZone, resolveTimeZone } from '@/lib/timezone';
|
||||
@@ -67,6 +71,7 @@ export function IntlProvider({ locale: initialLocale, messages: initialMessages,
|
||||
const timeZoneSetting = useSettingsStore((state) => state.timeZone);
|
||||
const timeZone = resolveTimeZone(timeZoneSetting, browserTimeZone);
|
||||
const plainLanguage = useSettingsStore((state) => state.plainLanguage);
|
||||
const pathname = usePathname();
|
||||
|
||||
// The catalog currently in use. Seeded with the server-provided messages for
|
||||
// the SSR-resolved locale, so the first render needs no async work and
|
||||
@@ -159,7 +164,13 @@ export function IntlProvider({ locale: initialLocale, messages: initialMessages,
|
||||
[baseMessages, plainMessages]
|
||||
);
|
||||
|
||||
if (!catalog) return null;
|
||||
if (!catalog) {
|
||||
// Lite without this locale's catalog yet: no text can be drawn, but the
|
||||
// page's skeleton can (it takes no translations), so the prerendered HTML
|
||||
// still shows the page's shape instead of a blank screen.
|
||||
const surface = pathname ? surfaceFromPath(pathname, locales) : null;
|
||||
return surface ? <RouteSkeleton surface={surface} /> : null;
|
||||
}
|
||||
|
||||
return (
|
||||
<NextIntlClientProvider
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { TARGET_NAMES, getTarget } from '@/lib/connector/registry';
|
||||
import { locales } from '@/i18n/routing';
|
||||
import { CONNECTOR_SURFACES, surfaceFromPath } from '../surfaces';
|
||||
import { BOOT_HINTS_KEY } from '../keys';
|
||||
import { resetRailHintsMemo, writeRailHints } from '../hints';
|
||||
|
||||
describe('connector targets', () => {
|
||||
it('every target the connector can resolve has a skeleton surface', () => {
|
||||
for (const name of TARGET_NAMES) expect(CONNECTOR_SURFACES[name], name).toBeTruthy();
|
||||
expect(Object.keys(CONNECTOR_SURFACES).sort()).toEqual([...TARGET_NAMES].sort());
|
||||
});
|
||||
|
||||
it('the surface matches where the target resolves to', () => {
|
||||
const firstSegment: Record<string, string> = { '': 'mail' };
|
||||
for (const name of TARGET_NAMES) {
|
||||
const target = getTarget(name)!;
|
||||
const params: Record<string, string> = {};
|
||||
for (const [key, spec] of Object.entries(target.params ?? {})) {
|
||||
if (!spec.required) continue;
|
||||
params[key] = spec.type === 'enum' ? spec.values![0] : 'x';
|
||||
}
|
||||
const { path } = target.resolve(params);
|
||||
const seg = path.split('/').filter(Boolean)[0] ?? '';
|
||||
expect(CONNECTOR_SURFACES[name], `${name} -> ${path}`).toBe(firstSegment[seg] ?? seg);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('surfaceFromPath', () => {
|
||||
it.each([
|
||||
['/', 'mail'],
|
||||
['/en', 'mail'],
|
||||
['/de/mail/folder/inbox', 'mail'],
|
||||
['/en/calendar/week/2026-11-04', 'calendar'],
|
||||
['/ar/contacts/abc', 'contacts'],
|
||||
['/en/files/de', 'files'],
|
||||
['/en/apps/p/q', 'apps'],
|
||||
['/en/settings/filters', 'settings'],
|
||||
['/en/pro', 'pro'],
|
||||
['/en/login', 'login'],
|
||||
['/admin', 'admin'],
|
||||
['/admin/logs', 'admin'],
|
||||
['/admin/login', 'admin-login'],
|
||||
['/setup', 'setup'],
|
||||
['/connector/calendar', 'calendar'],
|
||||
['/connector/admin_auth', 'admin'],
|
||||
['/connector/nope', null],
|
||||
['/protocol/mailto', 'mail'],
|
||||
['/protocol/webcal', 'calendar'],
|
||||
['/webmail/de/calendar', 'calendar'],
|
||||
['/en/auth/callback', null],
|
||||
['/en/plugins/oauth/callback', null],
|
||||
])('%s -> %s', (path, surface) => {
|
||||
expect(surfaceFromPath(path, locales)).toBe(surface);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rail hints', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
resetRailHintsMemo();
|
||||
});
|
||||
|
||||
it('stores the rail items with mail first', () => {
|
||||
writeRailHints(['calendar', 'quota']);
|
||||
expect(JSON.parse(localStorage.getItem(BOOT_HINTS_KEY)!)).toEqual({ v: 1, rail: ['mail', 'calendar', 'quota'] });
|
||||
});
|
||||
|
||||
it('skips writing the same value twice', () => {
|
||||
writeRailHints(['files']);
|
||||
localStorage.removeItem(BOOT_HINTS_KEY);
|
||||
writeRailHints(['files']);
|
||||
expect(localStorage.getItem(BOOT_HINTS_KEY)).toBeNull();
|
||||
writeRailHints(['files', 'admin']);
|
||||
expect(JSON.parse(localStorage.getItem(BOOT_HINTS_KEY)!).rail).toEqual(['mail', 'files', 'admin']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { BOOT_HINTS_KEY } from './keys';
|
||||
|
||||
/**
|
||||
* What the rail showed, for the next page load's skeleton: the apps and the
|
||||
* optional footer items whose presence depends on the server (capabilities,
|
||||
* admin rights, quota), which nothing else keeps across a reload. The boot
|
||||
* script turns it into `data-boot-rail` (lib/boot-skeleton/boot-script.ts).
|
||||
*/
|
||||
export type RailHint = 'calendar' | 'contacts' | 'files' | 'admin' | 'a11y' | 'quota';
|
||||
|
||||
let lastWritten: string | null = null;
|
||||
|
||||
export function writeRailHints(items: RailHint[]): void {
|
||||
const rail = ['mail', ...items];
|
||||
const value = JSON.stringify({ v: 1, rail });
|
||||
if (value === lastWritten) return;
|
||||
lastWritten = value;
|
||||
try {
|
||||
localStorage.setItem(BOOT_HINTS_KEY, value);
|
||||
} catch {
|
||||
/* storage full or blocked: the next skeleton draws every app */
|
||||
}
|
||||
}
|
||||
|
||||
/** For tests. */
|
||||
export function resetRailHintsMemo(): void {
|
||||
lastWritten = null;
|
||||
}
|
||||
Reference in New Issue
Block a user