mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
The add and edit surfaces could be scrolled sideways and the time picker sat past the right edge: measurements, mood, the medication intake editor and the cycle log sheet all showed it, so the cause is in what they share. Two mechanisms, both in shared primitives. DateField and TimeField wrap a text input. A flex item's min-width:auto resolves to a content-based minimum, and for a text input that is its intrinsic size attribute — 20 characters, about 180px. Neither field could render below ~230px whatever width the parent offered, so the date + time row demanded ~470px inside a 400px dialog body and pushed the clock button 58px past the edge. min-w-0 on the field box and on the overlay input releases that floor; the row now fits at every width from 360px up. The scrolling body only declares overflow-y. CSS promotes the other axis from visible to auto, so every deliberate outward bleed in the tree turned the form into a pannable surface — SheetSection's trigger row was 8px wider than its own container for no benefit (a focus ring already draws outside the box), and Switch's hit-target pseudo reaches 13px past the track. The bodies and the dialog surface now state overflow-x explicitly, the section trigger no longer bleeds, and the dialog's grid column is pinned so an over-wide child cannot drag the surface out with it. Measured before and after on the four reported surfaces at 360 / 390 / 430 / 768 / 1280 / 1440: the date/time row spilled 83px on every width from 640px up and none of it remains. An e2e spec asserts the two harms directly — the surface is not a horizontal scroll container, and no focusable control lies outside its visible box — with a canary that proves the reachability check can fail.
120 lines
4.6 KiB
TypeScript
120 lines
4.6 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { ChevronDown } from "lucide-react";
|
|
import { Collapsible as CollapsiblePrimitive } from "radix-ui";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
/**
|
|
* v1.17.0 — `SheetSection` is the collapsible disclosure primitive that
|
|
* powers the sectioned single-sheet add flows (mood, cycle). Each section
|
|
* carries a title, an optional leading icon, and a `summary` slot that
|
|
* communicates what is selected inside it while collapsed (e.g. a count
|
|
* badge) — so a closed section never hides its contents silently.
|
|
*
|
|
* Built on Radix Collapsible. The chevron rotates and the content height
|
|
* animates open/closed via the `tw-animate-css` `collapsible-down/up`
|
|
* keyframes; both motions are suppressed under `prefers-reduced-motion`.
|
|
* Matches the shadcn new-york + zinc token language used across the app.
|
|
*/
|
|
|
|
interface SheetSectionProps {
|
|
/** Section heading shown on the always-visible trigger row. */
|
|
title: string;
|
|
/**
|
|
* A node rendered on the trigger row's trailing edge — typically a count
|
|
* badge that reports what is selected inside while the section is
|
|
* collapsed. Stays visible whether the section is open or closed.
|
|
*/
|
|
summary?: React.ReactNode;
|
|
/** Optional leading icon (a Lucide component or any node). */
|
|
icon?: React.ReactNode;
|
|
/** Whether the section starts expanded (uncontrolled). Defaults to closed. */
|
|
defaultOpen?: boolean;
|
|
/** Controlled open state. When set, pair with `onOpenChange`. */
|
|
open?: boolean;
|
|
/** Controlled open-state change handler. */
|
|
onOpenChange?: (open: boolean) => void;
|
|
/** Optional className merged onto the section wrapper. */
|
|
className?: string;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
export function SheetSection({
|
|
title,
|
|
summary,
|
|
icon,
|
|
defaultOpen = false,
|
|
open,
|
|
onOpenChange,
|
|
className,
|
|
children,
|
|
}: SheetSectionProps) {
|
|
return (
|
|
<CollapsiblePrimitive.Root
|
|
// Controlled when `open` is provided, otherwise uncontrolled.
|
|
{...(open === undefined ? { defaultOpen } : { open, onOpenChange })}
|
|
data-slot="sheet-section"
|
|
className={cn("border-border/60 border-t pt-3", className)}
|
|
>
|
|
<CollapsiblePrimitive.Trigger
|
|
data-slot="sheet-section-trigger"
|
|
// `w-full`, not `-mx-1 w-[calc(100%+0.5rem)] px-1`. The old pair held
|
|
// the row's text at the section's own left edge while pushing the box
|
|
// 4 px outward on each side, purely to hold the focus ring off the
|
|
// label — but a Tailwind ring is drawn OUTSIDE the border box anyway,
|
|
// so the bleed bought nothing and made every row 8 px wider than the
|
|
// sheet body that scrolls it. In the desktop dialog, whose body has no
|
|
// horizontal padding to absorb it, that surfaced as a sideways-
|
|
// scrollable form on the cycle and mood sheets.
|
|
className="group focus-visible:ring-ring/50 flex w-full items-center gap-2 rounded-md py-1.5 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
>
|
|
{icon ? (
|
|
<span
|
|
className="text-muted-foreground flex h-4 w-4 shrink-0 items-center justify-center [&>svg]:h-4 [&>svg]:w-4"
|
|
aria-hidden="true"
|
|
>
|
|
{icon}
|
|
</span>
|
|
) : null}
|
|
<span className="text-foreground flex-1 text-sm font-medium">
|
|
{title}
|
|
</span>
|
|
{summary ? (
|
|
<span data-slot="sheet-section-summary" className="flex items-center">
|
|
{summary}
|
|
</span>
|
|
) : null}
|
|
<ChevronDown
|
|
aria-hidden="true"
|
|
className="text-muted-foreground h-4 w-4 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-180 motion-reduce:transition-none"
|
|
/>
|
|
</CollapsiblePrimitive.Trigger>
|
|
<CollapsiblePrimitive.Content
|
|
data-slot="sheet-section-content"
|
|
className="data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down overflow-hidden motion-reduce:animate-none"
|
|
>
|
|
<div className="space-y-3 pt-3 pb-1">{children}</div>
|
|
</CollapsiblePrimitive.Content>
|
|
</CollapsiblePrimitive.Root>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A compact "(N)" count badge for a `SheetSection` summary slot. Renders
|
|
* nothing when `count <= 0`, so a section with no selections shows a bare
|
|
* chevron rather than a "(0)".
|
|
*/
|
|
export function SheetSectionCount({ count }: { count: number }) {
|
|
if (count <= 0) return null;
|
|
return (
|
|
<span
|
|
data-slot="sheet-section-count"
|
|
className="bg-primary/15 text-primary inline-flex min-w-5 items-center justify-center rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums"
|
|
>
|
|
{count}
|
|
</span>
|
|
);
|
|
}
|