mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
3a241dbe35
commit
887d448ecc
10 files changed
+478
-75
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+56
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user