fix(data-list): lift the selection bar above the mobile nav and fix tap targets

The page-scoped selection bar sat sticky bottom-2, behind the fixed
64px mobile bottom-nav, so the destructive Delete (N) button was
unreachable on phones and ignored the iOS home-indicator inset. Lift
it to clear the nav band + safe area on mobile and drop back to
bottom-4 at sm+ where the nav is hidden.

The per-row select checkbox wrapped a Radix checkbox (a 16px button)
in a 44px label; a label does not forward taps to a button, so the
effective target failed WCAG 2.5.5. Make the 44px area a single
button that owns the toggle, with the checkbox a pointer-events-none
controlled visual, avoiding any double-toggle.

Bring the mood pagination buttons to the 44px target used by the
measurements list. Add (sort: Desc) to the two source-filter indexes
so the schema matches the DESC migration SQL; schema-fidelity only,
no new migration.
This commit is contained in:
Marc-André Bombeck committed 2026-06-07 19:48:50 +02:00
1 parent f2bd051fa7
commit 4038a34ae6
10 files changed
+71 -14

No files matched your search

+2
View File
@@ -546,6 +546,8 @@
"deleteConfirmTitle": "Eintrag löschen?",
"deleteConfirmDescription": "Dieser Stimmungseintrag wird unwiderruflich gelöscht.",
"pageInfo": "Seite {page} von {total}",
"previousPage": "Vorherige Seite",
"nextPage": "Nächste Seite",
"loginRequired": "Bitte anmelden, um Stimmungseinträge zu sehen.",
"note": "Notiz",
"notePlaceholder": "Notiz dazu, wie du dich fühlst…",
+2
View File
@@ -546,6 +546,8 @@
"deleteConfirmTitle": "Delete entry?",
"deleteConfirmDescription": "This mood entry will be permanently deleted.",
"pageInfo": "Page {page} of {total}",
"previousPage": "Previous page",
"nextPage": "Next page",
"loginRequired": "Please sign in to view mood entries.",
"note": "Note",
"notePlaceholder": "Add a note about how you're feeling…",
+2
View File
@@ -546,6 +546,8 @@
"deleteConfirmTitle": "¿Eliminar entrada?",
"deleteConfirmDescription": "Esta entrada de estado de ánimo se eliminará de forma permanente.",
"pageInfo": "Página {page} de {total}",
"previousPage": "Página anterior",
"nextPage": "Página siguiente",
"loginRequired": "Inicia sesión para ver tus estados de ánimo.",
"note": "Nota",
"notePlaceholder": "Añade una nota sobre cómo te sientes…",
+2
View File
@@ -546,6 +546,8 @@
"deleteConfirmTitle": "Supprimer l’entrée ?",
"deleteConfirmDescription": "Cette entrée d’humeur sera supprimée définitivement.",
"pageInfo": "Page {page} sur {total}",
"previousPage": "Page précédente",
"nextPage": "Page suivante",
"loginRequired": "Connecte-toi pour voir tes entrées d’humeur.",
"note": "Note",
"notePlaceholder": "Ajoutez une note sur votre ressenti…",
+2
View File
@@ -546,6 +546,8 @@
"deleteConfirmTitle": "Eliminare la voce?",
"deleteConfirmDescription": "Questa voce dell’umore sarà eliminata in modo definitivo.",
"pageInfo": "Pagina {page} di {total}",
"previousPage": "Pagina precedente",
"nextPage": "Pagina successiva",
"loginRequired": "Accedi per vedere le voci dell’umore.",
"note": "Nota",
"notePlaceholder": "Aggiungi una nota su come ti senti…",
+2
View File
@@ -546,6 +546,8 @@
"deleteConfirmTitle": "Usunąć wpis?",
"deleteConfirmDescription": "Ten wpis nastroju zostanie nieodwracalnie usunięty.",
"pageInfo": "Strona {page} z {total}",
"previousPage": "Poprzednia strona",
"nextPage": "Następna strona",
"loginRequired": "Zaloguj się, aby zobaczyć wpisy nastroju.",
"note": "Notatka",
"notePlaceholder": "Dodaj notatkę o swoim samopoczuciu…",
+2 -2
View File
@@ -853,7 +853,7 @@ model Measurement {
// v1.15.13 — leads with `(userId, source)` so the management list's
// source filter + recency sort is an index range scan instead of a
// `userId` scan + in-memory sort. See migration 0136.
@@index([userId, source, measuredAt])
@@index([userId, source, measuredAt(sort: Desc)])
@@index([externalId])
// v1.7.0 — supports the `/api/sync/changes` keyset feed, which walks
// `where userId` ordered by `(updatedAt asc, id asc)` with `take
@@ -2089,7 +2089,7 @@ model MoodEntry {
// v1.15.13 — leads with `(userId, source)` so the mood management
// list's source filter + recency sort is an index range scan. See
// migration 0136.
@@index([userId, source, moodLoggedAt])
@@index([userId, source, moodLoggedAt(sort: Desc)])
// v1.7.0 sync — keyset support for the `/api/sync/changes` mood feed
// (`where userId` ordered by `(updatedAt asc, id asc)`).
@@index([userId, updatedAt, id])
@@ -53,7 +53,15 @@ export function SelectionActionBar({
<div
role="region"
aria-label={t("dataList.selectionBarLabel")}
className="bg-card border-border sticky bottom-2 z-10 mt-2 flex items-center justify-between gap-2 rounded-lg border p-2 shadow-lg sm:bottom-4"
// v1.15.13 HIGH-1 — on mobile the fixed bottom-nav (64px, `md:hidden`,
// z-50) plus the iOS home-indicator inset sit over the page's lower
// edge. A `sticky bottom-2` bar lands BEHIND the nav, hiding the
// destructive "Delete (N)" button. Lift the bar to clear the nav band
// + safe area using the same offset the Coach FAB uses
// (`layout-coach-fab.tsx`); at `sm:`/desktop the nav is gone, so drop
// back to `bottom-4`. z-30 keeps the bar above page content without
// needing to beat the (now-cleared) nav's z-50.
className="bg-card border-border sticky bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] z-30 mt-2 flex items-center justify-between gap-2 rounded-lg border p-2 shadow-lg sm:bottom-4"
>
<div className="flex items-center gap-1">
<Button
@@ -1056,13 +1056,30 @@ export function MeasurementList({
{/* v1.15.13 — multi-select checkbox in a 44px tap
target; absent for synthetic grouped rows. */}
{!isGrouped && (
<label className="flex size-11 shrink-0 items-center justify-center">
// v1.15.13 MEDIUM-1 — a Radix Checkbox renders a
// 16px `<button role=checkbox>`; a wrapping
// `<label>` does NOT forward taps to a button (label
// forwarding only works for native form controls),
// so the effective tap target was 16px (fails WCAG
// 2.5.5). The 44px button below owns the whole hit
// area and is the single toggle source; the inner
// Checkbox is a `pointer-events-none` controlled
// visual, so a tap can fire the handler exactly once.
<button
type="button"
role="checkbox"
aria-checked={isSelected}
aria-label={t("dataList.selectRow")}
onClick={() => onToggleRow(m.id)}
className="focus-visible:ring-ring/50 flex size-11 shrink-0 items-center justify-center rounded focus-visible:ring-2 focus-visible:outline-none"
>
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleRow(m.id)}
aria-label={t("dataList.selectRow")}
tabIndex={-1}
aria-hidden="true"
className="pointer-events-none"
/>
</label>
</button>
)}
{Icon && (
<div
+27 -7
View File
@@ -695,14 +695,28 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
className="bg-card border-border flex items-center justify-between rounded-lg border p-3 data-[state=selected]:border-dracula-purple/60 data-[state=selected]:bg-dracula-purple/5"
>
<div className="flex items-center gap-2 overflow-hidden">
{/* v1.15.13 — multi-select checkbox in a 44px target. */}
<label className="flex size-11 shrink-0 items-center justify-center">
{/* v1.15.13 MEDIUM-1 — a Radix Checkbox renders a 16px
`<button role=checkbox>`; a wrapping `<label>` does
NOT forward taps to a button, so the effective tap
target was 16px (fails WCAG 2.5.5). The 44px button
owns the whole hit area and is the single toggle
source; the inner Checkbox is a `pointer-events-none`
controlled visual, so a tap fires the handler once. */}
<button
type="button"
role="checkbox"
aria-checked={isSelected}
aria-label={t("dataList.selectRow")}
onClick={() => onToggleRow(entry.id)}
className="focus-visible:ring-ring/50 flex size-11 shrink-0 items-center justify-center rounded focus-visible:ring-2 focus-visible:outline-none"
>
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleRow(entry.id)}
aria-label={t("dataList.selectRow")}
tabIndex={-1}
aria-hidden="true"
className="pointer-events-none"
/>
</label>
</button>
<div className="bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
<span
data-testid="mood-row-score"
@@ -785,19 +799,25 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
})}
</span>
<div className="flex gap-1">
{/* v1.15.13 L2 — match the measurements list's 44px pagination
target (was `size="sm"`, below the WCAG 2.5.5 floor). */}
<Button
variant="ghost"
size="sm"
size="icon"
className="size-11"
disabled={page <= 1}
onClick={() => goToPage((p) => p - 1)}
aria-label={t("mood.previousPage")}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
size="icon"
className="size-11"
disabled={page >= totalPages}
onClick={() => goToPage((p) => p + 1)}
aria-label={t("mood.nextPage")}
>
<ChevronRight className="h-4 w-4" />
</Button>