diff --git a/messages/de.json b/messages/de.json index 077e4b884..00b4e95d0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index bef0288ca..00a064ed4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 740945af1..2cd430fbe 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 3c367017e..2ad38aa29 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index d84b56155..26b6a437a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index ed19fcf71..739c9d2e5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/package.json b/package.json index 680a77dce..858fce745 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d301c2f73..23c313fea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/components/settings/__tests__/dashboard-layout-section.test.tsx b/src/components/settings/__tests__/dashboard-layout-section.test.tsx index 708e281a8..8882eac33 100644 --- a/src/components/settings/__tests__/dashboard-layout-section.test.tsx +++ b/src/components/settings/__tests__/dashboard-layout-section.test.tsx @@ -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(" — 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(" — drag-and-drop reorder", () => { + it("paints a drag handle for every widget row", () => { + const html = render(); + 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(); + // Split the markup into per-handle button fragments so we don't need + // to care about attribute ordering inside the rendered - - - ); - })} + ))} + + + ); + })()} + {/* 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. */} +

+ {t("dashboard.dragHandleHint")} +

)} @@ -382,3 +452,148 @@ export function DashboardLayoutSection({ id }: { id: string }) { ); } + +/** + * 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. +
+ + + {labels.widgetLabel} + +
+ onToggleTile(widget.id, v)} + aria-label={`${labels.widgetLabel} — ${labels.tileColumn}`} + disabled={disabled} + data-slot="widget-tile-switch" + /> +
+
+ onToggleChart(widget.id, v)} + aria-label={`${labels.widgetLabel} — ${labels.chartColumn}`} + disabled={disabled} + data-slot="widget-chart-switch" + /> +
+ + +
+ ); +}