feat(settings): drag-to-reorder dashboard widgets with @dnd-kit

The dashboard layout section already let users reorder widgets via ↑/↓
arrow buttons; the v1.4.43 QoL audit (M1) flagged that modern parity
with Apple Health and Withings web expects drag-to-reorder as the
mouse/touch surface. Adds a GripVertical drag handle on each row wired
to @dnd-kit/sortable. The keyboard fallback stays — arrow buttons are
unchanged and the new handle is also keyboard-operable via dnd-kit's
KeyboardSensor (Tab + Space + arrows).

Both surfaces persist through the same draft → PUT path the arrow
buttons already used, so save / cancel / reset semantics carry over
without changes. Pure reorder logic is extracted into a shared
`reorderWidgets` helper so the mutation payload contract is pinned by
unit tests without spinning up a DndContext.

Bundle delta: ~29 KB gzipped (core 22 + sortable 5 + utilities 2),
well within the 50 KB budget called out in the audit.

All six locales gain `dragHandle` (button label) and `dragHandleHint`
(shared `aria-describedby` paragraph) keys.
This commit is contained in:
Marc-André Bombeck committed 2026-05-22 09:16:30 +02:00
1 parent 3a241dbe35
commit 887d448ecc
10 files changed
+478 -75

No files matched your search

+2
View File
@@ -275,6 +275,8 @@
"moveDown": "Nach unten",
"layoutTileColumn": "Kachel",
"layoutChartColumn": "Chart",
"dragHandle": "Zum Verschieben ziehen",
"dragHandleHint": "Ziehe den Griff, um dieses Widget neu zu sortieren. Du kannst auch die Pfeiltasten Nach oben und Nach unten verwenden, oder den Griff fokussieren und Leertaste plus Pfeiltasten drücken.",
"emptyTitle": "Dein Dashboard ist leer",
"emptyDescription": "Erfasse den ersten Messwert oder Stimmungseintrag. Kacheln und Charts erscheinen automatisch, sobald Daten vorhanden sind.",
"emptyAddMeasurement": "Ersten Messwert erfassen",
+2
View File
@@ -275,6 +275,8 @@
"moveDown": "Move down",
"layoutTileColumn": "Tile",
"layoutChartColumn": "Chart",
"dragHandle": "Drag to reorder",
"dragHandleHint": "Drag the handle to reorder this widget. You can also use the Up and Down arrow buttons, or focus the handle and press Space and the arrow keys.",
"emptyTitle": "Your dashboard is empty",
"emptyDescription": "Log your first measurement or mood entry. Tiles and charts appear automatically as data lands.",
"emptyAddMeasurement": "Add your first measurement",
+2
View File
@@ -275,6 +275,8 @@
"moveDown": "Bajar",
"layoutTileColumn": "Tarjeta",
"layoutChartColumn": "Gráfico",
"dragHandle": "Arrastra para reordenar",
"dragHandleHint": "Arrastra el manejador para reordenar este widget. También puedes usar los botones de flecha Arriba y Abajo, o enfocar el manejador y pulsar Espacio más las flechas.",
"emptyTitle": "Tu panel está vacío",
"emptyDescription": "Registra tu primera medición o estado de ánimo. Las tarjetas y gráficas aparecerán automáticamente en cuanto haya datos.",
"emptyAddMeasurement": "Registrar la primera medición",
+2
View File
@@ -275,6 +275,8 @@
"moveDown": "Descendre",
"layoutTileColumn": "Tuile",
"layoutChartColumn": "Graphique",
"dragHandle": "Glisser pour réordonner",
"dragHandleHint": "Glisse la poignée pour réordonner ce widget. Tu peux aussi utiliser les flèches Haut et Bas, ou cibler la poignée puis appuyer sur Espace plus les flèches.",
"emptyTitle": "Ton tableau de bord est vide",
"emptyDescription": "Saisis ta première mesure ou humeur. Les tuiles et graphiques apparaissent automatiquement dès qu’il y a des données.",
"emptyAddMeasurement": "Saisir la première mesure",
+2
View File
@@ -275,6 +275,8 @@
"moveDown": "Sposta giù",
"layoutTileColumn": "Tile",
"layoutChartColumn": "Grafico",
"dragHandle": "Trascina per riordinare",
"dragHandleHint": "Trascina la maniglia per riordinare questo widget. Puoi anche usare i pulsanti freccia Su e Giù, oppure mettere a fuoco la maniglia e premere Spazio e i tasti freccia.",
"emptyTitle": "La tua dashboard è vuota",
"emptyDescription": "Registra la prima misurazione o un umore. Tile e grafici appaiono automaticamente non appena ci sono dati.",
"emptyAddMeasurement": "Registra la prima misurazione",
+2
View File
@@ -275,6 +275,8 @@
"moveDown": "W dół",
"layoutTileColumn": "Kafelek",
"layoutChartColumn": "Wykres",
"dragHandle": "Przeciągnij, aby zmienić kolejność",
"dragHandleHint": "Przeciągnij uchwyt, aby zmienić kolejność tego widżetu. Możesz też użyć przycisków strzałek W górę i W dół albo skupić uchwyt i wcisnąć Spację oraz strzałki.",
"emptyTitle": "Twój panel jest pusty",
"emptyDescription": "Zarejestruj pierwszy pomiar lub nastrój. Kafelki i wykresy pojawią się automatycznie, gdy będą dane.",
"emptyAddMeasurement": "Zarejestruj pierwszy pomiar",
+3
View File
@@ -50,6 +50,9 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1048.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@node-rs/argon2": "^2.0.2",
"@parse/node-apn": "^8.1.0",
+56
View File
@@ -11,6 +11,15 @@ importers:
'@aws-sdk/client-s3':
specifier: ^3.1048.0
version: 3.1048.0
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@dnd-kit/sortable':
specifier: ^10.0.0
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.5)
'@hookform/resolvers':
specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.75.0(react@19.2.5))
@@ -476,6 +485,28 @@ packages:
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
engines: {node: '>=10.0.0'}
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
react: '>=16.8.0'
'@dnd-kit/core@6.3.1':
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@dnd-kit/sortable@10.0.0':
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
peerDependencies:
'@dnd-kit/core': ^6.3.0
react: '>=16.8.0'
'@dnd-kit/utilities@3.2.2':
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
peerDependencies:
react: '>=16.8.0'
'@dotenvx/dotenvx@1.65.0':
resolution: {integrity: sha512-v4FA/Lw3pTEloLxBqTOaYDX6MNo0Jo7lGBsPZhwnJBqRJp0AzQg1ZZNxrFsh6HVC6QWeWrfIKLn0y2eyIXaVDg==}
hasBin: true
@@ -6884,6 +6915,31 @@ snapshots:
'@discoveryjs/json-ext@0.5.7': {}
'@dnd-kit/accessibility@3.1.1(react@19.2.5)':
dependencies:
react: 19.2.5
tslib: 2.8.1
'@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@dnd-kit/accessibility': 3.1.1(react@19.2.5)
'@dnd-kit/utilities': 3.2.2(react@19.2.5)
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
tslib: 2.8.1
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)':
dependencies:
'@dnd-kit/core': 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@dnd-kit/utilities': 3.2.2(react@19.2.5)
react: 19.2.5
tslib: 2.8.1
'@dnd-kit/utilities@3.2.2(react@19.2.5)':
dependencies:
react: 19.2.5
tslib: 2.8.1
'@dotenvx/dotenvx@1.65.0':
dependencies:
commander: 11.1.0
@@ -10,6 +10,12 @@ import { DEFAULT_DASHBOARD_LAYOUT } from "@/lib/dashboard-layout";
* tile, distinct from the existing chart switch. SSR smoke tests are
* sufficient here; full state-mutation testing would require a DOM
* runtime which the rest of the settings suite doesn't pull in.
*
* v1.4.47 W4 — drag-and-drop reorder via @dnd-kit. The SSR markup picks
* up the new drag handle, the shared describedby hint paragraph, and
* keeps the legacy arrow buttons + switches. The reorder contract is
* exercised via the exported `reorderWidgets` helper so we pin the
* mutation payload shape without a DOM runtime.
*/
vi.mock("@tanstack/react-query", () => ({
@@ -30,7 +36,10 @@ vi.mock("sonner", () => ({
}));
import { I18nProvider } from "@/lib/i18n/context";
import { DashboardLayoutSection } from "../dashboard-layout-section";
import {
DashboardLayoutSection,
reorderWidgets,
} from "../dashboard-layout-section";
function render(node: React.ReactElement, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
@@ -70,3 +79,111 @@ describe("<DashboardLayoutSection> — tile + chart split", () => {
expect(chartSwitches).toHaveLength(DEFAULT_DASHBOARD_LAYOUT.widgets.length);
});
});
/**
* v1.4.47 W4 — drag-and-drop reorder surface.
*
* The audit (v1.4.43 QoL M1) asked for drag-to-reorder while keeping the
* arrow-button keyboard fallback. Tests pin (a) the visual surface
* (drag handle + describedby hint), (b) the keyboard fallback survived,
* (c) the reorder contract — same `widgets[]` shape with `order: 0..n-1`
* the existing PUT already accepts.
*/
describe("<DashboardLayoutSection> — drag-and-drop reorder", () => {
it("paints a drag handle for every widget row", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const handles = html.match(/data-slot="widget-drag-handle"/g) ?? [];
expect(handles).toHaveLength(DEFAULT_DASHBOARD_LAYOUT.widgets.length);
});
it("each drag handle has an aria-describedby pointing to a shared hint", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// Split the markup into per-handle button fragments so we don't need
// to care about attribute ordering inside the rendered <button>.
const handleButtons =
html.match(/<button[^>]*data-slot="widget-drag-handle"[^>]*>/g) ?? [];
expect(handleButtons).toHaveLength(DEFAULT_DASHBOARD_LAYOUT.widgets.length);
// Every handle declares aria-describedby and they all share the
// same id (one hint paragraph below the list — single source of
// truth for screen readers).
const describedByIds = handleButtons.map((button) => {
const m = button.match(/aria-describedby="([^"]+)"/);
expect(m).not.toBeNull();
return m![1];
});
const unique = new Set(describedByIds);
expect(unique.size).toBe(1);
const hintId = describedByIds[0];
// The matching hint paragraph is rendered exactly once.
expect(html).toMatch(new RegExp(`id="${hintId}"`));
});
it("keeps the arrow buttons present after the drag handle lands (a11y fallback)", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// Move up + Move down translation keys resolve to the English
// strings — counted once per row.
const moveUpCount = (html.match(/aria-label="Move up"/g) ?? []).length;
const moveDownCount = (html.match(/aria-label="Move down"/g) ?? []).length;
expect(moveUpCount).toBe(DEFAULT_DASHBOARD_LAYOUT.widgets.length);
expect(moveDownCount).toBe(DEFAULT_DASHBOARD_LAYOUT.widgets.length);
});
it("hint string localises to German", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />, "de");
// Anchor on a stable German fragment from the hint copy.
expect(html).toContain("Ziehe den Griff");
});
});
describe("reorderWidgets — pure mutation contract", () => {
// Synthetic small list so the assertions are readable; the helper is
// shape-only and doesn't care which ids are dashboard widgets.
const initial = [
{ id: "a", order: 0 },
{ id: "b", order: 1 },
{ id: "c", order: 2 },
{ id: "d", order: 3 },
];
it("moves an item down and rewrites order to 0..n-1", () => {
const out = reorderWidgets(initial, "a", "c");
expect(out.map((w) => w.id)).toEqual(["b", "c", "a", "d"]);
expect(out.map((w) => w.order)).toEqual([0, 1, 2, 3]);
});
it("moves an item up and rewrites order to 0..n-1", () => {
const out = reorderWidgets(initial, "d", "a");
expect(out.map((w) => w.id)).toEqual(["d", "a", "b", "c"]);
expect(out.map((w) => w.order)).toEqual([0, 1, 2, 3]);
});
it("no-op when source equals target", () => {
const out = reorderWidgets(initial, "b", "b");
expect(out.map((w) => w.id)).toEqual(["a", "b", "c", "d"]);
});
it("no-op when an id is missing — still normalises order", () => {
const out = reorderWidgets(initial, "missing", "a");
expect(out.map((w) => w.id)).toEqual(["a", "b", "c", "d"]);
expect(out.map((w) => w.order)).toEqual([0, 1, 2, 3]);
});
it("preserves stable sort by `order` before reordering", () => {
const shuffled = [
{ id: "c", order: 2 },
{ id: "a", order: 0 },
{ id: "d", order: 3 },
{ id: "b", order: 1 },
];
const out = reorderWidgets(shuffled, "a", "d");
// Sort first → [a, b, c, d]; move a → d position → [b, c, d, a].
expect(out.map((w) => w.id)).toEqual(["b", "c", "d", "a"]);
expect(out.map((w) => w.order)).toEqual([0, 1, 2, 3]);
});
it("returns a fresh array — does not mutate the input", () => {
const snapshot = JSON.parse(JSON.stringify(initial));
reorderWidgets(initial, "a", "c");
expect(initial).toEqual(snapshot);
});
});
@@ -1,7 +1,7 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useId, useState } from "react";
import { toast } from "sonner";
import {
LayoutDashboard,
@@ -9,7 +9,24 @@ import {
Loader2,
ArrowUp,
ArrowDown,
GripVertical,
} from "lucide-react";
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
@@ -29,6 +46,30 @@ import {
SelectValue,
} from "@/components/ui/select";
/**
* v1.4.47 W4 — pure reorder helper shared by the arrow buttons and the
* @dnd-kit drag-end handler. Both surfaces produce the same `widgets[]`
* shape (`order: 0..n-1`) so the existing PUT contract stays untouched
* and either input mode flushes via the same Save mutation. Exported so
* the unit test can pin the contract without spinning up a DndContext.
*/
export function reorderWidgets(
widgets: readonly { id: string; order: number }[],
fromId: string,
toId: string,
): { id: string; order: number }[] {
const sorted = [...widgets].sort((a, b) => a.order - b.order);
const fromIdx = sorted.findIndex((w) => w.id === fromId);
const toIdx = sorted.findIndex((w) => w.id === toId);
if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) {
return sorted.map((w, i) => ({ ...w, order: i }));
}
const next = [...sorted];
const [removed] = next.splice(fromIdx, 1);
next.splice(toIdx, 0, removed);
return next.map((w, i) => ({ ...w, order: i }));
}
const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
weight: "dashboard.weight",
bp: "dashboard.bloodPressure",
@@ -53,6 +94,25 @@ const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
export function DashboardLayoutSection({ id }: { id: string }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
// v1.4.47 W4 — stable id namespace for the drag-handle `aria-describedby`
// tooltip. One hint paragraph is rendered once at the bottom of the list
// and referenced by every drag handle in this section.
const dragHintId = useId();
// v1.4.47 W4 — sensors: pointer for mouse/touch, keyboard for Tab + Space
// + arrow-key reordering. The KeyboardSensor still works for users who
// tab to the GripVertical handle; the legacy ArrowUp / ArrowDown buttons
// below remain the primary keyboard surface for accessibility.
const sensors = useSensors(
useSensor(PointerSensor, {
// Activation distance avoids the drag stealing every click on the row
// switches — only a 6 px pointer-down → move counts as drag intent.
activationConstraint: { distance: 6 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const { data: remote, isLoading } = useQuery({
queryKey: queryKeys.dashboardWidgets(),
@@ -152,6 +212,37 @@ export function DashboardLayoutSection({ id }: { id: string }) {
});
}
/**
* v1.4.47 W4 — drag-and-drop reorder via @dnd-kit. Persists the same
* `order` rewrite shape the arrow buttons already use, so save / cancel
* / reset and the existing draft state machine work unchanged. The
* pointer + keyboard sensors are wired in the same `useSensors` call;
* keyboard a11y still also works through the legacy arrow buttons that
* remain on every row.
*/
function handleDragEnd(event: DragEndEvent) {
if (!layout) return;
const { active, over } = event;
if (!over || active.id === over.id) return;
const reordered = reorderWidgets(
layout.widgets,
String(active.id),
String(over.id),
);
// `reorderWidgets` operates on the pure { id, order } shape; map back
// to the full DashboardWidgetConfig so `visible` / `tileVisible` are
// preserved on every row.
const byId = new Map(layout.widgets.map((w) => [w.id, w]));
setDraft({
...layout,
widgets: reordered.map((r, i) => {
const original = byId.get(r.id as DashboardWidgetId);
if (!original) return { ...r, order: i } as never;
return { ...original, order: i };
}),
});
}
// Presence of a draft implies dirty — no JSON comparison needed.
const dirty = draft !== null && layout !== null;
@@ -259,7 +350,11 @@ export function DashboardLayoutSection({ id }: { id: string }) {
right-hand spacer reserves the width of two
size-11/sm:size-9 buttons so the Tile / Chart column
headers continue to line up with the switches below. */}
{/* v1.4.47 W4 — column header spacer additionally reserves the
width of the new drag-handle icon (w-7) so Tile / Chart
alignment with the row switches below stays pixel-perfect. */}
<div className="text-muted-foreground flex items-center gap-2 px-3 pb-1 text-[10px] font-medium tracking-wide uppercase">
<span className="w-7" aria-hidden="true" />
<span className="flex-1" aria-hidden="true" />
<span className="w-12 text-center">
{t("dashboard.layoutTileColumn")}
@@ -267,83 +362,58 @@ export function DashboardLayoutSection({ id }: { id: string }) {
<span className="w-12 text-center">
{t("dashboard.layoutChartColumn")}
</span>
<span
className="w-22 sm:w-18"
aria-hidden="true"
/>
<span className="w-22 sm:w-18" aria-hidden="true" />
</div>
{[...layout.widgets]
.sort((a, b) => a.order - b.order)
.map((widget, index, arr) => {
const labelKey = WIDGET_LABEL_KEYS[widget.id] ?? widget.id;
const tileChecked =
typeof widget.tileVisible === "boolean"
? widget.tileVisible
: widget.visible;
return (
// v1.4.29 — drag-list row compactness. The legacy
// vertical 44+44-px arrow stack inflated each row
// to ~116 px; the horizontal arrow pair on the
// trailing edge drops the row to 48 px (`min-h-12`)
// while preserving the 44-px mobile tap target
// (`size-11`). `sm:size-9` shrinks the buttons
// visually on desktop where pointer accuracy is
// higher.
<div
key={widget.id}
className="border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2"
{(() => {
const sortedWidgets = [...layout.widgets].sort(
(a, b) => a.order - b.order,
);
const sortedIds = sortedWidgets.map((w) => w.id);
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortedIds}
strategy={verticalListSortingStrategy}
>
<span
className="flex-1 truncate text-sm"
title={t(labelKey)}
>
{t(labelKey)}
</span>
<div className="flex w-12 justify-center">
<Switch
checked={tileChecked}
onCheckedChange={(v) => toggleTile(widget.id, v)}
aria-label={`${t(labelKey)} — ${t("dashboard.layoutTileColumn")}`}
{sortedWidgets.map((widget, index, arr) => (
<SortableWidgetRow
key={widget.id}
widget={widget}
labelKey={WIDGET_LABEL_KEYS[widget.id] ?? widget.id}
index={index}
total={arr.length}
dragHintId={dragHintId}
disabled={saveMutation.isPending}
data-slot="widget-tile-switch"
labels={{
tileColumn: t("dashboard.layoutTileColumn"),
chartColumn: t("dashboard.layoutChartColumn"),
moveUp: t("dashboard.moveUp"),
moveDown: t("dashboard.moveDown"),
dragHandle: t("dashboard.dragHandle"),
widgetLabel: t(
WIDGET_LABEL_KEYS[widget.id] ?? widget.id,
),
}}
onToggleTile={toggleTile}
onToggleChart={toggle}
onMove={move}
/>
</div>
<div className="flex w-12 justify-center">
<Switch
checked={widget.visible}
onCheckedChange={(v) => toggle(widget.id, v)}
aria-label={`${t(labelKey)} — ${t("dashboard.layoutChartColumn")}`}
disabled={saveMutation.isPending}
data-slot="widget-chart-switch"
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => move(widget.id, -1)}
disabled={index === 0 || saveMutation.isPending}
aria-label={t("dashboard.moveUp")}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => move(widget.id, 1)}
disabled={
index === arr.length - 1 || saveMutation.isPending
}
aria-label={t("dashboard.moveDown")}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
);
})}
))}
</SortableContext>
</DndContext>
);
})()}
{/* v1.4.47 W4 — single shared aria-describedby target for all
drag handles. Screen readers read this once per focused
handle; sighted users see it in the native browser tooltip
via the matching `title` attribute on each handle. */}
<p id={dragHintId} className="text-muted-foreground sr-only">
{t("dashboard.dragHandleHint")}
</p>
</div>
)}
@@ -382,3 +452,148 @@ export function DashboardLayoutSection({ id }: { id: string }) {
</div>
);
}
/**
* v1.4.47 W4 — sortable row primitive extracted from the section render
* so the @dnd-kit `useSortable` hook stays scoped to one row. Translation
* strings are passed in pre-resolved (rather than calling `useTranslations`
* inside) so this component stays cheap to re-render for the 13+ rows.
*
* The drag handle is the only listener-bearing surface — the row body
* stays click-through so the switches and arrow buttons keep working.
* Pointer activation has a 6 px distance constraint (configured on the
* parent sensor) so a tap on the handle never accidentally drags.
*/
interface SortableWidgetRowProps {
widget: {
id: DashboardWidgetId;
visible: boolean;
tileVisible?: boolean;
order: number;
};
labelKey: string;
index: number;
total: number;
dragHintId: string;
disabled: boolean;
labels: {
tileColumn: string;
chartColumn: string;
moveUp: string;
moveDown: string;
dragHandle: string;
widgetLabel: string;
};
onToggleTile: (id: DashboardWidgetId, value: boolean) => void;
onToggleChart: (id: DashboardWidgetId, value: boolean) => void;
onMove: (id: DashboardWidgetId, delta: -1 | 1) => void;
}
function SortableWidgetRow({
widget,
index,
total,
dragHintId,
disabled,
labels,
onToggleTile,
onToggleChart,
onMove,
}: SortableWidgetRowProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: widget.id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
};
const tileChecked =
typeof widget.tileVisible === "boolean"
? widget.tileVisible
: widget.visible;
return (
// v1.4.29 — row sized at 48 px (`min-h-12`) with 44-px mobile tap
// targets preserved on the trailing arrow buttons (`size-11`),
// shrunk to `sm:size-9` on desktop. v1.4.47 W4 — `isDragging`
// raises the row visually (elevation + accent ring) so the ghost
// overlay is unambiguous during a drag.
<div
ref={setNodeRef}
style={style}
data-slot="widget-row"
data-dragging={isDragging ? "true" : undefined}
className={`border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2 ${
isDragging ? "ring-primary z-10 opacity-90 shadow-lg ring-2" : ""
}`}
>
<button
type="button"
{...attributes}
{...listeners}
aria-label={`${labels.dragHandle} — ${labels.widgetLabel}`}
// v1.4.47 W4 — `aria-describedby` is set after `{...attributes}`
// so our shared hint paragraph wins over dnd-kit's own announcer
// hookup. The announcer still fires on drag-start / drag-over /
// drag-end via the screenReaderInstructions slot below.
aria-describedby={dragHintId}
title={labels.dragHandle}
disabled={disabled}
data-slot="widget-drag-handle"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background -m-1 inline-flex h-7 w-7 cursor-grab touch-none items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-50"
>
<GripVertical className="h-4 w-4" />
</button>
<span className="flex-1 truncate text-sm" title={labels.widgetLabel}>
{labels.widgetLabel}
</span>
<div className="flex w-12 justify-center">
<Switch
checked={tileChecked}
onCheckedChange={(v) => onToggleTile(widget.id, v)}
aria-label={`${labels.widgetLabel} — ${labels.tileColumn}`}
disabled={disabled}
data-slot="widget-tile-switch"
/>
</div>
<div className="flex w-12 justify-center">
<Switch
checked={widget.visible}
onCheckedChange={(v) => onToggleChart(widget.id, v)}
aria-label={`${labels.widgetLabel} — ${labels.chartColumn}`}
disabled={disabled}
data-slot="widget-chart-switch"
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => onMove(widget.id, -1)}
disabled={index === 0 || disabled}
aria-label={labels.moveUp}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => onMove(widget.id, 1)}
disabled={index === total - 1 || disabled}
aria-label={labels.moveDown}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
);
}