diff --git a/client/src/Components/inputs/Dialog.tsx b/client/src/Components/inputs/Dialog.tsx index 80d260a55..dafc3845a 100644 --- a/client/src/Components/inputs/Dialog.tsx +++ b/client/src/Components/inputs/Dialog.tsx @@ -27,6 +27,7 @@ export const DialogInput = ({ fullWidth = false, additionalButtons, cancelSx, + onEscape, }: { open: boolean; title?: string; @@ -43,6 +44,12 @@ export const DialogInput = ({ additionalButtons?: ReactNode; /** Styles the cancel button — e.g. an auto margin to push it left. */ cancelSx?: SxProps; + /** + * Called when Escape is pressed. Defaults to `onCancel`, which is right when + * cancel simply dismisses — but not when it does something else, so a dialog + * whose cancel slot carries another action passes its own dismiss here. + */ + onEscape?(): void; }) => { const { t } = useTranslation(); const theme = useTheme(); @@ -52,7 +59,12 @@ export const DialogInput = ({ maxWidth={maxWidth} fullWidth={fullWidth} onClose={(_event, reason) => { - if (reason !== "backdropClick" && onCancel) onCancel(undefined); + if (reason === "backdropClick") return; + if (onEscape) { + onEscape(); + return; + } + if (onCancel) onCancel(undefined); }} > {title && ( diff --git a/client/src/Pages/Dashboard/Components/CardPicker.tsx b/client/src/Pages/Dashboard/Components/CardPicker.tsx index 2ff927142..a01346926 100644 --- a/client/src/Pages/Dashboard/Components/CardPicker.tsx +++ b/client/src/Pages/Dashboard/Components/CardPicker.tsx @@ -1,4 +1,5 @@ import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import FormControlLabel from "@mui/material/FormControlLabel"; import { useTheme } from "@mui/material/styles"; @@ -59,19 +60,34 @@ export const CardPicker = ({ onCancel={onReset} cancelText={t("pages.dashboard.picker.reset")} cancelSx={{ marginRight: "auto" }} + // Escape must dismiss, not reset. The dialog sends Escape to onCancel by + // default, and this one's cancel slot resets the selection — so without + // this, Escape would wipe the user's cards instead of closing. + onEscape={onClose} // Changes apply immediately, so Done only dismisses; it is the primary // action here and carries the usual contained styling. onConfirm={onClose} confirmText={t("pages.dashboard.picker.done")} > - {grouped.map(({ group, cards }) => ( ))} - + ); }; diff --git a/client/src/Pages/Dashboard/useCardSelection.ts b/client/src/Pages/Dashboard/useCardSelection.ts index a8e939c0a..7d8ae5530 100644 --- a/client/src/Pages/Dashboard/useCardSelection.ts +++ b/client/src/Pages/Dashboard/useCardSelection.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { logger } from "@/Utils/logger"; import { useIsAdmin } from "@/Hooks/useIsAdmin"; @@ -50,7 +50,17 @@ export const useCardSelection = () => { () => readStored() ?? DEFAULT_CARD_IDS ); + // Skip the write on mount. The value has just been read from storage, so + // rewriting it achieves nothing — but if the read failed for any reason and + // fell back to the defaults, that write would overwrite the user's real + // selection and make the loss permanent. + const hasMounted = useRef(false); + useEffect(() => { + if (!hasMounted.current) { + hasMounted.current = true; + return; + } try { window.localStorage.setItem(DASHBOARD_CARDS_STORAGE_KEY, JSON.stringify(selected)); } catch (error) {