From ff583104a8293416dcbf7d1a7ccbbca89164d641 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 13:15:28 +0200 Subject: [PATCH] fix(ui): wrap lab names and token names between words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lab list wrapped a long analyte name with overflow-wrap: anywhere, which cut a word that would have fit on the next line in the middle (Hämo|globin). It now wraps between words and hyphenates in the page language, cutting only a word wider than the column. Token and client names in the API and MCP settings used break-all for the same reason and now wrap the same way; addresses and codes keep break-all. --- .../documents/sources/segmented-choice.tsx | 110 ------------------ .../labs/__tests__/lab-list-columns.test.tsx | 6 +- src/components/labs/lab-biomarker-chart.tsx | 26 ++--- src/components/labs/lab-list.tsx | 8 +- .../medications/detail/segmented-toggle.tsx | 66 ----------- src/components/settings/api-section.tsx | 4 +- src/components/settings/mcp-section.tsx | 4 +- src/components/timeline/segmented.tsx | 100 ---------------- src/components/ui/view-toggle.tsx | 78 ------------- 9 files changed, 27 insertions(+), 375 deletions(-) delete mode 100644 src/components/documents/sources/segmented-choice.tsx delete mode 100644 src/components/medications/detail/segmented-toggle.tsx delete mode 100644 src/components/timeline/segmented.tsx delete mode 100644 src/components/ui/view-toggle.tsx diff --git a/src/components/documents/sources/segmented-choice.tsx b/src/components/documents/sources/segmented-choice.tsx deleted file mode 100644 index a140cfa5e..000000000 --- a/src/components/documents/sources/segmented-choice.tsx +++ /dev/null @@ -1,110 +0,0 @@ -"use client"; - -/** - * A small segmented radio group (Paperless-ngx | Papra) with the keyboard - * contract of a radio group: one tab stop (the checked option), arrow keys - * move and select, Home and End jump to the ends. Shared by the document - * picker's source switch and the settings card's system switch. - */ -import { useRef, type KeyboardEvent, type ReactNode } from "react"; - -import { cn } from "@/lib/utils"; - -/** - * The option an arrow, Home or End key moves to from `index`, wrapping at the - * ends; null for any other key. - */ -export function nextChoiceIndex( - key: string, - index: number, - count: number, -): number | null { - if (count <= 0) return null; - switch (key) { - case "ArrowRight": - case "ArrowDown": - return (index + 1) % count; - case "ArrowLeft": - case "ArrowUp": - return (index - 1 + count) % count; - case "Home": - return 0; - case "End": - return count - 1; - default: - return null; - } -} - -export function SegmentedChoice({ - options, - value, - onChange, - label, - disabled = false, - renderOption, - slot, - className, -}: { - options: readonly T[]; - value: T; - onChange: (value: T) => void; - /** Accessible name of the group. */ - label: string; - disabled?: boolean; - renderOption: (option: T) => ReactNode; - slot?: string; - className?: string; -}) { - const refs = useRef>([]); - - const onKeyDown = (event: KeyboardEvent, i: number) => { - const next = nextChoiceIndex(event.key, i, options.length); - if (next === null) return; - event.preventDefault(); - if (disabled) return; - onChange(options[next]); - refs.current[next]?.focus(); - }; - - return ( -
- {options.map((option, i) => { - const checked = option === value; - return ( - - ); - })} -
- ); -} diff --git a/src/components/labs/__tests__/lab-list-columns.test.tsx b/src/components/labs/__tests__/lab-list-columns.test.tsx index 4b570c544..3b3f0e0ea 100644 --- a/src/components/labs/__tests__/lab-list-columns.test.tsx +++ b/src/components/labs/__tests__/lab-list-columns.test.tsx @@ -169,10 +169,14 @@ describe(" columns", () => { "lg:overflow-visible", "lg:whitespace-normal", "lg:text-clip", - "lg:[overflow-wrap:anywhere]", + "lg:break-words", + "lg:hyphens-auto", ]) { expect(classes).toContain(c); } + // `anywhere` breaks inside a word that would have fit on the next line. + expect(classes).not.toContain("lg:[overflow-wrap:anywhere]"); + expect(classes).not.toContain("lg:break-all"); }); }); diff --git a/src/components/labs/lab-biomarker-chart.tsx b/src/components/labs/lab-biomarker-chart.tsx index 01cca67b8..8287662d4 100644 --- a/src/components/labs/lab-biomarker-chart.tsx +++ b/src/components/labs/lab-biomarker-chart.tsx @@ -14,7 +14,6 @@ import { YAxis, } from "recharts"; -import { Button } from "@/components/ui/button"; import { CHART_HEIGHT_PX } from "@/lib/charts/constants"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { formatReferenceRange } from "@/lib/labs/reference-range"; @@ -34,6 +33,7 @@ import { import { dateKeyOfInstant } from "../day/day-url"; import type { LabResultDto } from "./types"; import { useLabNumber } from "./use-lab-format"; +import { Segmented } from "@/components/ui/segmented"; /** * v1.18.1 — per-biomarker trend chart, in the dashboard's visual language. @@ -251,19 +251,17 @@ export function LabBiomarkerChart({ return (
-
- {RANGE_DAYS.map((r) => ( - - ))} +
+ ({ + value: r.key, + label: t(`labs.chart.range.${r.key}`), + }))} + value={range} + onChange={setRange} + label={t("charts.rangeLabel")} + slot="lab-chart-range" + />
{points.length === 0 ? ( diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index da19953c0..d3219e0cd 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -254,10 +254,14 @@ export function LabList({
{/* One line with an ellipsis on a phone; from `lg` the name wraps instead, so a long one (the schema allows 120 - characters) always reads in full. */} + characters) always reads in full. It wraps between + words and hyphenates in the page language; a word is + only cut when it is wider than the column on its own + (`anywhere` cut "Hämoglobin" in the middle of the + word whenever the column was a little short). */} {group.analyte} diff --git a/src/components/medications/detail/segmented-toggle.tsx b/src/components/medications/detail/segmented-toggle.tsx deleted file mode 100644 index bac02195d..000000000 --- a/src/components/medications/detail/segmented-toggle.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client"; - -import { cn } from "@/lib/utils"; - -/** - * Small two-plus-segment text toggle for medication-detail surfaces. Mirrors - * the icon-only `MedicationViewToggle` chrome (bordered pill group, filled - * active segment, ghost inactive) but carries text labels. `aria-pressed` - * announces the state per segment; the group is named for screen readers. - * Segments keep the 44-px mobile tap floor, shrinking to the 36-px control - * height on desktop — matching the floor `ViewToggle` and the sibling - * intake / history-rail controls raised in this release window (L3, - * `.planning/audits/2026-07-18-qa-ui.md`). - */ -export interface SegmentedToggleOption { - value: T; - label: string; -} - -interface SegmentedToggleProps { - value: T; - options: SegmentedToggleOption[]; - onChange: (value: T) => void; - ariaLabel: string; - dataSlot?: string; -} - -export function SegmentedToggle({ - value, - options, - onChange, - ariaLabel, - dataSlot, -}: SegmentedToggleProps) { - return ( -
- {options.map(({ value: optionValue, label }) => { - const active = value === optionValue; - return ( - - ); - })} -
- ); -} diff --git a/src/components/settings/api-section.tsx b/src/components/settings/api-section.tsx index 13f42ef6f..fa0edc26b 100644 --- a/src/components/settings/api-section.tsx +++ b/src/components/settings/api-section.tsx @@ -657,7 +657,7 @@ function ApiTokensCard() { className="bg-muted/30 border-border space-y-2 rounded-lg border p-3" >
-

+

{tok.name}

{isExpired ? ( @@ -803,7 +803,7 @@ function ApiTokensCard() { key={tok.id} className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3" > -

+

{tok.name}

diff --git a/src/components/settings/mcp-section.tsx b/src/components/settings/mcp-section.tsx index 40b90787d..bc21fcb4f 100644 --- a/src/components/settings/mcp-section.tsx +++ b/src/components/settings/mcp-section.tsx @@ -148,7 +148,7 @@ function McpConnectionsCard() { className="bg-muted/30 border-border space-y-2 rounded-lg border p-3" >

-

+

{conn.clientName}

@@ -475,7 +475,7 @@ function McpTokensCard() { className="bg-muted/30 border-border space-y-2 rounded-lg border p-3" >
-

+

{tok.name}

{isExpired ? ( diff --git a/src/components/timeline/segmented.tsx b/src/components/timeline/segmented.tsx deleted file mode 100644 index 47033830d..000000000 --- a/src/components/timeline/segmented.tsx +++ /dev/null @@ -1,100 +0,0 @@ -"use client"; - -/** - * A small segmented choice (zoom, chronicle grouping, life-event precision): - * an ARIA radiogroup with one tab stop and arrow keys, through the shared - * roving helper. Neutral palette; the chosen segment is raised, not coloured. - * - * Inside a form sheet it renders as toggle buttons (`aria-pressed`) instead: - * the sheets' discard guard reads any checked radio as the person's input, - * and a precision that starts on "Day" is a default, not an answer. - */ -import { Fragment, type ReactElement, type ReactNode } from "react"; - -import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group"; -import { cn } from "@/lib/utils"; - -export interface SegmentedOption { - value: T; - label: string; -} - -export function Segmented({ - options, - value, - onChange, - label, - slot, - className, - stretch = false, - asToggleButtons = false, - wrapOption, -}: { - options: ReadonlyArray>; - value: T; - onChange: (next: T) => void; - label: string; - slot: string; - className?: string; - stretch?: boolean; - asToggleButtons?: boolean; - /** - * Wraps one segment's button, for a popover anchored to it (the range - * segment). Returns the button itself for every other segment. - */ - wrapOption?: (value: T, button: ReactElement) => ReactNode; -}) { - const selectedIndex = options.findIndex((o) => o.value === value); - const { getRadioProps } = useRovingRadioGroup({ - count: options.length, - selectedIndex, - onSelect: (index) => onChange(options[index].value), - }); - return ( -
- {options.map((option, index) => { - const checked = option.value === value; - const button = ( - - ); - return wrapOption ? ( - - {wrapOption(option.value, button)} - - ) : ( - button - ); - })} -
- ); -} diff --git a/src/components/ui/view-toggle.tsx b/src/components/ui/view-toggle.tsx deleted file mode 100644 index ed5c5aec6..000000000 --- a/src/components/ui/view-toggle.tsx +++ /dev/null @@ -1,78 +0,0 @@ -"use client"; - -import type { LucideIcon } from "lucide-react"; - -import { cn } from "@/lib/utils"; - -export interface ViewToggleSegment { - value: T; - label: string; - icon: LucideIcon; -} - -export interface ViewToggleProps { - view: T; - onChange: (view: T) => void; - segments: ViewToggleSegment[]; - /** Accessible name for the segmented-control group. */ - groupLabel: string; - /** `data-slot` prefix per segment, rendered as `${dataSlotPrefix}-${value}`. */ - dataSlotPrefix: string; -} - -/** - * Shared icon-only segmented view toggle (e.g. cards vs table/list). A small - * two-segment control: the active segment carries the filled surface, the - * inactive one stays ghost. `aria-pressed` announces the state per segment; - * the group is named for screen readers. Segments keep the 44-px mobile tap - * floor (`size-11`), shrinking to the 36-px control height on desktop - * (`sm:size-9`) like a neighbouring Add button — the same floor the intake - * kebab and history-rail delete controls raised in this release window - * (L3, `.planning/audits/2026-07-18-qa-ui.md`). - * - * Previously forked between the medications module - * (`medications/medication-view-toggle.tsx`) and the Vorsorge/Illness/Labs - * settings pages (`module-list/module-view-toggle.tsx`) — verbatim - * copy-paste that only diverged in icons + i18n keys and had already - * drifted. Both are now thin wrappers over this one primitive. - */ -export function ViewToggle({ - view, - onChange, - segments, - groupLabel, - dataSlotPrefix, -}: ViewToggleProps) { - return ( -
- {segments.map(({ value, label, icon: Icon }) => { - const active = view === value; - return ( - - ); - })} -
- ); -}