fix(ui): wrap lab names and token names between words

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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 13:15:28 +02:00
1 parent 60be63bcd1
commit ff583104a8
9 files changed
+27 -375

No files matched your search

@@ -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<T extends string>({
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<Array<HTMLButtonElement | null>>([]);
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, 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 (
<div
role="radiogroup"
aria-label={label}
aria-disabled={disabled || undefined}
className={cn(
"bg-muted inline-flex self-start rounded-md p-1",
className,
)}
>
{options.map((option, i) => {
const checked = option === value;
return (
<button
key={option}
ref={(el) => {
refs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={checked}
tabIndex={checked ? 0 : -1}
disabled={disabled}
onClick={() => onChange(option)}
onKeyDown={(event) => onKeyDown(event, i)}
data-slot={slot}
className={cn(
"flex min-h-9 items-center gap-1.5 rounded-sm px-3 text-sm font-medium",
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
checked
? "bg-background text-foreground shadow-xs"
: "text-muted-foreground hover:text-foreground",
)}
>
{renderOption(option)}
</button>
);
})}
</div>
);
}
@@ -169,10 +169,14 @@ describe("<LabList> 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");
});
});
+12 -14
View File
@@ -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 (
<div className="space-y-3">
<div className="flex justify-end gap-1">
{RANGE_DAYS.map((r) => (
<Button
key={r.key}
size="sm"
variant={range === r.key ? "secondary" : "ghost"}
aria-pressed={range === r.key}
className="h-7 px-2 text-xs"
onClick={() => setRange(r.key)}
>
{t(`labs.chart.range.${r.key}`)}
</Button>
))}
<div className="flex justify-end">
<Segmented
options={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"
/>
</div>
{points.length === 0 ? (
+6 -2
View File
@@ -254,10 +254,14 @@ export function LabList({
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
{/* 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). */}
<span
data-slot="lab-list-analyte"
className="truncate font-medium lg:overflow-visible lg:[overflow-wrap:anywhere] lg:text-clip lg:whitespace-normal"
className="truncate font-medium lg:overflow-visible lg:break-words lg:text-clip lg:hyphens-auto lg:whitespace-normal"
>
{group.analyte}
</span>
@@ -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<T extends string> {
value: T;
label: string;
}
interface SegmentedToggleProps<T extends string> {
value: T;
options: SegmentedToggleOption<T>[];
onChange: (value: T) => void;
ariaLabel: string;
dataSlot?: string;
}
export function SegmentedToggle<T extends string>({
value,
options,
onChange,
ariaLabel,
dataSlot,
}: SegmentedToggleProps<T>) {
return (
<div
role="group"
aria-label={ariaLabel}
className="border-border bg-background/30 inline-flex shrink-0 items-center rounded-md border p-0.5"
{...(dataSlot ? { "data-slot": dataSlot } : {})}
>
{options.map(({ value: optionValue, label }) => {
const active = value === optionValue;
return (
<button
key={optionValue}
type="button"
onClick={() => {
if (!active) onChange(optionValue);
}}
aria-pressed={active}
data-slot={`${dataSlot ?? "segment"}-${optionValue}`}
className={cn(
"focus-visible:ring-ring inline-flex min-h-11 items-center justify-center rounded-[5px] px-3 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none sm:min-h-9",
active
? "bg-secondary text-secondary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{label}
</button>
);
})}
</div>
);
}
+2 -2
View File
@@ -657,7 +657,7 @@ function ApiTokensCard() {
className="bg-muted/30 border-border space-y-2 rounded-lg border p-3"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-sm font-medium break-all">
<p className="min-w-0 flex-1 text-sm font-medium break-words">
{tok.name}
</p>
{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"
>
<p className="text-sm font-medium break-all">
<p className="text-sm font-medium break-words">
{tok.name}
</p>
<p className="text-muted-foreground text-xs">
+2 -2
View File
@@ -148,7 +148,7 @@ function McpConnectionsCard() {
className="bg-muted/30 border-border space-y-2 rounded-lg border p-3"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-sm font-medium break-all">
<p className="min-w-0 flex-1 text-sm font-medium break-words">
{conn.clientName}
</p>
<Badge className="bg-success/15 text-success text-xs">
@@ -475,7 +475,7 @@ function McpTokensCard() {
className="bg-muted/30 border-border space-y-2 rounded-lg border p-3"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-sm font-medium break-all">
<p className="min-w-0 flex-1 text-sm font-medium break-words">
{tok.name}
</p>
{isExpired ? (
-100
View File
@@ -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<T extends string> {
value: T;
label: string;
}
export function Segmented<T extends string>({
options,
value,
onChange,
label,
slot,
className,
stretch = false,
asToggleButtons = false,
wrapOption,
}: {
options: ReadonlyArray<SegmentedOption<T>>;
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 (
<div
role={asToggleButtons ? "group" : "radiogroup"}
aria-label={label}
data-slot={slot}
className={cn(
"bg-muted inline-flex items-center gap-1 rounded-lg p-1",
stretch && "flex w-full",
className,
)}
>
{options.map((option, index) => {
const checked = option.value === value;
const button = (
<button
key={option.value}
type="button"
{...(asToggleButtons
? { "aria-pressed": checked }
: {
role: "radio",
"aria-checked": checked,
...getRadioProps(index),
})}
data-value={option.value}
onClick={() => onChange(option.value)}
className={cn(
"focus-visible:ring-ring/50 inline-flex min-h-9 items-center justify-center rounded-md px-3 text-sm font-medium outline-none focus-visible:ring-2",
stretch && "flex-1",
checked
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
</button>
);
return wrapOption ? (
<Fragment key={option.value}>
{wrapOption(option.value, button)}
</Fragment>
) : (
button
);
})}
</div>
);
}
-78
View File
@@ -1,78 +0,0 @@
"use client";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
export interface ViewToggleSegment<T extends string> {
value: T;
label: string;
icon: LucideIcon;
}
export interface ViewToggleProps<T extends string> {
view: T;
onChange: (view: T) => void;
segments: ViewToggleSegment<T>[];
/** 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<T extends string>({
view,
onChange,
segments,
groupLabel,
dataSlotPrefix,
}: ViewToggleProps<T>) {
return (
<div
role="group"
aria-label={groupLabel}
className="border-border bg-background/30 inline-flex shrink-0 items-center rounded-md border p-0.5"
>
{segments.map(({ value, label, icon: Icon }) => {
const active = view === value;
return (
<button
key={value}
type="button"
onClick={() => {
if (!active) onChange(value);
}}
aria-pressed={active}
aria-label={label}
title={label}
data-slot={`${dataSlotPrefix}-${value}`}
className={cn(
"focus-visible:ring-ring inline-flex size-11 items-center justify-center rounded-[5px] transition-colors focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none sm:size-9",
active
? "bg-secondary text-secondary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
<Icon className="size-4" aria-hidden="true" />
</button>
);
})}
</div>
);
}