Merge branch 'feat/boot-skeleton'

This commit is contained in:
Linus Rath
2026-11-02 20:28:26 +01:00
3 changed files with 75 additions and 33 deletions
+41 -29
View File
@@ -400,43 +400,55 @@ function MobileHeaderSkeleton() {
* same component): the toolbar, the subject block and the sender block of the
* default reader, then the message body's tinted sheet.
*/
export function MailMessageSkeleton({ className }: { className?: string }) {
/**
* The reader's top toolbar while a message loads. The loaded reader lifts its
* toolbar above the message row on a tablet or desktop (EmailViewer's
* `liftToolbar`); its loading state draws this there too, so the row below
* doesn't move when the message arrives.
*/
export function MailMessageToolbarSkeleton() {
const button = "h-8 w-20 rounded-md";
return (
<div aria-hidden="true" className={cn("flex-1 min-w-0 flex flex-col h-full bg-background overflow-hidden", className)}>
<div className="bg-background flex-shrink-0 border-b border-border">
<div className="px-2 sm:px-4 lg:px-6 flex items-center h-14">
{/* Phones: back, then icon-over-label actions. */}
<div className="sm:hidden flex items-center justify-between gap-0.5 w-full">
<span className="flex items-center justify-center h-9 w-9 -ms-1 flex-shrink-0">
<div className="bg-background flex-shrink-0 border-b border-border">
<div className="px-2 sm:px-4 lg:px-6 flex items-center h-14">
{/* Phones: back, then icon-over-label actions. */}
<div className="sm:hidden flex items-center justify-between gap-0.5 w-full">
<span className="flex items-center justify-center h-9 w-9 -ms-1 flex-shrink-0">
<ArrowLeft className="w-5 h-5" />
</span>
{repeat(6, (i) => (
<span className="flex flex-col items-center gap-1 px-1 flex-1">
<Block className="w-5 h-5 rounded" />
<Bar className="h-2" w={i === 5 ? "3.5rem" : "2.25rem"} />
</span>
))}
</div>
<div className="max-sm:hidden flex items-center justify-between gap-2 w-full">
<div className="flex items-center gap-2">
{/* Back to the list, where the reader has the list's place (tablet, focus). */}
<span className="hidden md:max-lg:flex md:boot-focus:flex items-center justify-center h-9 w-9 -ms-1 flex-shrink-0">
<ArrowLeft className="w-5 h-5" />
</span>
{repeat(6, (i) => (
<span className="flex flex-col items-center gap-1 px-1 flex-1">
<Block className="w-5 h-5 rounded" />
<Bar className="h-2" w={i === 5 ? "3.5rem" : "2.25rem"} />
</span>
))}
<Block className={button} />
<Block className={button} />
<Block className={button} />
</div>
<div className="max-sm:hidden flex items-center justify-between gap-2 w-full">
<div className="flex items-center gap-2">
{/* Back to the list, where the reader has the list's place (tablet, focus). */}
<span className="hidden md:max-lg:flex md:boot-focus:flex items-center justify-center h-9 w-9 -ms-1 flex-shrink-0">
<ArrowLeft className="w-5 h-5" />
</span>
<Block className={button} />
<Block className={button} />
<Block className={button} />
</div>
<div className="flex items-center gap-2">
<Block className={button} />
<Block className={button} />
<Block className={cn(button, "max-md:hidden")} />
<Block className="h-8 w-8 rounded-md" />
</div>
<div className="flex items-center gap-2">
<Block className={button} />
<Block className={button} />
<Block className={cn(button, "max-md:hidden")} />
<Block className="h-8 w-8 rounded-md" />
</div>
</div>
</div>
</div>
);
}
export function MailMessageSkeleton({ className, toolbar = true }: { className?: string; toolbar?: boolean }) {
return (
<div aria-hidden="true" className={cn("flex-1 min-w-0 flex flex-col h-full bg-background overflow-hidden", className)}>
{toolbar ? <MailMessageToolbarSkeleton /> : null}
<div className="bg-background border-b border-border">
<div className="px-4 lg:px-6" style={{ paddingBlock: "var(--density-header-py)" }}>
<div className="flex items-start justify-between gap-2 lg:gap-4">
@@ -131,6 +131,33 @@ describe('the reader toolbar and the plugin side panel', () => {
expect(document.querySelector('[data-plugin-detail-panel]')).toBeTruthy();
});
it('loads a message with its toolbar where the loaded reader puts it', () => {
// The loading state lifts the toolbar above the message row too, so the
// row stays put when the message arrives: the same element at the same
// place (a moved row counts as a layout shift).
const view = (props: { email: Email | null; isLoading: boolean }) => (
<EmailViewer mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onDelete={() => {}} {...props} />
);
const { container, rerender } = render(view({ email: null, isLoading: true }));
const root = container.firstElementChild as HTMLElement;
expect(root.children).toHaveLength(2);
const row = root.children[1];
expect(row.className).toContain('flex-row');
expect(root.children[0].querySelector('.sk-shape')).toBeTruthy();
rerender(view({ email, isLoading: false }));
expect(root.querySelector('[data-viewer-top-toolbar="full-width"]')).toBe(root.children[0]);
expect(root.children[1]).toBe(row);
});
it('loads a message on a phone with the toolbar in the message column', () => {
device.isMobile = true;
const { container } = render(
<EmailViewer email={null} isLoading mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onDelete={() => {}} />,
);
const root = container.firstElementChild as HTMLElement;
expect(root.children).toHaveLength(1);
});
it('keeps the toolbar in the message column on a phone', () => {
device.isMobile = true;
show();
+7 -4
View File
@@ -117,7 +117,7 @@ import {
Pencil,
Paperclip,
} from "@/components/icons";
import { MailMessageSkeleton } from "@/components/boot-skeleton/mail-skeleton";
import { MailMessageSkeleton, MailMessageToolbarSkeleton } from "@/components/boot-skeleton/mail-skeleton";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
@@ -3930,12 +3930,12 @@ ${printWatermark ? printWatermark.html : ''}
)}
</>
);
const shell = (content: React.ReactNode) => (
const shell = (content: React.ReactNode, top: React.ReactNode = null) => (
<div
ref={setViewerRootEl}
className={cn("flex-1 flex flex-col h-full bg-background overflow-hidden relative", className)}
>
{null}
{top}
<div className="flex-1 min-h-0 flex flex-row overflow-hidden relative">
{content}
{sidePanel}
@@ -3947,8 +3947,11 @@ ${printWatermark ? printWatermark.html : ''}
// Show loading skeleton while email is being fetched: the page skeleton's
// message pane, so a linked message keeps the shape it had before the app
// started.
// Its toolbar sits where the loaded reader puts its own: above the message
// row when lifted, so the row doesn't move when the message arrives.
if (isLoading && !email) {
return shell(<MailMessageSkeleton />);
const lifted = toolbarPosition === 'top' && liftToolbar;
return shell(<MailMessageSkeleton toolbar={!lifted} />, lifted ? <MailMessageToolbarSkeleton /> : null);
}
if (!email) {