Settle the form sheets on one anatomy

Field labels no longer lose the space before '(optional)' or set the
colon apart from a unit, and a question label carries no colon after its
question mark. Cancel next to a primary is the outline button in every
form, the illness sheet's selects span the field width, and the reminder
sheet drops a description that repeated the page subtitle. On a phone a
sheet now opens with focus on itself rather than its first field, so a
date or name field no longer raises the keyboard over a sheet nobody has
read yet.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 16:20:42 +02:00
1 parent 0b881fe5b7
commit 03f374794e
15 files changed
+84 -31

No files matched your search

@@ -114,7 +114,7 @@ export function CustomMetricEntryForm({
{onCancel ? (
<Button
type="button"
variant="ghost"
variant="outline"
onClick={onCancel}
disabled={submitting}
>
@@ -137,7 +137,7 @@ export function CustomMetricForm({
{onCancel ? (
<Button
type="button"
variant="ghost"
variant="outline"
onClick={onCancel}
disabled={submitting}
>
+1 -1
View File
@@ -255,7 +255,7 @@ export function LogDaySheet({
description={t("illness.sheet.description")}
footer={
<>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button onClick={handleSave} disabled={upsert.isPending}>
+9 -7
View File
@@ -178,7 +178,7 @@ export function NewEpisodeSheet({
}
footer={
<>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button
@@ -203,9 +203,9 @@ export function NewEpisodeSheet({
</div>
<div className="space-y-1.5">
<Label>{t("illness.new.type")}</Label>
<Label htmlFor="illness-type">{t("illness.new.type")}</Label>
<Select value={type} onValueChange={(v) => setType(v as IllnessType)}>
<SelectTrigger>
<SelectTrigger id="illness-type" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -228,12 +228,14 @@ export function NewEpisodeSheet({
/>
<div className="space-y-1.5">
<Label>{t("illness.new.lifecycle")}</Label>
<Label htmlFor="illness-lifecycle">
{t("illness.new.lifecycle")}
</Label>
<Select
value={lifecycle}
onValueChange={(v) => setLifecycle(v as IllnessLifecycle)}
>
<SelectTrigger>
<SelectTrigger id="illness-lifecycle" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -248,9 +250,9 @@ export function NewEpisodeSheet({
{showParent ? (
<div className="space-y-1.5">
<Label>{t("illness.new.parent")}</Label>
<Label htmlFor="illness-parent">{t("illness.new.parent")}</Label>
<Select value={parentId} onValueChange={setParentId}>
<SelectTrigger>
<SelectTrigger id="illness-parent" className="w-full">
<SelectValue placeholder={t("illness.new.parentNone")} />
</SelectTrigger>
<SelectContent>
+1 -1
View File
@@ -144,7 +144,7 @@ export function BiomarkerForm({
{onCancel ? (
<Button
type="button"
variant="ghost"
variant="outline"
onClick={onCancel}
disabled={submitting}
>
+1 -1
View File
@@ -348,7 +348,7 @@ export function LabForm({
{onCancel ? (
<Button
type="button"
variant="ghost"
variant="outline"
onClick={onCancel}
disabled={submitting}
>
@@ -480,7 +480,6 @@ export function VorsorgeSection({
? "measurementReminders.form.editTitle"
: "measurementReminders.form.createTitle",
)}
description={t("measurementReminders.sectionDescription")}
footer={
<>
<Button type="button" variant="outline" onClick={closeSheet}>
@@ -300,7 +300,9 @@ function UnitLabel({ htmlFor, label }: { htmlFor: string; label: string }) {
const name = match ? match[1] : label;
const unit = match ? match[2] : null;
return (
<Label htmlFor={htmlFor} className="flex items-baseline gap-1">
// `after:-ml-1` pulls the label's colon back against the unit: the colon
// is a flex item of its own here, and the row gap set it apart.
<Label htmlFor={htmlFor} className="flex items-baseline gap-1 after:-ml-1">
<span className="truncate">{name}</span>
{unit ? (
<span className="text-muted-foreground shrink-0 text-xs font-normal">
@@ -773,8 +775,8 @@ export function MeasurementForm({
htmlFor="notes"
label={
<>
{t("measurements.notes")}{" "}
<span className="text-muted-foreground font-normal">
{t("measurements.notes")}
<span className="text-muted-foreground ml-1 font-normal">
({t("common.optional")})
</span>
</>
+5 -4
View File
@@ -353,6 +353,7 @@ export function MoodForm({
doubled heading. */}
<Label
id="mood-level-label"
noColon
className="text-muted-foreground text-sm font-normal"
>
{t("mood.heroQuestion")}
@@ -479,8 +480,8 @@ export function MoodForm({
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="tags">
{t("mood.tags")}{" "}
<span className="text-muted-foreground font-normal">
{t("mood.tags")}
<span className="text-muted-foreground ml-1 font-normal">
({t("common.optional")})
</span>
</Label>
@@ -554,8 +555,8 @@ export function MoodForm({
htmlFor="mood-note"
label={
<>
{t("mood.note")}{" "}
<span className="text-muted-foreground font-normal">
{t("mood.note")}
<span className="text-muted-foreground ml-1 font-normal">
({t("common.optional")})
</span>
</>
+1 -1
View File
@@ -128,7 +128,7 @@ export function AllergyForm({
{onCancel ? (
<Button
type="button"
variant="ghost"
variant="outline"
onClick={onCancel}
disabled={submitting}
>
@@ -114,7 +114,7 @@ export function FamilyHistoryForm({
{onCancel ? (
<Button
type="button"
variant="ghost"
variant="outline"
onClick={onCancel}
disabled={submitting}
>
@@ -85,7 +85,7 @@ export function EditSymptomEventSheet({
title={t("symptoms.section.editEventTitle", { symptom: label })}
footer={
<>
<Button variant="ghost" onClick={onClose}>
<Button variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button onClick={handleSave} disabled={update.isPending}>
@@ -128,8 +128,8 @@ export function EditSymptomEventSheet({
htmlFor="symptom-event-note"
label={
<>
{t("symptoms.entry.note")}{" "}
<span className="text-muted-foreground font-normal">
{t("symptoms.entry.note")}
<span className="text-muted-foreground ml-1 font-normal">
({t("common.optional")})
</span>
</>
@@ -194,7 +194,7 @@ export function EditSymptomDefinitionSheet({
title={t("symptoms.section.editSymptomTitle")}
footer={
<>
<Button variant="ghost" onClick={onClose}>
<Button variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button onClick={handleSave} disabled={update.isPending}>
@@ -347,8 +347,8 @@ export function SymptomEntryForm({
htmlFor={`${formId}-episode`}
label={
<>
{t("symptoms.entry.during")}{" "}
<span className="text-muted-foreground font-normal">
{t("symptoms.entry.during")}
<span className="text-muted-foreground ml-1 font-normal">
({t("common.optional")})
</span>
</>
@@ -376,8 +376,8 @@ export function SymptomEntryForm({
htmlFor={`${formId}-note`}
label={
<>
{t("symptoms.entry.note")}{" "}
<span className="text-muted-foreground font-normal">
{t("symptoms.entry.note")}
<span className="text-muted-foreground ml-1 font-normal">
({t("common.optional")})
</span>
</>
@@ -105,7 +105,7 @@ vi.mock("@/components/ui/dialog", () => ({
),
}));
import { ResponsiveSheet } from "../responsive-sheet";
import { keepKeyboardDownOnOpen, ResponsiveSheet } from "../responsive-sheet";
describe("<ResponsiveSheet>", () => {
it("renders the Dialog branch on `md+` viewports", () => {
@@ -324,3 +324,34 @@ describe("<ResponsiveSheet>", () => {
expect(onOpenChange).toHaveBeenCalledWith(true);
});
});
describe("keepKeyboardDownOnOpen (phone branch open focus)", () => {
function setup(activeInside: boolean) {
const focus = vi.fn();
const preventDefault = vi.fn();
const active = { id: "active" };
const content = {
focus,
contains: (node: unknown) => activeInside && node === active,
};
vi.stubGlobal("document", { activeElement: active });
keepKeyboardDownOnOpen({
preventDefault,
currentTarget: content,
} as unknown as Event);
vi.unstubAllGlobals();
return { focus, preventDefault };
}
it("focuses the sheet itself instead of its first field", () => {
const { focus, preventDefault } = setup(false);
expect(preventDefault).toHaveBeenCalledOnce();
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
});
it("leaves a field the form focused on purpose alone", () => {
const { focus, preventDefault } = setup(true);
expect(preventDefault).toHaveBeenCalledOnce();
expect(focus).not.toHaveBeenCalled();
});
});
+18
View File
@@ -131,6 +131,23 @@ const CONTENT_WIDTH_CLASS: Record<
* the form's chrome is unchanged because both branches expose the
* same header / body / footer slot shape.
*/
/**
* Phone branch only: on open, focus the sheet itself instead of its first
* field. Radix's default lands on the first tabbable element, and when that
* is a text-like field (a date, a name) the phone raises its keyboard over a
* sheet the person has not read yet, and the sheet jumps up under it. Focus
* still moves into the sheet (the content is focusable and traps focus), a
* field a form focuses on purpose (`autoFocus`, a mount effect) keeps it, and
* the desktop dialog keeps the default.
*/
export function keepKeyboardDownOnOpen(event: Event): void {
event.preventDefault();
const content = event.currentTarget as HTMLElement | null;
if (content && !content.contains(document.activeElement)) {
content.focus({ preventScroll: true });
}
}
export function ResponsiveSheet({
open,
onOpenChange,
@@ -153,6 +170,7 @@ export function ResponsiveSheet({
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
onCloseAutoFocus={onCloseAutoFocus}
onOpenAutoFocus={keepKeyboardDownOnOpen}
side="bottom"
showCloseButton={showCloseButton}
data-slot="responsive-sheet-content"