mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
Merge pull request #735 from MBombeck/feat/mood-level-a
Five dimensions behind the mood check-in
This commit is contained in:
47 files changed
+3667
-74
No files matched your search
@@ -33,6 +33,7 @@ export { HostMetricsChart } from "@/components/admin/host-metrics-chart";
|
||||
export { CustomMetricChart } from "@/components/custom-metrics/custom-metric-chart";
|
||||
export { BbtChart } from "@/components/cycle/bbt-chart";
|
||||
export { DeltaSparkline } from "@/components/insights/derived/delta-sparkline";
|
||||
export { MoodDimensionTrends } from "@/components/insights/mood/mood-dimension-trends";
|
||||
export { MoodDistributionChart } from "@/components/insights/mood/mood-distribution-chart";
|
||||
export { MoodTimeOfDayChart } from "@/components/insights/mood/mood-time-of-day-chart";
|
||||
export { MoodWeekdayChart } from "@/components/insights/mood/mood-weekday-chart";
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
MOOD_DIMENSIONS,
|
||||
MOOD_DIMENSION_WINDOWS,
|
||||
type MoodDimensionWindow,
|
||||
} from "@/lib/mood/dimensions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.37 — the five level-A dimensions over time.
|
||||
*
|
||||
* Each dimension is drawn in the orientation the person answered it in and
|
||||
* under the label they read, so a high stress line means a stressful stretch
|
||||
* rather than a good one. Nothing is flipped for the picture's convenience;
|
||||
* the legend says which way each scale runs.
|
||||
*
|
||||
* A dimension nobody has answered is not drawn and is named underneath as not
|
||||
* recorded. Drawing it flat through the middle would put five answers on the
|
||||
* chart for a question that was asked once.
|
||||
*/
|
||||
|
||||
export interface MoodDimensionPointData {
|
||||
date: string;
|
||||
value: number;
|
||||
samples: number;
|
||||
}
|
||||
|
||||
export interface MoodDimensionSummaryData {
|
||||
key: string;
|
||||
present: boolean;
|
||||
inverse: boolean;
|
||||
min: number;
|
||||
max: number;
|
||||
count: number;
|
||||
avg7: number | null;
|
||||
avg30: number | null;
|
||||
avg90: number | null;
|
||||
latest: number | null;
|
||||
latestDate: string | null;
|
||||
newestDaysAgo: number | null;
|
||||
series: MoodDimensionPointData[];
|
||||
}
|
||||
|
||||
/** One colour per dimension, from the same semantic set the other charts use. */
|
||||
const COLOR_BY_KEY: Record<string, string> = {
|
||||
a1: "var(--chart-1)",
|
||||
a2: "var(--destructive)",
|
||||
a3: "var(--dracula-orange)",
|
||||
a4: "var(--info)",
|
||||
a5: "var(--success)",
|
||||
};
|
||||
|
||||
function meanForWindow(
|
||||
summary: MoodDimensionSummaryData,
|
||||
window: MoodDimensionWindow,
|
||||
): number | null {
|
||||
if (window === 7) return summary.avg7;
|
||||
if (window === 30) return summary.avg30;
|
||||
return summary.avg90;
|
||||
}
|
||||
|
||||
export function MoodDimensionTrends({
|
||||
dimensions,
|
||||
}: {
|
||||
dimensions: MoodDimensionSummaryData[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [window, setWindow] = useState<MoodDimensionWindow>(30);
|
||||
|
||||
const present = dimensions.filter((d) => d.present);
|
||||
const missing = dimensions.filter((d) => !d.present);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
const byDate = new Map<string, Record<string, number | string>>();
|
||||
for (const summary of present) {
|
||||
for (const point of summary.series) {
|
||||
const row = byDate.get(point.date) ?? { date: point.date };
|
||||
row[summary.key] = point.value;
|
||||
byDate.set(point.date, row);
|
||||
}
|
||||
}
|
||||
const rows = [...byDate.values()].sort((a, b) =>
|
||||
String(a.date).localeCompare(String(b.date)),
|
||||
);
|
||||
// The series carry the longest window; the shorter ones are a slice of the
|
||||
// same points rather than a second read.
|
||||
return rows.slice(Math.max(0, rows.length - window));
|
||||
}, [present, window]);
|
||||
|
||||
const labelFor = (key: string) => {
|
||||
const dimension = MOOD_DIMENSIONS.find((d) => d.key === key);
|
||||
return dimension ? t(dimension.labelKey) : key;
|
||||
};
|
||||
|
||||
if (present.length === 0) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.mood.dimensions.none")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-slot="mood-dimension-trends">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{MOOD_DIMENSION_WINDOWS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setWindow(option)}
|
||||
aria-pressed={window === option}
|
||||
className={cn(
|
||||
"min-h-9 rounded-full border px-3 text-xs transition-colors",
|
||||
window === option
|
||||
? "border-primary bg-primary/15 text-primary"
|
||||
: "border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t("insights.mood.dimensions.window", { days: option })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="h-[clamp(160px,34vh,220px)] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={chartData}
|
||||
margin={{ top: 4, right: 8, bottom: 0, left: -20 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
tickFormatter={(value: string) => value.slice(5)}
|
||||
minTickGap={24}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 10]}
|
||||
ticks={[0, 5, 10]}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: "var(--popover)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
labelStyle={{ color: "var(--muted-foreground)" }}
|
||||
formatter={(value, name) => [
|
||||
String(value ?? ""),
|
||||
labelFor(String(name)),
|
||||
]}
|
||||
/>
|
||||
{present.map((summary) => (
|
||||
<Line
|
||||
key={summary.key}
|
||||
type="monotone"
|
||||
dataKey={summary.key}
|
||||
name={summary.key}
|
||||
stroke={COLOR_BY_KEY[summary.key] ?? "var(--chart-1)"}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
// A gap is a day nobody answered; joining across it would draw
|
||||
// a value that was never given.
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1.5">
|
||||
{present.map((summary) => {
|
||||
const mean = meanForWindow(summary, window);
|
||||
const dimension = MOOD_DIMENSIONS.find((d) => d.key === summary.key);
|
||||
return (
|
||||
<li
|
||||
key={summary.key}
|
||||
className="flex items-baseline justify-between gap-3 text-sm"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block size-2 shrink-0 rounded-full"
|
||||
style={{
|
||||
background: COLOR_BY_KEY[summary.key] ?? "var(--chart-1)",
|
||||
}}
|
||||
/>
|
||||
<span>{labelFor(summary.key)}</span>
|
||||
{summary.inverse && dimension ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("insights.mood.dimensions.higherIsMore", {
|
||||
anchor: t(dimension.highAnchorKey),
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-foreground shrink-0 tabular-nums">
|
||||
{mean === null
|
||||
? t("insights.mood.dimensions.noneInWindow")
|
||||
: mean}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{missing.length > 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.mood.dimensions.notRecorded", {
|
||||
list: missing.map((d) => labelFor(d.key)).join(", "),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,12 +34,25 @@ import { MoodHeatmap } from "@/components/charts/mood-heatmap";
|
||||
// without a layout shift (charts stay Recharts, visually identical). Types
|
||||
// stay value-free imports so they don't drag the chunk back in.
|
||||
import type { MoodDistributionRow } from "./mood-distribution-chart";
|
||||
import type { MoodDimensionSummaryData } from "./mood-dimension-trends";
|
||||
import type { MoodWeekdayRow } from "./mood-weekday-chart";
|
||||
import type { MoodTimeOfDayPattern } from "./mood-time-of-day-chart";
|
||||
// v1.16.7 established the shared-barrel pattern for the trio; all three
|
||||
// loaders now resolve the app-wide chart-runtime boundary, so they share
|
||||
// the ONE recharts chunk with every other chart surface: the cards reveal
|
||||
// together, and recharts ships exactly once for the whole app.
|
||||
const MoodDimensionTrends = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
default: mod.MoodDimensionTrends,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<Skeleton className="h-[clamp(160px,34vh,220px)] w-full rounded-md" />
|
||||
),
|
||||
},
|
||||
);
|
||||
const MoodDistributionChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
@@ -127,6 +140,9 @@ interface MoodInsightsResponse {
|
||||
cells: Array<{ date: string; score: number; samples: number }>;
|
||||
};
|
||||
distribution: MoodDistributionRow[];
|
||||
// Optional only to tolerate a stale pre-v1.37 cached payload during a
|
||||
// rollout; the live endpoint always populates it.
|
||||
dimensions?: MoodDimensionSummaryData[];
|
||||
weekday: MoodWeekdayRow[];
|
||||
timeOfDay: MoodTimeOfDayPattern;
|
||||
stability: MoodStabilityData | null;
|
||||
@@ -258,6 +274,8 @@ export function MoodInsightsSections({
|
||||
const hasCrosstab = crosstabRows.length > 0;
|
||||
const factorCrosstabRows = data.factorCrosstab ?? [];
|
||||
const hasFactorCrosstab = factorCrosstabRows.length > 0;
|
||||
const dimensionSummaries = data.dimensions ?? [];
|
||||
const hasDimensions = dimensionSummaries.some((d) => d.present);
|
||||
|
||||
// v1.12.7 — the Stimmungskalender is lifted above the line chart on the page,
|
||||
// so it renders as its own region here.
|
||||
@@ -334,6 +352,15 @@ export function MoodInsightsSections({
|
||||
renders nothing when both halves are empty. */}
|
||||
<MoodWhatStandsOut narratives={narratives} />
|
||||
|
||||
{/* v1.37 — the five level-A dimensions. Renders only once at least one
|
||||
of them carries a value; an account that has never opened the detail
|
||||
section sees the page it saw before. */}
|
||||
{hasDimensions && (
|
||||
<SectionCard title={t("insights.mood.dimensions.title")} icon={Gauge}>
|
||||
<MoodDimensionTrends dimensions={dimensionSummaries} />
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<SectionCard
|
||||
title={t("insights.mood.distributionTitle")}
|
||||
|
||||
@@ -85,7 +85,10 @@ describe("MoodForm — Reset confirm when dirty (v1.11.5)", () => {
|
||||
expect(formSrc).toMatch(/const isDirty =\s*\n?\s*mood !== ""/);
|
||||
expect(formSrc).toContain('tagsInput.trim() !== ""');
|
||||
expect(formSrc).toContain("tagKeys.length > 0");
|
||||
expect(formSrc).toContain("ratedFactors.length > 0");
|
||||
// v1.37 — a level-A value the user set is typed input too. The rated
|
||||
// factors this line used to name are no longer captured here; see the
|
||||
// level-A describe below.
|
||||
expect(formSrc).toContain("Object.values(levelA).some((v) => v !== null)");
|
||||
expect(formSrc).toContain('note.trim() !== ""');
|
||||
// The `isDirty` expression itself must not read `moodLoggedAt`.
|
||||
const isDirtyExpr = formSrc.slice(
|
||||
@@ -115,31 +118,51 @@ describe("MoodForm — Reset confirm when dirty (v1.11.5)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("MoodForm — rated factors (v1.12.0)", () => {
|
||||
it("lifts a ratedFactors set and threads it to the tag picker", () => {
|
||||
expect(formSrc).toContain("const [ratedFactors, setRatedFactors]");
|
||||
expect(formSrc).toMatch(
|
||||
/function rateFactor\(key: string, rating: number \| null\)/,
|
||||
);
|
||||
// The picker receives both the binary toggle and the factor rate
|
||||
// callbacks so it can render BINARY chips and RATED controls.
|
||||
expect(formSrc).toMatch(
|
||||
/<MoodTagPicker[\s\S]*ratedFactors=\{ratedFactors\}[\s\S]*onRateFactor=\{rateFactor\}/,
|
||||
describe("MoodForm — level A replaces the factor ratings (v1.37)", () => {
|
||||
it("no longer offers a factor rating on a new entry", () => {
|
||||
// A 1-5 score for "work stress" and a 0-10 stress self-state are two
|
||||
// answers to one question. The create sheet asks the second one only.
|
||||
// Nothing was deleted: the catalogue rows, the stored ratings and the
|
||||
// edit dialog below all still work, which the next describe pins.
|
||||
expect(formSrc).not.toContain("onRateFactor=");
|
||||
expect(formSrc).not.toContain('mode="rated"');
|
||||
expect(formSrc).not.toContain("ratedFactors");
|
||||
// The binary structured-tag contract stays byte-identical.
|
||||
expect(formSrc).toContain('mode="binary"');
|
||||
expect(formSrc).toContain(
|
||||
"tagKeys: tagKeys.length > 0 ? tagKeys : undefined,",
|
||||
);
|
||||
});
|
||||
|
||||
it("sends ratedFactors as a parallel array on POST (omitted when empty)", () => {
|
||||
expect(formSrc).toContain(
|
||||
"ratedFactors: ratedFactors.length > 0 ? ratedFactors : undefined,",
|
||||
it("offers the five level-A sliders and sends only the answered ones", () => {
|
||||
expect(formSrc).toContain("<MoodLevelASection");
|
||||
expect(formSrc).toContain("...levelACreatePayload(levelA)");
|
||||
// Picking a face seeds pleasantness; moving that slider ends the seeding.
|
||||
expect(formSrc).toMatch(
|
||||
/function pickMood\(value: string\) \{[\s\S]*seedA1FromMood\(prev, value, a1Touched\)/,
|
||||
);
|
||||
// The binary structured-tag contract stays byte-identical.
|
||||
expect(formSrc).toContain(
|
||||
"tagKeys: tagKeys.length > 0 ? tagKeys : undefined,",
|
||||
expect(formSrc).toContain("onA1Touched={() => setA1Touched(true)}");
|
||||
// Nothing here defaults a value: the state starts empty and stays empty.
|
||||
expect(formSrc).toContain("useState<LevelAState>(EMPTY_LEVEL_A)");
|
||||
});
|
||||
|
||||
it("offers no clear control for pleasantness, matching the write path", () => {
|
||||
// An entry always carries a label, so it always implies a pleasantness
|
||||
// value and both write routes derive one from a null. A clear button here
|
||||
// would be a control the server undoes on the way to the database.
|
||||
const fieldsSrc = readFileSync(
|
||||
resolve(__dirname, "../mood-level-a-fields.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(fieldsSrc).toMatch(
|
||||
/onClear=\{\s*dimension\.key === "a1"\s*\?\s*undefined/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MoodList — rated factor editing", () => {
|
||||
// Unchanged by v1.37 on purpose: the create sheet stopped asking, and an
|
||||
// entry that already carries a rating must still be correctable.
|
||||
it("hydrates, renders, and submits the entry's current ratedFactors", () => {
|
||||
expect(listSrc).toContain("ratedFactors: RatedFactor[];");
|
||||
expect(listSrc).toContain(
|
||||
@@ -156,6 +179,18 @@ describe("MoodList — rated factor editing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("MoodList — level A on the edit path (v1.37)", () => {
|
||||
it("seeds the five values from the entry and sends them back, nulls included", () => {
|
||||
// What the capture sheet writes, this dialog has to show. A value visible
|
||||
// in a trend and nowhere else is a value the person cannot correct.
|
||||
expect(listSrc).toContain("levelA: levelAFromEntry(entry)");
|
||||
expect(listSrc).toContain("setEditLevelA(seed.levelA)");
|
||||
expect(listSrc).toContain("<MoodLevelASection");
|
||||
// Nulls travel: this path replaces, so a cleared value has to be stated.
|
||||
expect(listSrc).toContain("...levelAUpdatePayload(levelA)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MoodList — delete failure toast (v1.11.5)", () => {
|
||||
it("surfaces a toast on a rejected delete instead of failing silently", () => {
|
||||
expect(listSrc).toContain('import { toast } from "sonner"');
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
SlidersHorizontal,
|
||||
StickyNote,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -45,7 +44,14 @@ import {
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section";
|
||||
import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker";
|
||||
import { MoodTagPicker } from "./mood-tag-picker";
|
||||
import {
|
||||
EMPTY_LEVEL_A,
|
||||
MoodLevelASection,
|
||||
levelACreatePayload,
|
||||
seedA1FromMood,
|
||||
type LevelAState,
|
||||
} from "./mood-level-a-fields";
|
||||
import { MoodQuickTags } from "./mood-quick-tags";
|
||||
import { useRecentTags } from "./recent-tags";
|
||||
import { moodFaceIcon } from "./mood-tag-icons";
|
||||
@@ -114,14 +120,17 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
const { getRadioProps: getMoodRadioProps } = useRovingRadioGroup({
|
||||
count: MOOD_LEVELS.length,
|
||||
selectedIndex: MOOD_LEVELS.findIndex((l) => l.value === mood),
|
||||
onSelect: (index) => setMood(MOOD_LEVELS[index]!.value),
|
||||
onSelect: (index) => pickMood(MOOD_LEVELS[index]!.value),
|
||||
});
|
||||
// v1.37 — the five level-A values. Pleasantness follows the picked face
|
||||
// until the user moves it themselves; the other four start and stay empty
|
||||
// unless somebody answers them.
|
||||
const [levelA, setLevelA] = useState<LevelAState>(EMPTY_LEVEL_A);
|
||||
const [a1Touched, setA1Touched] = useState(false);
|
||||
const [levelAOpen, setLevelAOpen] = useState(false);
|
||||
const [tagsInput, setTagsInput] = useState("");
|
||||
// v1.8.5 — structured-tag keys picked from the taxonomy catalog.
|
||||
const [tagKeys, setTagKeys] = useState<string[]>([]);
|
||||
// v1.12.0 — rated mood factors (FACTOR-kind catalog tags scored on
|
||||
// their own 1..scaleMax scale). Sent as `ratedFactors: [{key,rating}]`.
|
||||
const [ratedFactors, setRatedFactors] = useState<RatedFactor[]>([]);
|
||||
// v1.8.5 (C1) — first-class free-text note. The model + API already
|
||||
// accepted `note`; the web form was the only surface that couldn't
|
||||
// write it.
|
||||
@@ -142,24 +151,25 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
mood !== "" ||
|
||||
tagsInput.trim() !== "" ||
|
||||
tagKeys.length > 0 ||
|
||||
ratedFactors.length > 0 ||
|
||||
// A level-A value the user set counts as typed input: Reset must ask
|
||||
// before it discards an answer that took five slider moves.
|
||||
Object.values(levelA).some((v) => v !== null) ||
|
||||
note.trim() !== "";
|
||||
|
||||
// v1.37 — picking a face suggests a pleasantness value; the suggestion
|
||||
// stands until the user moves that slider, after which the two are
|
||||
// independent. Moving the slider never changes which face is lit.
|
||||
function pickMood(value: string) {
|
||||
setMood(value);
|
||||
setLevelA((prev) => seedA1FromMood(prev, value, a1Touched));
|
||||
}
|
||||
|
||||
function toggleTagKey(key: string) {
|
||||
setTagKeys((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
}
|
||||
|
||||
// v1.12.0 — set or clear a rated factor's score. `rating === null`
|
||||
// removes the factor from the set (re-tapping the active step clears).
|
||||
function rateFactor(key: string, rating: number | null) {
|
||||
setRatedFactors((prev) => {
|
||||
const rest = prev.filter((f) => f.key !== key);
|
||||
return rating === null ? rest : [...rest, { key, rating }];
|
||||
});
|
||||
}
|
||||
|
||||
// v1.4.27 MB3 — error banner descriptor for the timestamp input.
|
||||
const errorId = useId();
|
||||
const errorDescriptor = error ? errorId : undefined;
|
||||
@@ -171,9 +181,11 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
|
||||
function resetForm() {
|
||||
setMood("");
|
||||
setLevelA(EMPTY_LEVEL_A);
|
||||
setA1Touched(false);
|
||||
setLevelAOpen(false);
|
||||
setTagsInput("");
|
||||
setTagKeys([]);
|
||||
setRatedFactors([]);
|
||||
setNote("");
|
||||
setMoodLoggedAt(getDefaultMoodLoggedAtValue());
|
||||
setError(null);
|
||||
@@ -209,9 +221,10 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
mood,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
tagKeys: tagKeys.length > 0 ? tagKeys : undefined,
|
||||
// v1.12.0 — rated factors as a parallel [{key,rating}] array.
|
||||
ratedFactors: ratedFactors.length > 0 ? ratedFactors : undefined,
|
||||
note: trimmedNote.length > 0 ? trimmedNote : undefined,
|
||||
// v1.37 — only the values somebody answered. An untouched slider
|
||||
// sends nothing, and the server writes NULL rather than a middle.
|
||||
...levelACreatePayload(levelA),
|
||||
moodLoggedAt: timestamp,
|
||||
});
|
||||
|
||||
@@ -324,7 +337,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
aria-label={t(level.labelKey)}
|
||||
data-slot="mood-face"
|
||||
data-mood={level.value}
|
||||
onClick={() => setMood(level.value)}
|
||||
onClick={() => pickMood(level.value)}
|
||||
{...getMoodRadioProps(index)}
|
||||
// v1.12 — selected-state unified with the tag tiles and
|
||||
// factor steps: `border-primary bg-primary/15 text-primary`
|
||||
@@ -392,21 +405,20 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
/>
|
||||
</SheetSection>
|
||||
|
||||
{/* v1.17.0 — "Factors": the FACTOR-kind segmented ratings, kept
|
||||
exactly as the existing segmented control. */}
|
||||
<SheetSection
|
||||
title={t("mood.sectionFactors")}
|
||||
icon={<SlidersHorizontal />}
|
||||
summary={<SheetSectionCount count={ratedFactors.length} />}
|
||||
>
|
||||
<MoodTagPicker
|
||||
selected={tagKeys}
|
||||
onToggle={toggleTagKey}
|
||||
ratedFactors={ratedFactors}
|
||||
onRateFactor={rateFactor}
|
||||
mode="rated"
|
||||
/>
|
||||
</SheetSection>
|
||||
{/* v1.37 — the five level-A values, closed by default. The rated
|
||||
factors that used to sit here are no longer offered on a new
|
||||
entry: a 1-5 score for "work stress" and a 0-10 stress
|
||||
self-state are different questions, and asking both invites
|
||||
two answers to one thing. Existing ratings keep working
|
||||
everywhere they already do, including the edit dialog, and no
|
||||
catalogue row was removed. */}
|
||||
<MoodLevelASection
|
||||
value={levelA}
|
||||
onChange={setLevelA}
|
||||
onA1Touched={() => setA1Touched(true)}
|
||||
open={levelAOpen}
|
||||
onOpenChange={setLevelAOpen}
|
||||
/>
|
||||
|
||||
{/* v1.17.0 — "Note & details": free-text tags, GLP-1 quick-tags,
|
||||
and the note field. The badge counts whichever of these carry
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The five level-A sliders, and the state rules around them.
|
||||
*
|
||||
* One component for both mood surfaces — the create sheet and the list's edit
|
||||
* dialog — because what one writes the other has to be able to show. A value a
|
||||
* user set on Monday and cannot see on Tuesday is worse than one they were
|
||||
* never offered.
|
||||
*
|
||||
* The section is closed by default and nothing in it is required. The five
|
||||
* faces above it remain a complete entry on their own: one tap still saves.
|
||||
*/
|
||||
import { Gauge } from "lucide-react";
|
||||
|
||||
import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section";
|
||||
import { SliderField } from "@/components/ui/slider";
|
||||
import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions";
|
||||
import { getA1ForMood } from "@/lib/validations/mood";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/** The five values as the form holds them. `null` means unanswered. */
|
||||
export interface LevelAState {
|
||||
a1: number | null;
|
||||
a2: number | null;
|
||||
a3: number | null;
|
||||
a4: number | null;
|
||||
a5: number | null;
|
||||
}
|
||||
|
||||
export const EMPTY_LEVEL_A: LevelAState = {
|
||||
a1: null,
|
||||
a2: null,
|
||||
a3: null,
|
||||
a4: null,
|
||||
a5: null,
|
||||
};
|
||||
|
||||
/** How many of the five carry an answer — the collapsed section's badge. */
|
||||
export function levelAAnsweredCount(state: LevelAState): number {
|
||||
return MOOD_DIMENSIONS.filter((d) => state[d.key] !== null).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* The five values as the API takes them, with the unanswered ones left out.
|
||||
*
|
||||
* Omitted rather than sent as null on the create path: absent means "nothing
|
||||
* to say", and the server writes NULL for it. Nothing here ever sends a
|
||||
* default, which is the whole reason the columns are nullable.
|
||||
*/
|
||||
export function levelACreatePayload(
|
||||
state: LevelAState,
|
||||
): Partial<Record<keyof LevelAState, number>> {
|
||||
const out: Partial<Record<keyof LevelAState, number>> = {};
|
||||
for (const dimension of MOOD_DIMENSIONS) {
|
||||
const value = state[dimension.key];
|
||||
if (value !== null) out[dimension.key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The five values as the edit path takes them — nulls included.
|
||||
*
|
||||
* The edit path replaces rather than adds, so a value the user cleared has to
|
||||
* travel as an explicit null. Sending nothing would leave the old answer in
|
||||
* place and the dialog would lie about what it just saved.
|
||||
*/
|
||||
export function levelAUpdatePayload(state: LevelAState): LevelAState {
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the state from an entry being edited.
|
||||
*
|
||||
* The reader is the API's own wire shape, so a row that has never carried
|
||||
* these values arrives as `undefined` and lands as `null` — unanswered, which
|
||||
* is what it is.
|
||||
*/
|
||||
export function levelAFromEntry(entry: {
|
||||
a1?: number | null;
|
||||
a2?: number | null;
|
||||
a3?: number | null;
|
||||
a4?: number | null;
|
||||
a5?: number | null;
|
||||
}): LevelAState {
|
||||
return {
|
||||
a1: entry.a1 ?? null,
|
||||
a2: entry.a2 ?? null,
|
||||
a3: entry.a3 ?? null,
|
||||
a4: entry.a4 ?? null,
|
||||
a5: entry.a5 ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-seed pleasantness from a freshly picked face — but only while the user
|
||||
* has not moved that slider themselves.
|
||||
*
|
||||
* This is the one interaction in the sheet that will confuse a later reader,
|
||||
* so it is written down here rather than inferred from two call sites: picking
|
||||
* a face suggests a pleasantness value, and moving the slider takes the
|
||||
* suggestion over. After that the faces and the slider are independent, and in
|
||||
* particular moving the slider never changes which face is lit — the two are
|
||||
* different questions at different resolutions, not two views of one answer.
|
||||
*/
|
||||
export function seedA1FromMood(
|
||||
state: LevelAState,
|
||||
mood: string,
|
||||
a1Touched: boolean,
|
||||
): LevelAState {
|
||||
if (a1Touched) return state;
|
||||
return { ...state, a1: mood ? getA1ForMood(mood) : null };
|
||||
}
|
||||
|
||||
interface MoodLevelASectionProps {
|
||||
value: LevelAState;
|
||||
onChange: (next: LevelAState) => void;
|
||||
/** Called when the user moves pleasantness themselves, which ends the re-seeding. */
|
||||
onA1Touched?: () => void;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function MoodLevelASection({
|
||||
value,
|
||||
onChange,
|
||||
onA1Touched,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: MoodLevelASectionProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<SheetSection
|
||||
title={t("mood.sectionLevelA")}
|
||||
icon={<Gauge />}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
summary={<SheetSectionCount count={levelAAnsweredCount(value)} />}
|
||||
>
|
||||
<div className="space-y-4" data-slot="mood-level-a">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("mood.sectionLevelAHelp")}
|
||||
</p>
|
||||
{MOOD_DIMENSIONS.map((dimension) => {
|
||||
const current = value[dimension.key];
|
||||
const low = t(dimension.lowAnchorKey);
|
||||
const high = t(dimension.highAnchorKey);
|
||||
return (
|
||||
<SliderField
|
||||
key={dimension.key}
|
||||
label={t(dimension.labelKey)}
|
||||
value={current}
|
||||
min={dimension.min}
|
||||
max={dimension.max}
|
||||
step={1}
|
||||
lowAnchor={low}
|
||||
highAnchor={high}
|
||||
emptyLabel={t("mood.dimensionNotAnswered")}
|
||||
valueText={
|
||||
current === null
|
||||
? undefined
|
||||
: t("mood.dimensionValueText", {
|
||||
value: current,
|
||||
max: dimension.max,
|
||||
low,
|
||||
high,
|
||||
})
|
||||
}
|
||||
clearLabel={t("mood.dimensionClear")}
|
||||
// Pleasantness has no clear control, and that is the same rule
|
||||
// the server keeps: an entry always carries a five-point label,
|
||||
// so it always implies a pleasantness value, and there is no
|
||||
// state in which the field is honestly unanswered. Offering a
|
||||
// control that the write path would immediately undo would be a
|
||||
// button that quietly does nothing. The other four are genuinely
|
||||
// optional and keep theirs.
|
||||
onClear={
|
||||
dimension.key === "a1"
|
||||
? undefined
|
||||
: () => onChange({ ...value, [dimension.key]: null })
|
||||
}
|
||||
onValueChange={(next) => {
|
||||
if (dimension.key === "a1") onA1Touched?.();
|
||||
onChange({ ...value, [dimension.key]: next });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SheetSection>
|
||||
);
|
||||
}
|
||||
@@ -87,6 +87,13 @@ import {
|
||||
} from "@/components/data-list";
|
||||
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
|
||||
import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker";
|
||||
import {
|
||||
EMPTY_LEVEL_A,
|
||||
MoodLevelASection,
|
||||
levelAFromEntry,
|
||||
levelAUpdatePayload,
|
||||
type LevelAState,
|
||||
} from "./mood-level-a-fields";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
|
||||
@@ -104,6 +111,12 @@ interface MoodEntry {
|
||||
// v1.8.5 — structured-tag keys + free-text note.
|
||||
tagKeys: string[];
|
||||
ratedFactors: RatedFactor[];
|
||||
// v1.37 — the five level-A values, absent on entries written before them.
|
||||
a1?: number | null;
|
||||
a2?: number | null;
|
||||
a3?: number | null;
|
||||
a4?: number | null;
|
||||
a5?: number | null;
|
||||
note: string | null;
|
||||
source: string;
|
||||
moodLoggedAt: string;
|
||||
@@ -185,6 +198,9 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
const [editTagsInput, setEditTagsInput] = useState("");
|
||||
const [editTagKeys, setEditTagKeys] = useState<string[]>([]);
|
||||
const [editRatedFactors, setEditRatedFactors] = useState<RatedFactor[]>([]);
|
||||
// v1.37 — the five level-A values on the edit path. Seeded from the entry,
|
||||
// so what the capture sheet wrote is visible and changeable here.
|
||||
const [editLevelA, setEditLevelA] = useState<LevelAState>(EMPTY_LEVEL_A);
|
||||
const [editNote, setEditNote] = useState("");
|
||||
const [editMoodLoggedAt, setEditMoodLoggedAt] = useState("");
|
||||
const [editSeed, setEditSeed] = useState<{
|
||||
@@ -192,6 +208,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
tagsInput: string;
|
||||
tagKeys: string[];
|
||||
ratedFactors: RatedFactor[];
|
||||
levelA: LevelAState;
|
||||
note: string;
|
||||
moodLoggedAt: string;
|
||||
}>({
|
||||
@@ -199,6 +216,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
tagsInput: "",
|
||||
tagKeys: [],
|
||||
ratedFactors: [],
|
||||
levelA: EMPTY_LEVEL_A,
|
||||
note: "",
|
||||
moodLoggedAt: "",
|
||||
});
|
||||
@@ -391,6 +409,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
tags,
|
||||
tagKeys,
|
||||
ratedFactors,
|
||||
levelA,
|
||||
note,
|
||||
moodLoggedAt,
|
||||
}: {
|
||||
@@ -399,6 +418,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
tags: string[] | null;
|
||||
tagKeys: string[];
|
||||
ratedFactors: RatedFactor[];
|
||||
levelA: LevelAState;
|
||||
note: string | null;
|
||||
moodLoggedAt: string;
|
||||
}) => {
|
||||
@@ -407,6 +427,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
tags,
|
||||
tagKeys,
|
||||
ratedFactors,
|
||||
// Nulls included: this path replaces rather than adds, so a value the
|
||||
// user cleared has to travel as an explicit null or the old answer
|
||||
// survives an edit that visibly removed it.
|
||||
...levelAUpdatePayload(levelA),
|
||||
note,
|
||||
moodLoggedAt,
|
||||
});
|
||||
@@ -421,6 +445,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
ratedFactors: [...editRatedFactors].sort((a, b) =>
|
||||
a.key.localeCompare(b.key),
|
||||
),
|
||||
levelA: editLevelA,
|
||||
note: editNote,
|
||||
moodLoggedAt: editMoodLoggedAt,
|
||||
});
|
||||
@@ -439,6 +464,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
ratedFactors: [...editRatedFactors].sort((a, b) =>
|
||||
a.key.localeCompare(b.key),
|
||||
),
|
||||
levelA: editLevelA,
|
||||
note: editNote,
|
||||
moodLoggedAt: editMoodLoggedAt,
|
||||
},
|
||||
@@ -455,6 +481,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
ratedFactors: [...(entry.ratedFactors ?? [])].sort((a, b) =>
|
||||
a.key.localeCompare(b.key),
|
||||
),
|
||||
levelA: levelAFromEntry(entry),
|
||||
note: entry.note ?? "",
|
||||
moodLoggedAt: toDateTimeLocalValue(entry.moodLoggedAt),
|
||||
};
|
||||
@@ -463,6 +490,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
setEditTagsInput(seed.tagsInput);
|
||||
setEditTagKeys(seed.tagKeys);
|
||||
setEditRatedFactors(seed.ratedFactors);
|
||||
setEditLevelA(seed.levelA);
|
||||
setEditNote(seed.note);
|
||||
setEditMoodLoggedAt(seed.moodLoggedAt);
|
||||
setEditSeed(seed);
|
||||
@@ -515,6 +543,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
tags: tags.length > 0 ? tags : null,
|
||||
tagKeys: editTagKeys,
|
||||
ratedFactors: editRatedFactors,
|
||||
levelA: editLevelA,
|
||||
note: trimmedNote.length > 0 ? trimmedNote : null,
|
||||
moodLoggedAt: measuredDate.toISOString(),
|
||||
});
|
||||
@@ -1008,6 +1037,13 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* v1.37 — the five level-A values, seeded from the entry.
|
||||
Pleasantness is not re-seeded from the face here: this dialog
|
||||
edits an entry that already has an answer, and replacing it
|
||||
with the derived one would quietly discard what the person
|
||||
set. */}
|
||||
<MoodLevelASection value={editLevelA} onChange={setEditLevelA} />
|
||||
|
||||
{/* v1.8.5 (C1) — free-text note. */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-mood-note">
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* `<Slider>` / `<SliderField>` — 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<React.ComponentProps<typeof SliderField>> = {}) {
|
||||
return renderToStaticMarkup(
|
||||
<SliderField
|
||||
label="Stress"
|
||||
value={7}
|
||||
onValueChange={() => {}}
|
||||
emptyLabel="nicht angegeben"
|
||||
valueText="7 von 10"
|
||||
lowAnchor="vollkommen entspannt"
|
||||
highAnchor="extrem belastet"
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<Slider>", () => {
|
||||
it("renders the Radix parts under their slots", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<Slider value={[4]} min={0} max={10} step={1} />,
|
||||
);
|
||||
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(
|
||||
<Slider
|
||||
value={[6]}
|
||||
min={0}
|
||||
max={10}
|
||||
valueText="6 von 10: sehr unangenehm bis sehr angenehm"
|
||||
thumbLabel="Stimmung"
|
||||
/>,
|
||||
);
|
||||
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("<SliderField>", () => {
|
||||
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"');
|
||||
});
|
||||
});
|
||||
@@ -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<typeof SliderPrimitive.Root> & {
|
||||
/**
|
||||
* 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 (
|
||||
<SliderPrimitive.Root
|
||||
data-slot="slider"
|
||||
className={cn(
|
||||
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50",
|
||||
// The 44 px hit target WCAG 2.5.5 asks for, without a 44 px track:
|
||||
// the padding is vertical only, so the visible rail stays 6 px.
|
||||
"py-[19px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className="bg-muted relative h-1.5 w-full grow overflow-hidden rounded-full"
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
className="bg-primary absolute h-full"
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
aria-label={thumbLabel}
|
||||
aria-valuetext={valueText}
|
||||
className="border-primary bg-background focus-visible:ring-ring/50 block size-4 rounded-full border-2 shadow-sm transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:pointer-events-none"
|
||||
/>
|
||||
</SliderPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<typeof SliderPrimitive.Root>,
|
||||
"value" | "onValueChange" | "min" | "max" | "step" | "className"
|
||||
>) {
|
||||
const unset = value === null;
|
||||
return (
|
||||
<div
|
||||
data-slot="slider-field"
|
||||
data-unset={unset ? "true" : undefined}
|
||||
className={cn("flex flex-col gap-1", className)}
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
{/* Content colour for the label and the value, muted for the anchors
|
||||
below: the anchors are the legend, the value is the answer. */}
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
data-slot="slider-field-value"
|
||||
className={cn(
|
||||
"text-sm tabular-nums",
|
||||
unset ? "text-muted-foreground" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
{unset ? emptyLabel : value}
|
||||
</span>
|
||||
{!unset && onClear ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
data-slot="slider-field-clear"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 rounded text-xs underline-offset-2 outline-none hover:underline focus-visible:ring-[3px]"
|
||||
>
|
||||
{clearLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
// An unanswered field parks the thumb at the low end with an empty
|
||||
// track, so it reads as "nothing here" rather than as the value the
|
||||
// low end happens to carry. The announced text says the same.
|
||||
value={[unset ? min : value]}
|
||||
onValueChange={(next) => onValueChange(next[0])}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
thumbLabel={label}
|
||||
valueText={unset ? emptyLabel : valueText}
|
||||
{...props}
|
||||
/>
|
||||
<div className="text-muted-foreground flex justify-between gap-2 text-xs">
|
||||
<span>{lowAnchor}</span>
|
||||
<span className="text-right">{highAnchor}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Slider, SliderField };
|
||||
Reference in new issue
Block a user