fix(ui): give the segmented control back the chart tabs' look

The shared control introduced a muted track with the chosen segment
raised on the background colour. In a chart header it read heavier than
the card around it, larger type and wider segments on a grey bar, and it
dropped the primary fill the range tabs had carried.

Segmented now draws the chart tabs' former look everywhere: no track, the
chosen segment filled in the primary colour, the others plain until
hovered. A small size keeps the range rows in chart headers at the former
type and padding; the touch floor of 44 px on a coarse pointer stays.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 09:32:58 +02:00
1 parent c1fb59fe39
commit f9796858e6
9 files changed
+48 -7

No files matched your search

@@ -51,6 +51,7 @@ export function ChartRangeTabs({
const { t } = useTranslations();
return (
<Segmented
size="sm"
mode="toggle"
label={t("charts.rangeLabel")}
optionSlot={() => "chart-range-tab"}
@@ -140,6 +140,7 @@ export function CustomMetricChart({
<div className="space-y-3">
<div className="flex justify-end">
<Segmented
size="sm"
options={RANGE_DAYS.map((r) => ({
value: r.key,
label: t(`customMetrics.chart.range.${r.key}`),
@@ -141,6 +141,7 @@ export function MoodDimensionTrends({
return (
<div className="space-y-3" data-slot="mood-dimension-trends">
<Segmented
size="sm"
options={MOOD_DIMENSION_WINDOWS.map((option) => ({
value: String(option),
label: t("insights.mood.dimensions.window", { days: option }),
@@ -177,6 +177,7 @@ export function PeriodNarrativeCard({
<div className="flex items-start justify-between gap-2">
{/* Calm segmented control, no chart. */}
<Segmented
size="sm"
mode="toggle"
label={t("insights.narrativeTitle")}
slot="period-narrative-period"
@@ -392,6 +392,7 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
{/* The page's other range controls read "7T / 30T"; this one
printed an English "7d" beside them, hence `daysNLabel`. */}
<Segmented
size="sm"
mode="toggle"
label={t("charts.rangeLabel")}
slot="sleep-stage-window-toggle"
@@ -254,6 +254,7 @@ export function LabBiomarkerChart({
<div className="space-y-3">
<div className="flex justify-end">
<Segmented
size="sm"
options={RANGE_DAYS.map((r) => ({
value: r.key,
label: t(`labs.chart.range.${r.key}`),
@@ -260,6 +260,7 @@ function TargetCard({
<div className="space-y-3">
<div className="flex justify-end">
<Segmented
size="sm"
mode="toggle"
value={scaleMode}
options={[
@@ -61,6 +61,27 @@ describe("<Segmented>", () => {
expect(out).toContain("min-h-9");
});
it("fills the chosen segment in the primary colour, on no track", () => {
const out = html();
const segments = out.match(/<button[^>]*>/g) ?? [];
expect(segments).toHaveLength(2);
const chosen = segments.filter((b) => b.includes('aria-checked="true"'));
expect(chosen).toHaveLength(1);
expect(chosen[0]).toContain("bg-primary text-primary-foreground");
const other = segments.find((b) => b.includes('aria-checked="false"'));
expect(other).not.toContain("bg-primary");
const group = out.match(/<div[^>]*role="radiogroup"[^>]*>/)?.[0];
expect(group).not.toContain("bg-muted");
});
it("sets a chart header's range row in the small type", () => {
expect(html()).toContain("text-sm");
const small = html({ size: "sm" });
expect(small).toContain("text-xs");
expect(small).not.toContain("text-sm");
expect(small.match(/pointer-coarse:min-h-11/g)?.length).toBe(2);
});
it("renders pressed buttons in toggle mode", () => {
const out = html({ mode: "toggle" });
expect(out).toContain('role="group"');
+20 -7
View File
@@ -3,8 +3,15 @@
/**
* The segmented control: a short row of mutually exclusive choices on one
* track (zoom level, chronicle grouping, cards or table, recent or all,
* Paperless-ngx or Papra). One look everywhere: a muted track, the chosen
* segment raised on the background colour, never coloured.
* Paperless-ngx or Papra). One look everywhere, the one the dashboard chart
* tabs always had: no track, the chosen segment filled in the primary colour,
* the others plain until hovered. A muted track with a raised segment was
* tried and read as heavier than the card around it.
*
* Two sizes: `md` (default) for a choice that is the content of its area (a
* view switch, a form sheet, a detail page's sections), `sm` for a range row
* in a chart header, where the segments sit beside the chart's title and
* must not outweigh it.
*
* Two semantics, one chrome:
*
@@ -59,6 +66,8 @@ export interface SegmentedProps<T extends string> {
iconOnly?: boolean;
/** Spread the segments over the full width. */
stretch?: boolean;
/** `sm` for a range row in a chart header; see the module comment. */
size?: "sm" | "md";
disabled?: boolean;
className?: string;
/**
@@ -78,6 +87,7 @@ export function Segmented<T extends string>({
mode = "radio",
iconOnly = false,
stretch = false,
size = "md",
disabled = false,
className,
wrapOption,
@@ -100,7 +110,7 @@ export function Segmented<T extends string>({
aria-disabled={disabled || undefined}
data-slot={slot}
className={cn(
"bg-muted inline-flex shrink-0 items-center gap-0.5 rounded-lg p-0.5",
"inline-flex shrink-0 items-center gap-1",
stretch && "flex w-full",
className,
)}
@@ -135,14 +145,17 @@ export function Segmented<T extends string>({
onChange(option.value);
}}
className={cn(
"focus-visible:ring-ring/50 inline-flex items-center justify-center gap-1.5 rounded-md text-sm font-medium transition-colors outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none",
"focus-visible:ring-ring/50 inline-flex items-center justify-center gap-1.5 rounded-md font-medium transition-colors outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none",
size === "sm" ? "text-xs" : "text-sm",
iconOnly
? "size-9 pointer-coarse:size-11"
: "min-h-9 px-3 pointer-coarse:min-h-11",
: size === "sm"
? "min-h-9 px-2 sm:px-3 pointer-coarse:min-h-11 pointer-coarse:min-w-11"
: "min-h-9 px-3 pointer-coarse:min-h-11",
stretch && "flex-1",
checked
? "bg-background text-foreground shadow-sm"
: "text-foreground/75 hover:text-foreground dark:text-tab-inactive dark:hover:text-foreground",
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "text-foreground hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
)}
>
{Icon ? (