merge: W2 settings/admin chrome consistency into v1.22

This commit is contained in:
Marc-André Bombeck committed 2026-06-27 12:47:07 +02:00
commit 47f8d62ab5
13 files changed
+66 -136

No files matched your search

+1 -1
View File
@@ -316,7 +316,7 @@ export function AdminShell({ active, children }: AdminShellProps) {
aria-label={t("admin.shell.sectionsNav")}
className="hidden md:col-start-1 md:row-start-2 md:block"
>
<div className="sticky top-20">
<div className="no-scrollbar sticky top-6 max-h-[calc(100dvh-5.5rem)] overflow-y-auto">
<ul className="space-y-1">
<li>
<Link
@@ -31,7 +31,7 @@ vi.mock("@/hooks/use-auth", () => ({
}));
import { I18nProvider } from "@/lib/i18n/context";
import { SettingsSectionFrame } from "../settings-section-frame";
import { SettingsSectionFrame } from "./section-frame-harness";
import { CoachSection } from "../coach-section";
function render(locale: "en" | "de" = "en") {
@@ -44,7 +44,7 @@ vi.mock("@/hooks/use-auth", () => ({
}));
import { I18nProvider } from "@/lib/i18n/context";
import { SettingsSectionFrame } from "../settings-section-frame";
import { SettingsSectionFrame } from "./section-frame-harness";
import { ExportSection } from "../export-section";
function render(node: React.ReactElement, locale: "en" | "de" = "en") {
@@ -29,7 +29,7 @@ vi.mock("@/hooks/use-auth", () => ({
}));
import { I18nProvider } from "@/lib/i18n/context";
import { SettingsSectionFrame } from "../settings-section-frame";
import { SettingsSectionFrame } from "./section-frame-harness";
import { GesundheitsakteSection } from "../gesundheitsakte-section";
function render(node: React.ReactElement, locale: "en" | "de" = "en") {
@@ -65,7 +65,7 @@ vi.mock("@/hooks/use-auth", () => ({
}));
import { I18nProvider } from "@/lib/i18n/context";
import { SettingsSectionFrame } from "../settings-section-frame";
import { SettingsSectionFrame } from "./section-frame-harness";
import { MedicationsSection } from "../medications-section";
function render(locale: "en" | "de" = "en"): string {
@@ -131,7 +131,7 @@ vi.mock("@/hooks/use-auth", () => ({
}));
import { I18nProvider } from "@/lib/i18n/context";
import { SettingsSectionFrame } from "../settings-section-frame";
import { SettingsSectionFrame } from "./section-frame-harness";
import { MoodSection } from "../mood-section";
function render(locale: "en" | "de" = "en"): string {
@@ -0,0 +1,45 @@
"use client";
/**
* Test-only harness that reproduces the page heading the live `SettingsShell`
* paints for a given section slug (`settings-shell.tsx` headingBlock). The
* per-section SSR smoke tests render a section body through this harness so
* they can assert the visible heading + its historic `settings-section-<slug>-title`
* id alongside the body — mirroring what a user sees on the route.
*
* Production no longer wraps section bodies in a standalone frame component:
* the shell paints the heading from the slug and each section body is just its
* cards. This harness lives in `__tests__` only and is never bundled.
*/
import * as React from "react";
import { useTranslations } from "@/lib/i18n/context";
import type { SettingsSectionSlug } from "../section-slugs";
export function SettingsSectionFrame({
slug,
children,
}: {
slug: SettingsSectionSlug;
children: React.ReactNode;
}) {
const { t } = useTranslations();
const titleId = `settings-section-${slug}-title`;
return (
<section aria-labelledby={titleId} className="space-y-6">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h1 id={titleId} className="text-2xl font-bold tracking-tight">
{t(`settings.sections.${slug}.title`)}
</h1>
<p className="text-muted-foreground text-sm">
{t(`settings.sections.${slug}.subtitle`)}
</p>
</div>
</div>
{children}
</section>
);
}
@@ -87,7 +87,7 @@ vi.mock("@/components/settings/thresholds-editor-section", () => ({
ThresholdsEditorSection: () => <div data-testid="thresholds-editor" />,
}));
import { I18nProvider } from "@/lib/i18n/context";
import { SettingsSectionFrame } from "../settings-section-frame";
import { SettingsSectionFrame } from "./section-frame-harness";
import type { SettingsSectionSlug } from "../section-slugs";
import { AboutSection } from "../about-section";
import { AccountSection } from "../account-section";
@@ -15,6 +15,7 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { SettingsCard } from "@/components/settings/settings-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { formatDate } from "@/lib/format";
@@ -198,10 +199,7 @@ export function PasskeyListSection({
data-testid="passkeys-mobile-list"
>
{passkeys.map((pk) => (
<li
key={pk.id}
className="bg-card border-border rounded-lg border p-4"
>
<SettingsCard as="li" key={pk.id}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{pk.name}</p>
@@ -267,7 +265,7 @@ export function PasskeyListSection({
</AlertDialogContent>
</AlertDialog>
</div>
</li>
</SettingsCard>
))}
</ul>
{deleteMsg && (
@@ -3,6 +3,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Syringe } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Checkbox } from "@/components/ui/checkbox";
import { useTranslations } from "@/lib/i18n/context";
@@ -66,7 +67,7 @@ export function InjectionSitesCard({
}
return (
<div className="bg-card border-border rounded-lg border p-4">
<SettingsCard>
<SettingsCardHeader
icon={Syringe}
title={t("settings.globalExcludedInjectionSitesLabel")}
@@ -84,6 +85,6 @@ export function InjectionSitesCard({
</label>
))}
</div>
</div>
</SettingsCard>
);
}
+7 -8
View File
@@ -8,8 +8,8 @@ import {
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { SettingsCard } from "./settings-card";
/**
* v1.17.1 (F-2) — the one "Layout & Personalization" home.
@@ -53,19 +53,18 @@ const LAYOUT_LINKS: ReadonlyArray<LayoutLink> = [
export function LayoutSection() {
const { t } = useTranslations();
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
// `<SettingsSectionFrame>` in the route; this body is the hub link list.
// The visible heading + subtitle are painted by `SettingsShell` from the
// section slug; this body is the hub link list.
return (
<ul className="space-y-3">
{LAYOUT_LINKS.map((link) => {
const Icon = link.icon;
return (
<li key={link.href}>
<Link
<SettingsCard
as={Link}
href={link.href}
className={cn(
"bg-card border-border hover:bg-accent/40 group flex items-center gap-4 rounded-xl border p-4 transition-colors sm:p-5",
)}
className="hover:bg-accent/40 group flex items-center gap-4 transition-colors"
>
<Icon
className="text-muted-foreground h-5 w-5 shrink-0"
@@ -81,7 +80,7 @@ export function LayoutSection() {
className="text-muted-foreground/60 group-hover:text-foreground h-4 w-4 shrink-0 transition-colors"
aria-hidden="true"
/>
</Link>
</SettingsCard>
</li>
);
})}
@@ -1,113 +0,0 @@
"use client";
/**
* `<SettingsSectionFrame>` — the canonical page-frame for every Settings
* section. The settings twin of the admin renderer's `SectionFrame`
* (`src/app/admin/[section]/renderer.tsx`): it emits the one visible
* heading + subheading every page must show, in the reference style used by
* the Admin Konsole → Übersicht landing.
*
* Before this primitive every settings section hand-rolled its own
* `sr-only` `<h1>`, so no settings page showed a visible heading and the
* left-menu-to-first-card alignment drifted page to page. Routing all 19
* sections through one frame fixes both: a constant-height heading block
* above the first card, and a real visible `h1 + subtitle`.
*
* <SettingsSectionFrame slug="account">
* {cards}
* </SettingsSectionFrame>
*
* Title comes from `settings.sections.<slug>.title`; subtitle from
* `settings.sections.<slug>.subtitle`. The `<h1>` keeps the historic
* `settings-section-<slug>-title` id so the section's own `aria-labelledby`
* linkage — and the spotlight-tour anchors that reference it — stay intact.
*
* `topSlot` renders ABOVE the heading (used by the Layout-hub child
* editors for their "← back to hub" link, which must sit above the title).
* `headingAccessory` renders inline at the top-right of the heading row
* (used by Gesundheitsakte for its guided-tour re-entry trigger).
*/
import * as React from "react";
import { useTranslations } from "@/lib/i18n/context";
import type { SettingsSectionSlug } from "./section-slugs";
interface SettingsSectionFrameBaseProps {
/** Optional content rendered above the heading (e.g. a back-to-hub link). */
topSlot?: React.ReactNode;
/** Optional content rendered inline at the top-right of the heading row
* (e.g. a guided-tour re-entry trigger). */
headingAccessory?: React.ReactNode;
children: React.ReactNode;
}
/**
* Slug mode (the 19 shell sections): title + subtitle come from
* `settings.sections.<slug>.{title,subtitle}` and the `<h1>` keeps the
* historic `settings-section-<slug>-title` id for `aria-labelledby` and
* spotlight-tour anchors.
*/
interface SettingsSectionFrameSlugProps extends SettingsSectionFrameBaseProps {
slug: SettingsSectionSlug;
}
/**
* Explicit mode (the per-module settings pages — Vorsorge / Illness / Labs —
* which deliberately stay out of the slug registry because their static
* routes win over `/settings/[section]`): caller passes resolved strings and
* an optional heading id. This lets `ModuleSettingsFrame` delegate here so
* every settings page shares one heading frame.
*/
interface SettingsSectionFrameExplicitProps extends SettingsSectionFrameBaseProps {
title: string;
subtitle: string;
/** Heading element id (defaults to a stable slug-less id). */
titleId?: string;
}
export type SettingsSectionFrameProps =
| SettingsSectionFrameSlugProps
| SettingsSectionFrameExplicitProps;
function isSlugProps(
props: SettingsSectionFrameProps,
): props is SettingsSectionFrameSlugProps {
return "slug" in props;
}
export function SettingsSectionFrame(props: SettingsSectionFrameProps) {
const { t } = useTranslations();
const { topSlot, headingAccessory, children } = props;
let title: string;
let subtitle: string;
let titleId: string;
if (isSlugProps(props)) {
titleId = `settings-section-${props.slug}-title`;
title = t(`settings.sections.${props.slug}.title`);
subtitle = t(`settings.sections.${props.slug}.subtitle`);
} else {
titleId = props.titleId ?? "settings-section-title";
title = props.title;
subtitle = props.subtitle;
}
return (
<section aria-labelledby={titleId} className="space-y-6">
{topSlot ? <div>{topSlot}</div> : null}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h1 id={titleId} className="text-2xl font-bold tracking-tight">
{title}
</h1>
<p className="text-muted-foreground text-sm">{subtitle}</p>
</div>
{headingAccessory ? (
<div className="shrink-0">{headingAccessory}</div>
) : null}
</div>
{children}
</section>
);
}
+1 -1
View File
@@ -502,7 +502,7 @@ export function SettingsShell({
aria-label={t("settings.shell.sectionsNav")}
className="hidden md:col-start-1 md:row-start-2 md:block"
>
<div className="sticky top-20">
<div className="no-scrollbar sticky top-6 max-h-[calc(100dvh-5.5rem)] overflow-y-auto">
<ul className="space-y-1">
{visibleSections.map((section) => {
const isActive = section.slug === activeSlug;