From 7eaede257e685a2ab02668be1319604b6ae90ce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 01:12:52 +0200 Subject: [PATCH] feat(ui): add a slider primitive for bounded numeric entry - Slider wraps the Radix control the way Switch and Progress already do; no new dependency - SliderField adds the label, the value and the two scale anchors, which are what make a 7 mean anything - a null value renders as unanswered rather than as the value the parked thumb sits on, and zero renders as the answer it is - the announced text carries the scale, not only the number --- src/components/ui/__tests__/slider.test.tsx | 120 ++++++++++++++ src/components/ui/slider.tsx | 167 ++++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 src/components/ui/__tests__/slider.test.tsx create mode 100644 src/components/ui/slider.tsx diff --git a/src/components/ui/__tests__/slider.test.tsx b/src/components/ui/__tests__/slider.test.tsx new file mode 100644 index 000000000..1908fc897 --- /dev/null +++ b/src/components/ui/__tests__/slider.test.tsx @@ -0,0 +1,120 @@ +/** + * `` / `` — the shape and the unanswered state. + * + * The suite renders server-side (`@testing-library/react` is not a dependency + * here), so nothing below drags a thumb or presses an arrow key; the keyboard + * behaviour is Radix's own contract and the manual pass covers it. What is + * proven here is what the component decides: that an unanswered field renders + * no number and announces itself as unanswered rather than as the value its + * parked thumb sits on, that a zero is rendered as an answer, and that the + * anchors reach the markup as muted legend text. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { Slider, SliderField } from "../slider"; + +function field(props: Partial> = {}) { + return renderToStaticMarkup( + {}} + emptyLabel="nicht angegeben" + valueText="7 von 10" + lowAnchor="vollkommen entspannt" + highAnchor="extrem belastet" + {...props} + />, + ); +} + +describe("", () => { + it("renders the Radix parts under their slots", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('data-slot="slider"'); + expect(html).toContain('data-slot="slider-track"'); + expect(html).toContain('data-slot="slider-range"'); + expect(html).toContain('data-slot="slider-thumb"'); + }); + + it("announces the caller's text instead of the bare number", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain( + 'aria-valuetext="6 von 10: sehr unangenehm bis sehr angenehm"', + ); + expect(html).toContain('aria-label="Stimmung"'); + // The thumb is a slider to assistive tech, with the bounds published. + // `aria-valuenow` is deliberately not asserted: Radix sets it after mount, + // so it is absent from server markup and a test demanding it here would be + // measuring the renderer rather than the component. + expect(html).toContain('role="slider"'); + expect(html).toContain('aria-valuemin="0"'); + expect(html).toContain('aria-valuemax="10"'); + expect(html).toContain('aria-orientation="horizontal"'); + }); +}); + +describe("", () => { + it("renders the label, the value and both anchors", () => { + const html = field(); + expect(html).toContain("Stress"); + expect(html).toContain("vollkommen entspannt"); + expect(html).toContain("extrem belastet"); + expect(html).toContain('data-slot="slider-field-value"'); + expect(html).toContain(">7<"); + }); + + it("puts the answer in content colour and the anchors in muted", () => { + const html = field(); + expect(html).toContain("tabular-nums"); + expect(html).toContain("text-foreground"); + expect(html).toContain("text-muted-foreground"); + expect(html).toContain("text-xs"); + }); + + it("renders an unanswered field as unanswered, not as its parked value", () => { + const html = field({ value: null, valueText: "should not be announced" }); + expect(html).toContain('data-unset="true"'); + expect(html).toContain("nicht angegeben"); + // The thumb sits at the low end so the track reads empty, but nothing + // claims that low end as the person's answer. + expect(html).toContain('aria-valuetext="nicht angegeben"'); + expect(html).not.toContain("should not be announced"); + }); + + it("renders a zero as an answer", () => { + // Zero is a real point on this scale. A field that treated it as absence + // would show the empty label and lose an answer somebody gave. + const html = field({ value: 0, valueText: "0 von 10" }); + expect(html).not.toContain('data-unset="true"'); + expect(html).not.toContain("nicht angegeben"); + expect(html).toContain('aria-valuetext="0 von 10"'); + }); + + it("offers to clear an answer only when there is one to clear", () => { + const set = field({ onClear: () => {}, clearLabel: "zurücksetzen" }); + expect(set).toContain('data-slot="slider-field-clear"'); + expect(set).toContain("zurücksetzen"); + + const unanswered = field({ + value: null, + onClear: () => {}, + clearLabel: "zurücksetzen", + }); + expect(unanswered).not.toContain('data-slot="slider-field-clear"'); + + const noHandler = field(); + expect(noHandler).not.toContain('data-slot="slider-field-clear"'); + }); +}); diff --git a/src/components/ui/slider.tsx b/src/components/ui/slider.tsx new file mode 100644 index 000000000..53ed01642 --- /dev/null +++ b/src/components/ui/slider.tsx @@ -0,0 +1,167 @@ +"use client"; + +import * as React from "react"; +import { Slider as SliderPrimitive } from "radix-ui"; + +import { cn } from "@/lib/utils"; + +/** + * Numeric range entry. + * + * The alternative this replaces is eleven discrete buttons, which is what the + * rated-factor picker does for a 1-5 scale. That pattern does not survive the + * jump to 0-10: eleven 32 px targets with 4 px gaps come to roughly 392 px + * before the label, and the narrow phone this app is mostly used on is 390 px + * wide. A slider answers the same question in a fixed width at any scale, and + * arrow keys move it a step at a time, which eleven buttons never did. + * + * `Slider` is the bare control. `SliderField` is the labelled row around it — + * name on the left, value on the right, the two scale anchors underneath — and + * it is what a capture surface should reach for, because the anchors are what + * make a 7 mean anything. + */ +function Slider({ + className, + valueText, + thumbLabel, + ...props +}: React.ComponentProps & { + /** + * What a screen reader announces instead of the bare number. A slider that + * says "6" tells somebody who cannot see the anchors nothing at all; the + * caller passes the number together with what the ends of the scale mean. + */ + valueText?: string; + /** Accessible name for the thumb when the field label is not adjacent. */ + thumbLabel?: string; +}) { + return ( + + + + + + + ); +} + +/** + * A labelled slider with its scale anchors. + * + * `value` is nullable and the null is load-bearing: a question nobody answered + * is not a question answered in the middle. An unset field renders no number + * and fills no track, and only an interaction commits a value. `onClear`, when + * given, renders the control that takes an answer back — without it a user who + * nudged a slider by accident can never return the field to empty. + */ +function SliderField({ + label, + value, + onValueChange, + onClear, + clearLabel, + emptyLabel, + valueText, + lowAnchor, + highAnchor, + min = 0, + max = 10, + step = 1, + className, + ...props +}: { + label: string; + value: number | null; + onValueChange: (value: number) => void; + onClear?: () => void; + clearLabel?: string; + /** Rendered in place of the number while the field is unanswered. */ + emptyLabel: string; + /** Announced by a screen reader; carries the anchors, not only the number. */ + valueText?: string; + lowAnchor: string; + highAnchor: string; + min?: number; + max?: number; + step?: number; + className?: string; +} & Omit< + React.ComponentProps, + "value" | "onValueChange" | "min" | "max" | "step" | "className" +>) { + const unset = value === null; + return ( +
+
+ {/* Content colour for the label and the value, muted for the anchors + below: the anchors are the legend, the value is the answer. */} + {label} + + + {unset ? emptyLabel : value} + + {!unset && onClear ? ( + + ) : null} + +
+ onValueChange(next[0])} + min={min} + max={max} + step={step} + thumbLabel={label} + valueText={unset ? emptyLabel : valueText} + {...props} + /> +
+ {lowAnchor} + {highAnchor} +
+
+ ); +} + +export { Slider, SliderField };