mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge remote-tracking branch 'origin/main' into fix/v1397-derive
This commit is contained in:
commit
533b75fdcf
70 files changed
+4167
-566
No files matched your search
@@ -125,7 +125,7 @@ export function AppLogPreviewSection() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
aria-label={t("admin.section.app-logs.refresh")}
|
||||
@@ -133,13 +133,12 @@ export function AppLogPreviewSection() {
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""} motion-reduce:animate-none`}
|
||||
/>
|
||||
<p className="text-sm">
|
||||
{t("admin.section.app-logs.processDetail")}
|
||||
</p>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<p className="text-sm">{t("admin.section.app-logs.processDetail")}</p>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Input
|
||||
type="search"
|
||||
|
||||
@@ -163,7 +163,10 @@ export function LabBiomarkerChart({
|
||||
const min = Math.min(...candidates);
|
||||
const max = Math.max(...candidates);
|
||||
const pad = (max - min || Math.abs(max) || 1) * 0.1;
|
||||
return [min - pad, max + pad];
|
||||
// A reference window that starts at 0 (LDL "0–130") must not drag the
|
||||
// axis below zero: a concentration cannot be negative, and a "-13" tick
|
||||
// reads as a value. The pad only stays below zero when the data already is.
|
||||
return [min >= 0 ? Math.max(0, min - pad) : min - pad, max + pad];
|
||||
}, [points, lowerBound, upperBound, sourceBand]);
|
||||
|
||||
const animate = !prefersReducedMotion();
|
||||
|
||||
@@ -368,6 +368,10 @@ export function LabForm({
|
||||
variant="outline"
|
||||
data-action="save-and-add-another"
|
||||
disabled={submitting}
|
||||
// Three buttons do not fit one phone-width row: the secondary save
|
||||
// takes a row of its own above Cancel + Save instead of pushing
|
||||
// Cancel off the left edge of the sheet.
|
||||
className="max-md:order-first max-md:w-full"
|
||||
>
|
||||
{pendingAction === "saveAndAddAnother" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
|
||||
@@ -311,103 +311,148 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
});
|
||||
}, [readings, sortBy, sortDir]);
|
||||
|
||||
// One action pair per reading, shared by the phone list and the table.
|
||||
const renderActions = (r: LabResultDto) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{canManageLabs && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
// v1.18.10 (W10) — 44px touch target on mobile (WCAG
|
||||
// 2.5.5), compact 36px on desktop.
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => void openEdit(r)}
|
||||
aria-label={t("labs.editReading")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<DeleteButton
|
||||
domain="labs"
|
||||
onConfirm={() => deleteMutation.mutate(r.id)}
|
||||
title={t("labs.deleteConfirmTitle")}
|
||||
description={t("labs.deleteConfirmDescription")}
|
||||
confirmLabel={t("labs.deleteReading")}
|
||||
className="size-11 sm:size-9"
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<SortableHead
|
||||
column="value"
|
||||
label={t("labs.form.value")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<SortableHead
|
||||
column="takenAt"
|
||||
label={t("labs.form.takenAt")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<SortableHead
|
||||
column="range"
|
||||
label={t("labs.referenceLabel")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<SortableHead
|
||||
column="note"
|
||||
label={t("labs.hasNote")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<TableHead className="w-20 pr-4 text-right">
|
||||
<span className="sr-only">{t("labs.editReading")}</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ordered.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-semibold tabular-nums">
|
||||
<div className="space-y-2">
|
||||
<span>{formatLabReading(r)}</span>
|
||||
<LabReferenceRangeBar
|
||||
value={r.value}
|
||||
referenceLow={r.referenceLow}
|
||||
referenceHigh={r.referenceHigh}
|
||||
unit={r.unit}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
|
||||
{formatDateShort(r.takenAt, true)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<ReferenceRangeBadge status={r.rangeStatus} />
|
||||
<SourceRangeNote
|
||||
reading={r}
|
||||
className="text-muted-foreground text-xs"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{r.hasNote ? t("labs.hasNote") : ""}
|
||||
</TableCell>
|
||||
<TableCell className="pr-4 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{canManageLabs && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
// v1.18.10 (W10) — 44px touch target on mobile (WCAG
|
||||
// 2.5.5), compact 36px on desktop.
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => void openEdit(r)}
|
||||
aria-label={t("labs.editReading")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<DeleteButton
|
||||
domain="labs"
|
||||
onConfirm={() => deleteMutation.mutate(r.id)}
|
||||
title={t("labs.deleteConfirmTitle")}
|
||||
description={t("labs.deleteConfirmDescription")}
|
||||
confirmLabel={t("labs.deleteReading")}
|
||||
className="size-11 sm:size-9"
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Phones get one stacked row per reading. The table needs ~510 px for
|
||||
its five columns, so on a phone it panned sideways inside its
|
||||
container and parked the edit / delete buttons off-screen, while the
|
||||
range bar's end labels were clipped by the same container. */}
|
||||
<ul
|
||||
data-slot="lab-history-mobile-list"
|
||||
className="divide-border divide-y lg:hidden"
|
||||
>
|
||||
{ordered.map((r) => (
|
||||
<li key={r.id} className="flex items-start gap-3 py-3">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="font-semibold tabular-nums">
|
||||
{formatLabReading(r)}
|
||||
</div>
|
||||
<LabReferenceRangeBar
|
||||
value={r.value}
|
||||
referenceLow={r.referenceLow}
|
||||
referenceHigh={r.referenceHigh}
|
||||
unit={r.unit}
|
||||
/>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
|
||||
<span className="whitespace-nowrap">
|
||||
{formatDateShort(r.takenAt, true)}
|
||||
</span>
|
||||
<ReferenceRangeBadge status={r.rangeStatus} />
|
||||
{r.hasNote ? (
|
||||
<span className="text-xs">{t("labs.hasNote")}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<SourceRangeNote
|
||||
reading={r}
|
||||
className="text-muted-foreground text-xs"
|
||||
/>
|
||||
</div>
|
||||
{renderActions(r)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div data-slot="lab-history-table" className="max-lg:hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<SortableHead
|
||||
column="value"
|
||||
label={t("labs.form.value")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<SortableHead
|
||||
column="takenAt"
|
||||
label={t("labs.form.takenAt")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<SortableHead
|
||||
column="range"
|
||||
label={t("labs.referenceLabel")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<SortableHead
|
||||
column="note"
|
||||
label={t("labs.hasNote")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<TableHead className="w-20 pr-4 text-right">
|
||||
<span className="sr-only">{t("labs.editReading")}</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ordered.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-semibold tabular-nums">
|
||||
<div className="space-y-2">
|
||||
<span>{formatLabReading(r)}</span>
|
||||
<LabReferenceRangeBar
|
||||
value={r.value}
|
||||
referenceLow={r.referenceLow}
|
||||
referenceHigh={r.referenceHigh}
|
||||
unit={r.unit}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
|
||||
{formatDateShort(r.takenAt, true)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<ReferenceRangeBadge status={r.rangeStatus} />
|
||||
<SourceRangeNote
|
||||
reading={r}
|
||||
className="text-muted-foreground text-xs"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{r.hasNote ? t("labs.hasNote") : ""}
|
||||
</TableCell>
|
||||
<TableCell className="pr-4 text-right">
|
||||
{renderActions(r)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<ResponsiveSheet
|
||||
open={editingId !== null}
|
||||
|
||||
@@ -80,7 +80,7 @@ function groupReadings(results: LabResultDto[]): MarkerGroup[] {
|
||||
|
||||
function LabRangeBarSlot({ reading }: { reading: LabResultDto }) {
|
||||
return (
|
||||
<div className="w-48">
|
||||
<div className={cn("w-full lg:w-48", MOBILE_CELL.rangeBar)}>
|
||||
<LabReferenceRangeBar
|
||||
value={reading.value}
|
||||
referenceLow={reading.referenceLow}
|
||||
@@ -91,6 +91,35 @@ function LabRangeBarSlot({ reading }: { reading: LabResultDto }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The compact list is a five-column table from `lg` up: name and reading, range
|
||||
* badge, range bar, trend, chevron. Below that the same five cells fold into
|
||||
* two lines per row, because the fixed columns alone (badge + 12rem bar + 72px
|
||||
* trend + gaps) are wider than a phone, and the only flexible column — the
|
||||
* name — was the one that gave way: it collapsed to nothing and the row
|
||||
* panned sideways off the card.
|
||||
*
|
||||
* Phone layout, per row:
|
||||
*
|
||||
* name / reading · date badge ›
|
||||
* range bar trend
|
||||
*
|
||||
* The placement classes are all `max-lg:` so the desktop table keeps exactly
|
||||
* the subgrid it had.
|
||||
*/
|
||||
const ROW_CLASS =
|
||||
"grid min-w-0 grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-x-3 gap-y-2 px-4 py-2.5 lg:col-span-full lg:grid-cols-subgrid lg:gap-y-0";
|
||||
|
||||
const MOBILE_CELL = {
|
||||
reading: "max-lg:col-start-1 max-lg:row-start-1",
|
||||
badge: "max-lg:col-start-2 max-lg:row-start-1 max-lg:justify-self-end",
|
||||
rangeBar: "max-lg:col-start-1 max-lg:row-start-2 max-lg:empty:hidden",
|
||||
trend:
|
||||
"max-lg:col-start-2 max-lg:row-start-2 max-lg:justify-self-end max-lg:empty:hidden",
|
||||
end: "max-lg:col-start-3 max-lg:row-start-1",
|
||||
notLinked: "max-lg:col-span-full max-lg:row-start-3",
|
||||
} as const;
|
||||
|
||||
export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
const { t } = useTranslations();
|
||||
const { canWriteDomain } = useRecordCapabilities();
|
||||
@@ -212,12 +241,15 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
</p>
|
||||
) : null}
|
||||
<Card>
|
||||
<CardContent className="divide-border grid grid-cols-[minmax(0,1fr)_max-content_12rem_72px_auto] gap-x-3 divide-y p-0">
|
||||
<CardContent className="divide-border grid grid-cols-1 divide-y p-0 lg:grid-cols-[minmax(0,1fr)_max-content_12rem_72px_auto] lg:gap-x-3">
|
||||
{groups.map((group) => {
|
||||
const inner = (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={cn("min-w-0 flex-1", MOBILE_CELL.reading)}>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
|
||||
<span className="truncate font-medium">
|
||||
<span
|
||||
data-slot="lab-list-analyte"
|
||||
className="truncate font-medium"
|
||||
>
|
||||
{group.analyte}
|
||||
</span>
|
||||
</div>
|
||||
@@ -234,47 +266,57 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
<Link
|
||||
key={group.key}
|
||||
href={`/labs/${group.biomarkerId}`}
|
||||
className="hover:bg-muted/40 col-span-full grid min-w-0 grid-cols-subgrid items-center px-4 py-2.5 transition-colors"
|
||||
className={cn(
|
||||
ROW_CLASS,
|
||||
"hover:bg-muted/40 transition-colors",
|
||||
)}
|
||||
>
|
||||
{inner}
|
||||
<ReferenceRangeBadge
|
||||
status={group.latest.rangeStatus}
|
||||
compact
|
||||
className="w-full"
|
||||
className={cn("lg:w-full", MOBILE_CELL.badge)}
|
||||
/>
|
||||
<LabRangeBarSlot reading={group.latest} />
|
||||
<div className="w-[72px]">
|
||||
<div className={cn("w-[72px]", MOBILE_CELL.trend)}>
|
||||
<LabTrendSparkline
|
||||
values={group.readings.map((reading) => reading.value)}
|
||||
referenceLow={group.latest.referenceLow}
|
||||
referenceHigh={group.latest.referenceHigh}
|
||||
/>
|
||||
</div>
|
||||
<ChevronRight className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"text-muted-foreground h-4 w-4 shrink-0",
|
||||
MOBILE_CELL.end,
|
||||
)}
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
// Un-linked group (not backfilled to a catalog biomarker):
|
||||
// render inert but say so, so it doesn't read as a broken
|
||||
// link next to its clickable neighbours.
|
||||
<div
|
||||
key={group.key}
|
||||
className="col-span-full grid min-w-0 grid-cols-subgrid items-center px-4 py-2.5"
|
||||
>
|
||||
<div key={group.key} className={ROW_CLASS}>
|
||||
{inner}
|
||||
<ReferenceRangeBadge
|
||||
status={group.latest.rangeStatus}
|
||||
compact
|
||||
className="w-full"
|
||||
className={cn("lg:w-full", MOBILE_CELL.badge)}
|
||||
/>
|
||||
<LabRangeBarSlot reading={group.latest} />
|
||||
<div className="w-[72px]">
|
||||
<div className={cn("w-[72px]", MOBILE_CELL.trend)}>
|
||||
<LabTrendSparkline
|
||||
values={group.readings.map((reading) => reading.value)}
|
||||
referenceLow={group.latest.referenceLow}
|
||||
referenceHigh={group.latest.referenceHigh}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted-foreground shrink-0 text-xs italic">
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground shrink-0 text-xs italic",
|
||||
MOBILE_CELL.notLinked,
|
||||
)}
|
||||
>
|
||||
{t("labs.notLinkedYet")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -129,9 +129,17 @@ export function OcrRowEditor({
|
||||
{fieldError("analyte", "labs.ocr.errorAnalyte")}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{row.biomarkerMatch === "existing" ? (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
// The chip repeats the analyte, so a long panel name ran past
|
||||
// the row on a phone; it truncates inside the row instead.
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-muted-foreground max-w-full"
|
||||
title={t("labs.ocr.linksExisting", { name: row.analyte })}
|
||||
>
|
||||
<Link2 aria-hidden />
|
||||
{t("labs.ocr.linksExisting", { name: row.analyte })}
|
||||
<span className="truncate">
|
||||
{t("labs.ocr.linksExisting", { name: row.analyte })}
|
||||
</span>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
|
||||
@@ -89,7 +89,12 @@ export function SettingsCardHeader({
|
||||
{titleAccessory}
|
||||
</div>
|
||||
{status ? (
|
||||
<div className="flex shrink-0 items-center gap-2">{status}</div>
|
||||
// `max-w-full` caps the slot at the row once it wraps under the
|
||||
// title, so a status line longer than a phone row wraps inside
|
||||
// the card instead of widening the page.
|
||||
<div className="flex max-w-full shrink-0 items-center gap-2">
|
||||
{status}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{/* One sentence, one text run. This slot deliberately carries no
|
||||
|
||||
@@ -294,7 +294,7 @@ function AddProviderControl({
|
||||
const [picked, setPicked] = useState<string>(addable[0] ?? "");
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<NativeSelect
|
||||
aria-label={t("settings.ai.providerChain.addProvider")}
|
||||
value={picked}
|
||||
|
||||
@@ -869,7 +869,7 @@ function SortableWidgetRow({
|
||||
style={style}
|
||||
data-slot="widget-row"
|
||||
data-dragging={isDragging ? "true" : undefined}
|
||||
className={`border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2 ${
|
||||
className={`border-border bg-background/30 flex min-h-12 flex-wrap items-center gap-2 rounded-md border px-3 py-2 sm:flex-nowrap ${
|
||||
isDragging ? "ring-primary z-10 opacity-90 shadow-lg ring-2" : ""
|
||||
}`}
|
||||
>
|
||||
@@ -898,7 +898,12 @@ function SortableWidgetRow({
|
||||
<span className="flex-1 truncate text-sm" title={labels.widgetLabel}>
|
||||
{labels.widgetLabel}
|
||||
</span>
|
||||
<div className="flex w-12 justify-center">
|
||||
{/* On a phone the two switches and two arrows (~250 px) left the name
|
||||
five characters ("Gewic…"). Below `sm` the controls wrap onto a
|
||||
second line, right-aligned under the column heads, and the name
|
||||
keeps the first line to itself. */}
|
||||
<span className="basis-full sm:hidden" aria-hidden="true" />
|
||||
<div className="flex w-12 justify-center max-sm:ml-auto">
|
||||
<Switch
|
||||
checked={tileChecked}
|
||||
onCheckedChange={(v) => onToggleTile(widget.id, v)}
|
||||
|
||||
@@ -234,8 +234,6 @@ export function LowStockCard({
|
||||
disabled={!isAuthenticated || saving}
|
||||
aria-label={t("notifications.lowStock.toggleAria")}
|
||||
/>
|
||||
<p className="text-sm">{t("notifications.lowStock.detail")}</p>
|
||||
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{enabled
|
||||
? t("notifications.lowStock.statusOn")
|
||||
@@ -244,6 +242,7 @@ export function LowStockCard({
|
||||
</label>
|
||||
}
|
||||
/>
|
||||
<p className="text-sm">{t("notifications.lowStock.detail")}</p>
|
||||
{enabled && (
|
||||
<div className="flex min-h-11 items-center gap-3">
|
||||
<label htmlFor="low-stock-days" className="text-sm font-medium">
|
||||
|
||||
@@ -193,8 +193,6 @@ export function MoodReminderCard({
|
||||
disabled={!isAuthenticated || saving}
|
||||
aria-label={t("notifications.moodReminder.toggleAria")}
|
||||
/>
|
||||
<p className="text-sm">{t("notifications.moodReminder.detail")}</p>
|
||||
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{enabled
|
||||
? t("notifications.moodReminder.statusOn")
|
||||
@@ -203,6 +201,7 @@ export function MoodReminderCard({
|
||||
</label>
|
||||
}
|
||||
/>
|
||||
<p className="text-sm">{t("notifications.moodReminder.detail")}</p>
|
||||
{enabled && (
|
||||
<div className="flex min-h-11 items-center gap-3">
|
||||
<label htmlFor="mood-reminder-hour" className="text-sm font-medium">
|
||||
|
||||
@@ -112,8 +112,11 @@ export function ReportScopePicker({
|
||||
{/* The one-click way to a complete report. An inline action row
|
||||
(UI-STANDARDS §11): the sentence wraps, the action stays beside it.
|
||||
It is offered, never applied — and it stays offered so a person can
|
||||
come back to the standard set after experimenting. */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
come back to the standard set after experimenting. On a phone the
|
||||
action's own label is wider than half the row, so beside it the
|
||||
sentence shrank to a one-word column and the button ran past the
|
||||
card edge: there the action drops under the sentence instead. */}
|
||||
<div className="flex items-start justify-between gap-3 max-sm:flex-col">
|
||||
<p
|
||||
id={hintId}
|
||||
className="text-muted-foreground min-w-0 flex-1 text-xs"
|
||||
|
||||
@@ -26,6 +26,15 @@ import { cn } from "@/lib/utils";
|
||||
* shells hand-rolled the whole block to get the second copy, which is how
|
||||
* they drifted apart from every module page in the first place.
|
||||
*/
|
||||
/**
|
||||
* A one-word German title ("Krankheitstagebuch") is wider than the column a
|
||||
* phone leaves beside the actions, and without a break opportunity it ran
|
||||
* underneath them. `hyphens-auto` breaks it by the page's `lang`; the
|
||||
* `wrap-break-word` floor covers a word the hyphenation dictionary does not.
|
||||
*/
|
||||
const HEADING_CLASS =
|
||||
"min-w-0 text-2xl font-bold tracking-tight hyphens-auto wrap-break-word";
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
titleId,
|
||||
@@ -34,6 +43,7 @@ export function PageHeader({
|
||||
backLink,
|
||||
topSlot,
|
||||
actions,
|
||||
stackActionsOnPhone = false,
|
||||
className,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
@@ -44,37 +54,65 @@ export function PageHeader({
|
||||
/** Optional block above the title (e.g. a hub back-link the shell owns). */
|
||||
topSlot?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
/**
|
||||
* Below `sm`, move the actions onto their own row under the description.
|
||||
* For text-labelled actions (two outline links, say): beside the title they
|
||||
* left the H1 a column narrower than its own word. Icon actions fit beside
|
||||
* the title and leave this off.
|
||||
*/
|
||||
stackActionsOnPhone?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const heading =
|
||||
headingAs === "div" ? (
|
||||
<div
|
||||
id={titleId}
|
||||
role="heading"
|
||||
aria-level={1}
|
||||
className="text-2xl font-bold tracking-tight"
|
||||
>
|
||||
<div id={titleId} role="heading" aria-level={1} className={HEADING_CLASS}>
|
||||
{title}
|
||||
</div>
|
||||
) : (
|
||||
<h1 id={titleId} className="text-2xl font-bold tracking-tight">
|
||||
<h1 id={titleId} className={HEADING_CLASS}>
|
||||
{title}
|
||||
</h1>
|
||||
);
|
||||
|
||||
// Title, description and actions share one grid. On a phone the
|
||||
// description spans the full row under the title + actions: a header with
|
||||
// three or four icon actions otherwise left the sentence a ~130 px column
|
||||
// that wrapped five or six times. From `sm` up the actions span both rows,
|
||||
// so the description sits beside them exactly as before.
|
||||
return (
|
||||
<div className={cn("space-y-1.5", className)}>
|
||||
{backLink ? <BackLink {...backLink} /> : null}
|
||||
{topSlot}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
{heading}
|
||||
{description ? (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
data-slot="page-header-row"
|
||||
className={cn(
|
||||
"grid items-start gap-x-3 gap-y-1.5",
|
||||
actions ? "grid-cols-[minmax(0,1fr)_auto]" : "grid-cols-1",
|
||||
actions && stackActionsOnPhone && "max-sm:grid-cols-1",
|
||||
)}
|
||||
>
|
||||
{heading}
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 items-center gap-2">{actions}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"col-start-2 row-start-1 flex shrink-0 items-center gap-2",
|
||||
description && "sm:row-span-2",
|
||||
stackActionsOnPhone &&
|
||||
"max-sm:col-span-full max-sm:col-start-1 max-sm:row-start-3 max-sm:flex-wrap",
|
||||
)}
|
||||
>
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
{description ? (
|
||||
<p
|
||||
className={cn(
|
||||
"text-muted-foreground text-sm",
|
||||
actions && "col-span-full sm:col-span-1",
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user