Files
HealthLog/src/components/timeline/segmented.tsx
T
Marc-André Bombeck 1440ad4301 feat(timeline): one row per medication, no cap on value lines, range popover
A medication arrived as up to four kinds of item (its own span, its
courses, its dose changes, its pauses) and was drawn item by item, so a
medication with a course read as two bars on two rows. Every item of one
medication now carries the medication's id as `group`, and the lane draws
one row per group: the courses are the stretches it was taken, each dose
change cuts them into pieces labelled with their dose, and a pause is a
gap in the same bar. The selection bar and the phone chronicle list one
entry per medication with the dose in the text.

The value series cap of six is gone. The response carries
`availableSeries`, every series the record can show, and the picker is
one compact "Values (n)" button whose menu lists them. Past the fifth
line the five data colours repeat; every line is named at its left.

Choosing a range opens a popover anchored to the "Range" segment (a sheet
on a phone) with "Apply", so the chart no longer moves; the URL keeps the
range as before. The selection bar sits on the card under a hairline
instead of a grey box, the legend explains the dashed stroke and the
hollow point, and no timeline text joins its parts with a middle dot or
calls an open span "ongoing".
2026-10-09 15:02:14 +02:00

101 lines
2.9 KiB
TypeScript

"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>
);
}