mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user