Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .design-sync/config.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/design-system/adoption-manifest.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -1562,6 +1562,7 @@
"tests/header-scroll-hide-contract.test.ts",
"tests/image-lightbox-geometry.test.ts",
"tests/in-page-nav-route-sections.dom.test.tsx",
"tests/mobile-sheet-safe-area-contract.test.ts",
"tests/mode-nav.dom.test.tsx",
"tests/rag-answer-fallback.test.ts",
"tests/settings-dialog-actions.dom.test.tsx",
Expand Down
2 changes: 1 addition & 1 deletion src/components/calculators/calculator-sheet.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -104,7 +104,7 @@ export function CalculatorSheet({
onClick={onClose}
className="absolute inset-0 animate-overlay-in bg-[color:var(--neutral-950)]/55 backdrop-blur-[2px]"
/>
<div className="relative flex max-h-[92dvh] w-full animate-sheet-up flex-col overflow-hidden rounded-t-xl border border-[color:var(--border-strong)] bg-[color:var(--background)] shadow-[var(--shadow-lux)] sm:max-w-3xl sm:animate-dialog-rise sm:rounded-xl">
<div className="relative flex max-h-[calc(100dvh-max(0.75rem,var(--safe-area-top)))] w-full animate-sheet-up flex-col overflow-hidden rounded-t-xl border border-[color:var(--border-strong)] bg-[color:var(--background)] shadow-[var(--shadow-lux)] sm:max-h-[92dvh] sm:max-w-3xl sm:animate-dialog-rise sm:rounded-xl">
<header className="modal-landscape-container grid shrink-0 grid-cols-[auto_minmax(0,1fr)_auto_auto] items-center gap-3 border-b border-[color:var(--border)] bg-[color:var(--surface)] py-3">
<span className="grid size-9 shrink-0 place-items-center rounded-md border border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]">
<Icon className="size-icon-md" aria-hidden="true" />
Expand Down
1 change: 1 addition & 0 deletions src/components/clinical-dashboard/ClinicalSidebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -184,6 +184,7 @@ function MoreModesMenu({
returnFocusRef={triggerRef}
mobilePlacement="bottom"
mobileSize="content"
mobileHeaderSafeArea="padding"
testId="sidebar-more-modes-sheet"
contentClassName="max-h-[calc(100dvh-0.5rem)] sm:max-w-md"
bodyClassName="p-2"
Expand Down
8 changes: 6 additions & 2 deletions src/components/clinical-dashboard/account-setup-dialog.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -104,6 +104,7 @@ export function AccountSetupDialog({
onClose={onClose}
title="Account setup"
closeLabel="Close account setup"
mobileHeaderSafeArea="offset"
headerClassName="absolute right-3 top-3 z-30 w-auto border-0 bg-transparent p-0 sm:right-4 sm:top-4 sm:p-0"
titleClassName="sr-only"
bodyClassName="bg-[color:var(--surface)] p-0 sm:p-0"
Expand DownExpand Up@@ -197,7 +198,7 @@ function AccountOrientationPanel() {
<section
aria-labelledby="account-workspace-benefits"
data-testid="account-storage-summary"
className="relative isolate flex min-h-0 flex-col overflow-hidden bg-[color:var(--clinical-accent-soft)] px-5 pb-5 pt-6 sm:px-8 sm:py-8 lg:min-h-[36rem] lg:px-10 lg:py-10"
className="relative isolate flex min-h-0 flex-col overflow-hidden bg-[color:var(--clinical-accent-soft)] px-5 pb-5 pt-[max(1.5rem,var(--safe-area-top))] sm:px-8 sm:py-8 lg:min-h-[36rem] lg:px-10 lg:py-10"
>
<span
aria-hidden="true"
Expand All@@ -209,7 +210,10 @@ function AccountOrientationPanel() {
/>

<div className="relative z-10 pr-12">
<span className="grid size-tap place-items-center rounded-xl border border-[color:var(--border-lux)] bg-[color:var(--surface)] shadow-[var(--e2),var(--shadow-inset)]">
<span
data-testid="account-workspace-mark"
className="grid size-tap place-items-center rounded-xl border border-[color:var(--border-lux)] bg-[color:var(--surface)] shadow-[var(--e2),var(--shadow-inset)]"
>
<BrandMark className="h-8 w-8" />
</span>
<h3
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -2401,6 +2401,7 @@ export function MasterSearchHeader({
portal
mobilePlacement="bottom"
mobileSize="content"
mobileHeaderSafeArea="padding"
testId="app-mode-menu-sheet"
contentClassName="max-h-[calc(100dvh-0.75rem)] rounded-t-3xl bg-[color:var(--surface-lux)] sm:max-w-md sm:rounded-2xl"
bodyClassName="bg-[color:var(--surface-lux)] px-2.5 pb-2 pt-0.5"
Expand Down
14 changes: 14 additions & 0 deletions src/components/ui/sheet.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@ import {
} from "@/components/ui/sheet-focus";

export type SheetMobileSize = "content" | "viewport";
export type SheetMobileHeaderSafeArea = "none" | "padding" | "offset";

type SheetAccessibleName =
| { title: string; labelledBy?: string; ariaLabel?: string }
Expand DownExpand Up@@ -63,6 +64,13 @@ type SheetBaseProps = {
placement?: "default" | "left";
mobilePlacement?: "bottom" | "top" | "fullscreen";
mobileSize?: SheetMobileSize;
/**
* Keeps the Sheet-owned header controls below the phone top safe area.
* Fullscreen sheets default to `padding`; near-full bottom sheets must opt in
* because short bottom sheets should not inherit a notch-sized empty band.
* Use `offset` only for an absolutely positioned header.
*/
mobileHeaderSafeArea?: SheetMobileHeaderSafeArea;
portal?: boolean;
desktopBackdropClassName?: string;
testId?: string;
Expand DownExpand Up@@ -112,6 +120,7 @@ export function Sheet({
placement = "default",
mobilePlacement = "bottom",
mobileSize = "content",
mobileHeaderSafeArea,
portal = true,
desktopBackdropClassName,
testId,
Expand DownExpand Up@@ -351,6 +360,7 @@ export function Sheet({
const defaultSheetIsFullscreen = placement !== "left" && mobilePlacement === "fullscreen";
const defaultSheetIsTopAligned = placement !== "left" && mobilePlacement === "top";
const defaultSheetUsesViewportSize = placement !== "left" && mobileSize === "viewport";
const resolvedMobileHeaderSafeArea = mobileHeaderSafeArea ?? (defaultSheetIsFullscreen ? "padding" : "none");
const contentClassTokens = contentClassName?.split(/\s+/) ?? [];
const hasMobileMaxHeight = contentClassTokens.some((token) => /^!?max-h-/.test(token));
const hasSmallScreenMaxHeight = contentClassTokens.some((token) => /^sm:!?max-h-/.test(token));
Expand DownExpand Up@@ -388,6 +398,7 @@ export function Sheet({
ref={panelRef}
id={id}
data-testid={testId}
data-mobile-header-safe-area={resolvedMobileHeaderSafeArea}
role="dialog"
aria-modal="true"
aria-labelledby={resolvedLabelledBy}
Expand DownExpand Up@@ -448,9 +459,12 @@ export function Sheet({
</div>
{title ? (
<div
data-sheet-header="true"
className={cn(
"flex items-center justify-between gap-3 border-b border-[color:var(--border)] p-4 sm:p-5",
headerClassName,
resolvedMobileHeaderSafeArea === "padding" && "pt-[max(1rem,var(--safe-area-top))] sm:pt-5",
resolvedMobileHeaderSafeArea === "offset" && "top-[max(0.75rem,var(--safe-area-top))] sm:top-4",
)}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
Expand Down
31 changes: 31 additions & 0 deletions tests/mobile-sheet-safe-area-contract.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
import { readFileSync } from "node:fs";

import { describe, expect, it } from "vitest";

function source(relativePath: string) {
return readFileSync(new URL(`../${relativePath}`, import.meta.url), "utf8");
}

describe("mobile sheet top safe-area ownership", () => {
it("routes fullscreen and near-full Sheet headers through the shared contract", () => {
const sheet = source("src/components/ui/sheet.tsx");
const accountSetup = source("src/components/clinical-dashboard/account-setup-dialog.tsx");
const sidebar = source("src/components/clinical-dashboard/ClinicalSidebar.tsx");
const masterHeader = source("src/components/clinical-dashboard/master-search-header.tsx");

expect(sheet).toContain('mobileHeaderSafeArea ?? (defaultSheetIsFullscreen ? "padding" : "none")');
expect(sheet).toContain('"pt-[max(1rem,var(--safe-area-top))] sm:pt-5"');
expect(sheet).toContain('"top-[max(0.75rem,var(--safe-area-top))] sm:top-4"');
expect(accountSetup).toContain('mobileHeaderSafeArea="offset"');
expect(accountSetup).toContain("pt-[max(1.5rem,var(--safe-area-top))]");
expect(sidebar).toContain('mobileHeaderSafeArea="padding"');
expect(masterHeader).toContain('mobileHeaderSafeArea="padding"');
});

it("keeps the standalone calculator sheet below the unsafe top band on phones", () => {
const calculatorSheet = source("src/components/calculators/calculator-sheet.tsx");

expect(calculatorSheet).toContain("max-h-[calc(100dvh-max(0.75rem,var(--safe-area-top)))]");
expect(calculatorSheet).toContain("sm:max-h-[92dvh]");
});
});
57 changes: 57 additions & 0 deletions tests/sheet.dom.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -236,6 +236,63 @@ describe("Sheet stacked-overlay coordination", () => {
expect(classes).not.toContain("sm:max-h-[88dvh]");
});

it("protects fullscreen and opted-in near-full headers without padding short bottom sheets", () => {
const { rerender } = render(
<Sheet open onClose={vi.fn()} title="Fullscreen" mobilePlacement="fullscreen">
<p>Body</p>
</Sheet>,
);

let dialog = screen.getByRole("dialog");
let header = dialog.querySelector<HTMLElement>('[data-sheet-header="true"]');
expect(dialog).toHaveAttribute("data-mobile-header-safe-area", "padding");
expect(header).not.toBeNull();
expect(header!.classList).toContain("pt-[max(1rem,var(--safe-area-top))]");

rerender(
<Sheet open onClose={vi.fn()} title="Short bottom sheet">
<p>Body</p>
</Sheet>,
);
dialog = screen.getByRole("dialog");
header = dialog.querySelector<HTMLElement>('[data-sheet-header="true"]');
expect(dialog).toHaveAttribute("data-mobile-header-safe-area", "none");
expect(header!.classList).not.toContain("pt-[max(1rem,var(--safe-area-top))]");

rerender(
<Sheet open onClose={vi.fn()} title="Near-full bottom sheet" mobileHeaderSafeArea="padding">
<p>Body</p>
</Sheet>,
);
dialog = screen.getByRole("dialog");
header = dialog.querySelector<HTMLElement>('[data-sheet-header="true"]');
expect(dialog).toHaveAttribute("data-mobile-header-safe-area", "padding");
expect(header!.classList).toContain("pt-[max(1rem,var(--safe-area-top))]");
expect(header!.classList).toContain("sm:pt-5");
});

it("offsets an absolutely positioned header below the phone safe area", () => {
render(
<Sheet
open
onClose={vi.fn()}
title="Overlay header"
mobileHeaderSafeArea="offset"
headerClassName="absolute right-3 top-3 p-0 sm:right-4 sm:top-4"
>
<p>Body</p>
</Sheet>,
);

const dialog = screen.getByRole("dialog");
const header = dialog.querySelector<HTMLElement>('[data-sheet-header="true"]');
expect(dialog).toHaveAttribute("data-mobile-header-safe-area", "offset");
expect(header).not.toBeNull();
expect(header!.classList).toContain("top-[max(0.75rem,var(--safe-area-top))]");
expect(header!.classList).toContain("sm:top-4");
expect(header!.classList).not.toContain("top-3");
});

it("keeps the dialog mounted in production when the title resolves empty", () => {
vi.stubEnv("NODE_ENV", "production");
try {
Expand Down
37 changes: 36 additions & 1 deletion tests/ui-smoke.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -907,6 +907,19 @@ function accountSetupDialog(page: Page) {
return page.getByRole("dialog", { name: "Account setup" });
}

async function expectControlsBelowPhoneTopSafeArea(page: Page, controls: Locator[]) {
const safeAreaTop = await page.evaluate(() =>
Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--safe-area-top")),
);
expect(safeAreaTop).toBeGreaterThan(0);

for (const control of controls) {
const bounds = await control.boundingBox();
expect(bounds).not.toBeNull();
expect(bounds!.y).toBeGreaterThanOrEqual(safeAreaTop);
}
}

async function expectAccountSettingsSurface(settings: Locator) {
await expect(settings.getByRole("heading", { name: "Account & app" })).toBeVisible();
await expect(settings.getByRole("heading", { name: "Account", exact: true })).toBeVisible();
Expand DownExpand Up@@ -1587,6 +1600,9 @@ test.describe("Clinical KB UI smoke coverage", () => {
await settings.getByRole("button", { name: "Close settings" }).click();
await expect(settings).toBeHidden();
await page.setViewportSize({ width: 390, height: 820 });
await page.evaluate(() => {
document.documentElement.style.setProperty("--safe-area-top", "59px");
});

const escapeMenu = await openMobileClinicalGuideMenu(page);
await escapeMenu.getByRole("button", { name: "Settings", exact: true }).click();
Expand All@@ -1601,14 +1617,26 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expectAccountSetupSurface(setup);
await expectAccountProviderLayout(setup, "stack");
await expect(setup.getByLabel("Work email")).toBeFocused();
const setupClose = setup.getByRole("button", { name: "Close account setup" });
const workspaceMark = setup.getByTestId("account-workspace-mark");
await expectControlsBelowPhoneTopSafeArea(page, [setupClose, workspaceMark]);
const setupBox = await setup.boundingBox();
expect(setupBox).not.toBeNull();
expect(setupBox!.x).toBeGreaterThanOrEqual(-1);
expect(setupBox!.width + fullscreenTolerance).toBeLessThanOrEqual(viewport.width + fullscreenTolerance);
await expectNoPageHorizontalOverflow(page);

for (const viewportSize of [
{ width: 320, height: 700 },
{ width: 430, height: 820 },
{ width: 639, height: 820 },
]) {
await page.setViewportSize(viewportSize);
await expectControlsBelowPhoneTopSafeArea(page, [setupClose, workspaceMark]);
await expectNoPageHorizontalOverflow(page);
}

await page.setViewportSize({ width: 320, height: 700 });
const setupClose = setup.getByRole("button", { name: "Close account setup" });
const setupEmail = setup.getByLabel("Work email");
await setupEmail.scrollIntoViewIfNeeded();
await expect(setupEmail).toBeInViewport();
Expand DownExpand Up@@ -1739,6 +1767,9 @@ test.describe("Clinical KB UI smoke coverage", () => {

const appModeTrigger = page.getByRole("button", { name: "Mode Answer" });
await waitForReactEventHandler(appModeTrigger, "onClick");
await page.evaluate(() => {
document.documentElement.style.setProperty("--safe-area-top", "59px");
});
await appModeTrigger.click();

const modeSheet = page.getByTestId("app-mode-menu-sheet");
Expand All@@ -1748,6 +1779,10 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expect(appModeMenu).toBeVisible();
await expect(appModeTrigger).toHaveAttribute("aria-expanded", "true");
await expect(appModeTrigger).toHaveAttribute("aria-controls", "app-mode-menu");
await expectControlsBelowPhoneTopSafeArea(page, [
modeSheet.getByRole("heading", { name: "Choose mode" }),
modeSheet.getByRole("button", { name: "Close mode menu" }),
]);

// The full catalogue remains in one radio menu, but the phone presentation
// now groups it into the three clinical jobs clinicians scan for first.
Expand Down
Loading