From a6180a93b6ec04b21dfab37e7895f9139d097a86 Mon Sep 17 00:00:00 2001 From: Pixnop <77785313+Pixnop@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:33:40 +0200 Subject: [PATCH 1/5] Fold the repeated confirm dialogs into one ConfirmDialog Eight prompts spelled out the same shell: a question, a muted consequence line, and a ButtonsWrapper holding a secondary Cancel and a confirm. Six were inline, two were already components around the same shape. ConfirmDialog holds it once. Cancel stays first in the DOM, so the focus trap still lands on it. The Manage Backups delete prompt moves from NormalButton to FormButton, which is what the other seven already used. --- .../src/components/ui/ConfirmDialog.tsx | 65 ++++++++++++++++ .../installations/pages/ListInstallations.tsx | 26 +++---- .../pages/ManageInstallationBackups.tsx | 71 +++++++----------- .../mods/components/DeleteModDialog.tsx | 42 +++++------ .../components/RemoveServerModsDialog.tsx | 48 ++++++------ .../pages/ManageInstallationServers.tsx | 23 +++--- .../features/versions/pages/ListVersions.tsx | 75 +++++++++---------- 7 files changed, 197 insertions(+), 153 deletions(-) create mode 100644 src/renderer/src/components/ui/ConfirmDialog.tsx diff --git a/src/renderer/src/components/ui/ConfirmDialog.tsx b/src/renderer/src/components/ui/ConfirmDialog.tsx new file mode 100644 index 00000000..7d6b65c8 --- /dev/null +++ b/src/renderer/src/components/ui/ConfirmDialog.tsx @@ -0,0 +1,65 @@ +import { useTranslation } from "react-i18next" +import { PiXCircleDuotone } from "react-icons/pi" + +import PopupDialogPanel from "@renderer/components/ui/PopupDialogPanel" +import { ButtonsWrapper, FormButton } from "@renderer/components/ui/FormComponents" +import type { ButtonVariant } from "@renderer/components/ui/buttonStyles" + +/** + * The launcher's one "are you sure?" shell: a question, what it costs, and two actions. + * + * Cancel comes first in the DOM on purpose, and every caller now gets that order for free. Headless + * UI's focus trap focuses the first focusable child of the panel, so a player who hits Enter on a + * prompt they were not expecting backs out instead of confirming. + * + * It owns no copy but the Cancel button: every other string reads off whatever the player picked. + */ +function ConfirmDialog({ + title, + isOpen, + close, + question, + consequence, + confirmLabel, + confirmIcon, + confirmVariant = "destructive", + onConfirm, + children, + beforeActions +}: Readonly<{ + title: string + isOpen: boolean + close: () => void + /** Left out by a prompt whose subject is a block of its own, passed as `children`. */ + question?: string + /** What confirming costs, in the muted colour tests/text-contrast.test.ts measures. */ + consequence?: string + confirmLabel: string + confirmIcon: React.ReactNode + /** Destructive unless confirming gives something back, as restoring a version does. */ + confirmVariant?: ButtonVariant + onConfirm: (e: React.MouseEvent) => void | Promise + /** Detail between the question and the consequence: the names about to go, a warning box. */ + children?: React.ReactNode + /** A choice the player makes before confirming, shown just above the buttons. */ + beforeActions?: React.ReactNode +}>): JSX.Element { + const { t } = useTranslation() + + return ( + + <> + {question !== undefined &&

{question}

} + {children} + {consequence !== undefined &&

{consequence}

} + {beforeActions} + + } /> + + + +
+ ) +} + +export default ConfirmDialog diff --git a/src/renderer/src/features/installations/pages/ListInstallations.tsx b/src/renderer/src/features/installations/pages/ListInstallations.tsx index f2950949..571b6bc1 100644 --- a/src/renderer/src/features/installations/pages/ListInstallations.tsx +++ b/src/renderer/src/features/installations/pages/ListInstallations.tsx @@ -7,7 +7,6 @@ import { PiBoxArrowDownDuotone, PiArrowCounterClockwiseDuotone, PiWrenchDuotone, - PiXCircleDuotone, PiTrashDuotone, PiWarningDuotone, PiArrowUpDuotone, @@ -30,8 +29,7 @@ import { useCheckPathExists, useOpenPathInExplorer } from "@renderer/features/in import { ListGroup, ListWrapper, ListItem } from "@renderer/components/ui/List" import ScrollableContainer from "@renderer/components/ui/ScrollableContainer" -import PopupDialogPanel from "@renderer/components/ui/PopupDialogPanel" -import { ButtonsWrapper, FormButton } from "@renderer/components/ui/FormComponents" +import ConfirmDialog from "@renderer/components/ui/ConfirmDialog" import { LinkButton, NormalButton } from "@renderer/components/ui/Buttons" import { ThinSeparator } from "@renderer/components/ui/ListSeparators" import { StickyMenuWrapper, StickyMenuGroupWrapper, StickyMenuGroup, StickyMenuBreadcrumbs, GoBackButton, GoToTopButton } from "@renderer/components/ui/StickyMenu" @@ -239,20 +237,22 @@ function ListInslallations(): JSX.Element { - setInstallationToDelete(null)}> - <> -

{t("features.installations.areYouSureDelete")}

-

{t("features.installations.deletingNotReversible")}

+ setInstallationToDelete(null)} + question={t("features.installations.areYouSureDelete")} + consequence={t("features.installations.deletingNotReversible")} + confirmLabel={t("generic.delete")} + confirmIcon={} + onConfirm={DeleteInstallationHandler} + beforeActions={
setDeleteData(e.target.checked)} />
- - setInstallationToDelete(null)} variant="secondary" size="md" icon={} /> - } /> - - -
+ } + /> ) diff --git a/src/renderer/src/features/installations/pages/ManageInstallationBackups.tsx b/src/renderer/src/features/installations/pages/ManageInstallationBackups.tsx index 7a6d50f1..c05b872f 100644 --- a/src/renderer/src/features/installations/pages/ManageInstallationBackups.tsx +++ b/src/renderer/src/features/installations/pages/ManageInstallationBackups.tsx @@ -1,7 +1,7 @@ import { useRef, useState } from "react" import { useTranslation } from "react-i18next" import { useParams } from "react-router-dom" -import { PiArrowCounterClockwiseDuotone, PiFolderOpenDuotone, PiTrashDuotone, PiXCircleDuotone } from "react-icons/pi" +import { PiArrowCounterClockwiseDuotone, PiFolderOpenDuotone, PiTrashDuotone } from "react-icons/pi" import { deleteInstallationBackup } from "@domain/installations/backupDeletion" import { restoreInstallationBackup } from "@domain/installations/restore" @@ -17,9 +17,8 @@ import { useTaskContext } from "@renderer/contexts/TaskManagerContext" import { ListGroup, ListItem, ListWrapper } from "@renderer/components/ui/List" import ScrollableContainer from "@renderer/components/ui/ScrollableContainer" -import PopupDialogPanel from "@renderer/components/ui/PopupDialogPanel" +import ConfirmDialog from "@renderer/components/ui/ConfirmDialog" import { NormalButton } from "@renderer/components/ui/Buttons" -import { ButtonsWrapper, FormButton } from "@renderer/components/ui/FormComponents" import { ThinSeparator } from "@renderer/components/ui/ListSeparators" import { StickyMenuWrapper, StickyMenuGroupWrapper, StickyMenuGroup, StickyMenuBreadcrumbs, GoBackButton, GoToTopButton } from "@renderer/components/ui/StickyMenu" @@ -182,45 +181,33 @@ function ManageInstallationBackups(): JSX.Element { - setBackupToRestore(null)}> - <> -

{t("features.backups.areYouSureRestoreBackup")}

-

{t("features.backups.restoringNotReversible")}

- - setBackupToRestore(null)} variant="secondary" size="md" icon={} /> - { - RestoreBackupHandler(backupToRestore) - setBackupToRestore(null) - }} - variant="destructive" - icon={} - /> - - -
- - setBackupToDelete(null)}> - <> -

{t("features.backups.areYouSureDelete")}

-

{t("features.backups.deletingNotReversible")}

- - setBackupToDelete(null)} variant="secondary" size="md" icon={} /> - { - DeleteBackupHandler(backupToDelete) - setBackupToDelete(null) - }} - variant="destructive" - icon={} - /> - - -
+ setBackupToRestore(null)} + question={t("features.backups.areYouSureRestoreBackup")} + consequence={t("features.backups.restoringNotReversible")} + confirmLabel={t("generic.restore")} + confirmIcon={} + onConfirm={() => { + RestoreBackupHandler(backupToRestore) + setBackupToRestore(null) + }} + /> + + setBackupToDelete(null)} + question={t("features.backups.areYouSureDelete")} + consequence={t("features.backups.deletingNotReversible")} + confirmLabel={t("generic.delete")} + confirmIcon={} + onConfirm={() => { + DeleteBackupHandler(backupToDelete) + setBackupToDelete(null) + }} + /> ) diff --git a/src/renderer/src/features/mods/components/DeleteModDialog.tsx b/src/renderer/src/features/mods/components/DeleteModDialog.tsx index a66a61fb..bda32c61 100644 --- a/src/renderer/src/features/mods/components/DeleteModDialog.tsx +++ b/src/renderer/src/features/mods/components/DeleteModDialog.tsx @@ -1,9 +1,8 @@ import { useEffect, useRef } from "react" import { useTranslation } from "react-i18next" -import { PiTrashDuotone, PiXCircleDuotone } from "react-icons/pi" +import { PiTrashDuotone } from "react-icons/pi" -import PopupDialogPanel from "@renderer/components/ui/PopupDialogPanel" -import { ButtonsWrapper, FormButton } from "@renderer/components/ui/FormComponents" +import ConfirmDialog from "@renderer/components/ui/ConfirmDialog" /** * Calls `onGone` once it has left the page. The timer puts the call after the microtask in which the @@ -40,24 +39,25 @@ function DeleteModDialog({ const sortedNames = names && [...names].sort((a, b) => a.localeCompare(b)) return ( - - <> -

{sortedNames ? t("features.mods.areYouSureDeleteSelected", { count: sortedNames.length }) : t("features.mods.areYouSureDelete")}

- {sortedNames && ( -
    - {sortedNames.map((name) => ( -
  • {name}
  • - ))} -
- )} -

{t("features.mods.deletingNotReversible")}

- {onClosed && } - - } /> - } /> - - -
+ } + onConfirm={onConfirm} + > + {sortedNames && ( +
    + {sortedNames.map((name) => ( +
  • {name}
  • + ))} +
+ )} + {onClosed && } +
) } diff --git a/src/renderer/src/features/mods/components/RemoveServerModsDialog.tsx b/src/renderer/src/features/mods/components/RemoveServerModsDialog.tsx index 689eeff2..2af99eb4 100644 --- a/src/renderer/src/features/mods/components/RemoveServerModsDialog.tsx +++ b/src/renderer/src/features/mods/components/RemoveServerModsDialog.tsx @@ -1,8 +1,7 @@ import { useTranslation } from "react-i18next" -import { PiTrashDuotone, PiXCircleDuotone } from "react-icons/pi" +import { PiTrashDuotone } from "react-icons/pi" -import PopupDialogPanel from "@renderer/components/ui/PopupDialogPanel" -import { ButtonsWrapper, FormButton } from "@renderer/components/ui/FormComponents" +import ConfirmDialog from "@renderer/components/ui/ConfirmDialog" /** * Asks before one server's downloaded Mods are removed. @@ -16,27 +15,28 @@ function RemoveServerModsDialog({ group, close, onConfirm }: Readonly<{ group: S const { t } = useTranslation() return ( - - <> - {/* A folder the launcher could not open has no count to state, and "0 Mods" would be a - claim about a folder nothing was ever read from. */} -

- {group?.unlistable - ? t("features.mods.serverModsRemoveUnlistable", { server: group.server, interpolation: { escapeValue: false } }) - : t("features.mods.serverModsRemoveConfirm", { count: group?.mods.length ?? 0, server: group?.server ?? "", interpolation: { escapeValue: false } })} -

- {/* The count above is what the scan listed, not what the folder holds, so stating it alone - would understate what this button is about to delete. */} - {group?.truncated &&

{t("features.mods.serverModsRemoveTruncated")}

} -

{t("features.mods.serverModsRemoveReassurance")}

- - } /> - {/* generic.delete, not a label of this feature's own: a dialog button rendering its title as - visible text has to resolve in all fourteen locales, and this one already does. */} - } /> - - -
+ } + onConfirm={onConfirm} + > + {/* The count above is what the scan listed, not what the folder holds, so stating it alone + would understate what this button is about to delete. */} + {group?.truncated &&

{t("features.mods.serverModsRemoveTruncated")}

} +
) } diff --git a/src/renderer/src/features/servers/pages/ManageInstallationServers.tsx b/src/renderer/src/features/servers/pages/ManageInstallationServers.tsx index a398f321..e0a2b23b 100644 --- a/src/renderer/src/features/servers/pages/ManageInstallationServers.tsx +++ b/src/renderer/src/features/servers/pages/ManageInstallationServers.tsx @@ -1,7 +1,7 @@ import { useRef, useState } from "react" import { useTranslation } from "react-i18next" import { useParams } from "react-router-dom" -import { PiCopyDuotone, PiPencilDuotone, PiPlayCircleDuotone, PiPlusCircleDuotone, PiTrashDuotone, PiXCircleDuotone } from "react-icons/pi" +import { PiCopyDuotone, PiPencilDuotone, PiPlayCircleDuotone, PiPlusCircleDuotone, PiTrashDuotone } from "react-icons/pi" import { formatServerAddress, MAX_SERVER_BOOKMARKS, NEVER_LAUNCHED, orderServerBookmarks } from "@domain/servers/bookmarks" @@ -13,9 +13,8 @@ import LaunchBackupPrompt from "@renderer/features/launch/components/LaunchBacku import ServerBookmarkDialog from "@renderer/features/servers/components/ServerBookmarkDialog" import { ListGroup, ListItem, ListWrapper } from "@renderer/components/ui/List" import ScrollableContainer from "@renderer/components/ui/ScrollableContainer" -import PopupDialogPanel from "@renderer/components/ui/PopupDialogPanel" +import ConfirmDialog from "@renderer/components/ui/ConfirmDialog" import { NormalButton } from "@renderer/components/ui/Buttons" -import { ButtonsWrapper, FormButton } from "@renderer/components/ui/FormComponents" import { ThinSeparator } from "@renderer/components/ui/ListSeparators" import { StickyMenuWrapper, StickyMenuGroupWrapper, StickyMenuGroup, StickyMenuBreadcrumbs, GoBackButton, GoToTopButton } from "@renderer/components/ui/StickyMenu" @@ -174,15 +173,15 @@ function ManageInstallationServers(): JSX.Element { setDialogOpen(false)} onSave={saveServer} server={serverToEdit} existing={servers} /> - setServerToRemove(null)}> - <> -

{t("features.servers.removeServerConfirm")}

- - setServerToRemove(null)} variant="secondary" size="md" icon={} /> - } /> - - -
+ setServerToRemove(null)} + question={t("features.servers.removeServerConfirm")} + confirmLabel={t("generic.delete")} + confirmIcon={} + onConfirm={removeServer} + /> diff --git a/src/renderer/src/features/versions/pages/ListVersions.tsx b/src/renderer/src/features/versions/pages/ListVersions.tsx index 6a7a4084..422366ef 100644 --- a/src/renderer/src/features/versions/pages/ListVersions.tsx +++ b/src/renderer/src/features/versions/pages/ListVersions.tsx @@ -29,6 +29,7 @@ import { useOptimumManifest } from "@renderer/features/versions/hooks/useOptimum import { ListGroup, ListWrapper, ListItem } from "@renderer/components/ui/List" import ScrollableContainer from "@renderer/components/ui/ScrollableContainer" import PopupDialogPanel from "@renderer/components/ui/PopupDialogPanel" +import ConfirmDialog from "@renderer/components/ui/ConfirmDialog" import { LinkButton, NormalButton } from "@renderer/components/ui/Buttons" import { ButtonsWrapper, FormButton, FormInputText } from "@renderer/components/ui/FormComponents" import { ThinSeparator } from "@renderer/components/ui/ListSeparators" @@ -250,26 +251,16 @@ function ListVersions(): JSX.Element { - setVersionToDelete(null)} - > - <> -

{t(versionToDelete?.linked ? "features.versions.areYouSureUnlink" : "features.versions.areYouSureUninstall", { version: versionToDelete?.label ?? versionToDelete?.version })}

-

{t(versionToDelete?.linked ? "features.versions.unlinkingKeepsTheFolder" : "features.versions.uninstallingNotReversible")}

- - setVersionToDelete(null)} variant="secondary" size="md" icon={} /> - } - /> - - -
+ question={t(versionToDelete?.linked ? "features.versions.areYouSureUnlink" : "features.versions.areYouSureUninstall", { version: versionToDelete?.label ?? versionToDelete?.version })} + consequence={t(versionToDelete?.linked ? "features.versions.unlinkingKeepsTheFolder" : "features.versions.uninstallingNotReversible")} + confirmLabel={t(versionToDelete?.linked ? "features.versions.removeFromList" : "generic.uninstall")} + confirmIcon={} + onConfirm={DeleteVersionHandler} + /> setVersionToRename(null)}>
@@ -293,30 +284,32 @@ function ListVersions(): JSX.Element {
- setVersionToRestore(null)}> - <> -

{t("features.versions.areYouSureRestoreVanilla", { version: versionToRestore?.label ?? versionToRestore?.version })}

-

{t("features.versions.restoreVanillaIsPartial")}

- - setVersionToRestore(null)} variant="secondary" size="md" icon={} /> - } /> - - -
- - setVersionInUseWarning(null)}> - <> -
- - {t("features.versions.versionInUseByInstallations", { installations: installationsInUseLabel(versionInUseWarning?.usedByInstallations ?? []) })} -
-

{t(versionInUseWarning?.version.linked ? "features.versions.unlinkingKeepsTheFolder" : "features.versions.uninstallingNotReversible")}

- - setVersionInUseWarning(null)} variant="secondary" size="md" icon={} /> - } /> - - -
+ setVersionToRestore(null)} + question={t("features.versions.areYouSureRestoreVanilla", { version: versionToRestore?.label ?? versionToRestore?.version })} + consequence={t("features.versions.restoreVanillaIsPartial")} + confirmLabel={t("features.versions.restoreVanilla")} + confirmIcon={} + confirmVariant="primary" + onConfirm={RestoreVanillaHandler} + /> + + setVersionInUseWarning(null)} + consequence={t(versionInUseWarning?.version.linked ? "features.versions.unlinkingKeepsTheFolder" : "features.versions.uninstallingNotReversible")} + confirmLabel={t("features.versions.deleteAnyway")} + confirmIcon={} + onConfirm={DeleteVersionAnywayHandler} + > +
+ + {t("features.versions.versionInUseByInstallations", { installations: installationsInUseLabel(versionInUseWarning?.usedByInstallations ?? []) })} +
+
) From ea9358732491b914657a552f01a7d4e850b88439 Mon Sep 17 00:00:00 2001 From: Pixnop <77785313+Pixnop@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:37:32 +0200 Subject: [PATCH 2/5] Move the last hand-written select menus onto SelectMenu The language picker, the UI scale picker and the ModDB count picker each spelled out the same Listbox scaffold: a trigger reading off the picked option, an AnimatePresence, a motion list and a styled option row. SelectMenu grows a hint for the muted note beside a label, a listSize for the one list long enough to need a scrollbar, and a title for a row whose description doubles as its tooltip. Its trigger now renders from a find instead of a filtered map, so picking an option no longer replaces the button node and a stored value outside the options still leaves a usable control. --- .../src/components/ui/LanguagesMenu.tsx | 77 ++----------- src/renderer/src/components/ui/SelectMenu.tsx | 61 ++++++---- .../src/features/config/pages/ConfigPage.tsx | 105 +++--------------- 3 files changed, 65 insertions(+), 178 deletions(-) diff --git a/src/renderer/src/components/ui/LanguagesMenu.tsx b/src/renderer/src/components/ui/LanguagesMenu.tsx index e91b50f6..e6f760c3 100644 --- a/src/renderer/src/components/ui/LanguagesMenu.tsx +++ b/src/renderer/src/components/ui/LanguagesMenu.tsx @@ -1,86 +1,29 @@ import { useState } from "react" import { useTranslation } from "react-i18next" -import { PiCaretDownDuotone } from "react-icons/pi" -import { AnimatePresence, motion } from "motion/react" -import clsx from "clsx" -import { Listbox, ListboxButton, ListboxOptions, ListboxOption } from "@headlessui/react" - -import { DROPDOWN_MENU_ITEM_VARIANTS, DROPDOWN_MENU_WRAPPER_VARIANTS } from "@renderer/utils/animateVariants" import { useChangeLanguage } from "@renderer/features/config/hooks/useChangeLanguage" -import { MENU_OPTION_STYLES, MENU_TRIGGER_STYLES } from "@renderer/components/ui/buttonStyles" +import SelectMenu from "@renderer/components/ui/SelectMenu" function LanguagesMenu(): JSX.Element { const { i18n, t } = useTranslation() const applyLanguageChange = useChangeLanguage() const [selectedLanguage, setSelectedLanguage] = useState(window.localStorage.getItem("lang") || "en-US") - const getLanguages = (): { code: string; name: string; credits: string }[] => { - const resources = i18n.options.resources - if (!resources) return [] - return Object.keys(resources).map((code) => ({ - code, - name: typeof resources[code]?.name === "string" ? resources[code].name : code, - credits: typeof resources[code]?.credits === "string" ? resources[code].credits : t("generic.byAnonymous") - })) - } - - const languages = getLanguages() + const resources = i18n.options.resources ?? {} + const languages = Object.keys(resources).map((code) => ({ + key: code, + label: typeof resources[code]?.name === "string" ? resources[code].name : code, + hint: typeof resources[code]?.credits === "string" ? resources[code].credits : t("generic.byAnonymous") + })) - const handleLanguageChange = async (lang: string): Promise => { + async function handleLanguageChange(lang: string): Promise { if (!(await applyLanguageChange(lang))) return localStorage.setItem("lang", lang) setSelectedLanguage(lang) } - return ( - - {({ open }) => ( - <> - {languages - .filter((lang) => lang.code === selectedLanguage) - .map((lang) => ( - -

- {lang.name} - {lang.credits} -

- -
- ))} - - - {open && ( - - - {languages.map((lang) => ( - -

- {lang.name} - {lang.credits} -

-
- ))} -
-
- )} -
- - )} -
- ) + // The list outgrows the window at fourteen locales and counting, so this one scrolls. + return } export default LanguagesMenu diff --git a/src/renderer/src/components/ui/SelectMenu.tsx b/src/renderer/src/components/ui/SelectMenu.tsx index 3b9e8711..49645dab 100644 --- a/src/renderer/src/components/ui/SelectMenu.tsx +++ b/src/renderer/src/components/ui/SelectMenu.tsx @@ -1,46 +1,57 @@ import clsx from "clsx" import { AnimatePresence, motion } from "motion/react" -import { Dispatch, SetStateAction } from "react" import { PiCaretDownDuotone } from "react-icons/pi" import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from "@headlessui/react" import { DROPDOWN_MENU_ITEM_VARIANTS, DROPDOWN_MENU_WRAPPER_VARIANTS } from "@renderer/utils/animateVariants" import { MENU_OPTION_STYLES, MENU_TRIGGER_STYLES } from "@renderer/components/ui/buttonStyles" -export interface SelectMenuOption { +export interface SelectMenuOption { key: T label: string + /** Muted note beside the label: who translated a locale, which scale is the default. */ + hint?: string } /** - * One single-select Listbox over a fixed set of string options, shared by the filter bar's - * single-value axes (Side, Installed). The trigger shows the picked option's own label; the panel - * lists every option with zebra striping. Options are supplied already translated: this component - * holds no i18n of its own, same as InstalledModsSelectFilter next door. + * One single-select Listbox over a fixed set of options, shared by the filter bar's single-value + * axes (Side, Installed), the language picker and the UI scale picker. The trigger shows the picked + * option's own label; the panel lists every option with zebra striping. Options are supplied already + * translated: this component holds no i18n of its own, same as InstalledModsSelectFilter next door. + * + * `listSize` is how a caller with more options than fit on screen caps the panel: the languages list + * scrolls, the four-option filters do not, and a cap in here would put a scrollbar on all of them. + * + * The trigger renders whether or not the value matches an option, so a stored value no build offers + * any more leaves a control the player can still open, and so picking an option does not swap the + * trigger node out from under the focus Headless UI hands back to it. */ -function SelectMenu({ +function SelectMenu({ value, options, onChange, - size = "w-full h-8" + size = "w-full h-8", + listSize, + title }: Readonly<{ value: T options: SelectMenuOption[] - onChange: Dispatch> + onChange: (value: T) => void size?: string + listSize?: string + /** Tooltip on the trigger, for a row whose own description says what the choice does. */ + title?: string }>): JSX.Element { + const selected = options.find((option) => option.key === value) + return ( {({ open }) => ( <> - {options - .filter((option) => option.key === value) - .map((selected) => ( - -

{selected.label}

- -
- ))} + + + + {open && ( @@ -50,7 +61,7 @@ function SelectMenu({ initial="initial" animate="animate" exit="exit" - className="flex flex-col bg-zinc-950/50 backdrop-blur-md border border-zinc-400/5 shadow-sm shadow-zinc-950/50 hover:shadow-none rounded-sm" + className={clsx("flex flex-col bg-zinc-950/50 backdrop-blur-md border border-zinc-400/5 shadow-sm shadow-zinc-950/50 hover:shadow-none rounded-sm", listSize)} > {options.map((option) => ( ({ variants={DROPDOWN_MENU_ITEM_VARIANTS} className={clsx(MENU_OPTION_STYLES, "odd:bg-zinc-800/30 even:bg-zinc-950/30")} > -

- {option.label} -

+
))} @@ -75,4 +84,14 @@ function SelectMenu({ ) } +/** The trigger shows the same label as the option it stands for, so both read off this. */ +function OptionLabel({ option, title }: Readonly<{ option?: { label: string; hint?: string }; title?: string }>): JSX.Element { + return ( +

+ {option?.label} + {option?.hint !== undefined && {option.hint}} +

+ ) +} + export default SelectMenu diff --git a/src/renderer/src/features/config/pages/ConfigPage.tsx b/src/renderer/src/features/config/pages/ConfigPage.tsx index f2bbf341..b8d5d3d7 100644 --- a/src/renderer/src/features/config/pages/ConfigPage.tsx +++ b/src/renderer/src/features/config/pages/ConfigPage.tsx @@ -1,9 +1,7 @@ import { useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { FiLoader } from "react-icons/fi" -import { PiCaretDownDuotone, PiMagnifyingGlassDuotone } from "react-icons/pi" -import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from "@headlessui/react" -import { AnimatePresence, motion } from "motion/react" +import { PiMagnifyingGlassDuotone } from "react-icons/pi" import clsx from "clsx" import { CUSTOM_BACKGROUND_ID, DEFAULT_BACKGROUND_ID } from "@domain/backgrounds" @@ -11,10 +9,8 @@ import { ACCENT_PRESETS } from "@domain/accentColors" import { resolveAllowPrerelease } from "@domain/appUpdate/betaUpdates" import { MODDB_VISIBILITY_ALWAYS, MODDB_VISIBILITY_ASK, MODDB_VISIBILITY_NEVER, type ModDbVisibilityPolicy } from "@domain/moddbVisibility" -import { DROPDOWN_MENU_ITEM_VARIANTS, DROPDOWN_MENU_WRAPPER_VARIANTS } from "@renderer/utils/animateVariants" import { backgroundImageSource } from "@renderer/utils/backgroundStyle" import { backgroundThumbnailSource } from "@renderer/utils/backgroundThumbnail" -import { MENU_OPTION_STYLES, MENU_TRIGGER_STYLES } from "@renderer/components/ui/buttonStyles" import { useSettingsConfig, useConfigDispatch, CONFIG_ACTIONS } from "@renderer/features/config/contexts/ConfigContext" import { useNotificationsContext } from "@renderer/contexts/NotificationsContext" @@ -38,6 +34,7 @@ import { import { NormalButton } from "@renderer/components/ui/Buttons" import ScrollableContainer from "@renderer/components/ui/ScrollableContainer" import LanguagesMenu from "@renderer/components/ui/LanguagesMenu" +import SelectMenu from "@renderer/components/ui/SelectMenu" import { StickyMenuWrapper, StickyMenuGroupWrapper, StickyMenuGroup, StickyMenuBreadcrumbs, GoBackButton, GoToTopButton, ReloadButton } from "@renderer/components/ui/StickyMenu" import { useConfigFolderPicker } from "@renderer/features/config/hooks/useConfigFolderPicker" import { useBackgroundCatalog } from "@renderer/features/config/hooks/useBackgroundCatalog" @@ -308,40 +305,13 @@ function ModDbCountPicker(): JSX.Element { return ( - configDispatch({ type: CONFIG_ACTIONS.SET_MODDB_VISIBILITY, payload: { ...moddbVisibility, policy } })}> - {({ open }) => ( - <> - -

- {label(selected)} -

- -
- - - {open && ( - - - {options.map((policy) => ( - -

- {label(policy)} -

-
- ))} -
-
- )} -
- - )} -
+ ({ key: policy, label: label(policy) }))} + onChange={(policy) => configDispatch({ type: CONFIG_ACTIONS.SET_MODDB_VISIBILITY, payload: { ...moddbVisibility, policy } })} + size="w-full" + title={t("features.config.moddbCountDesc")} + />
@@ -558,11 +528,11 @@ function UIScale(): JSX.Element { const { t } = useTranslation() const SCALE_OPTIONS = [ - { key: 50, value: "50%" }, - { key: 75, value: "75%" }, - { key: 100, value: "100%" }, - { key: 125, value: "125%" }, - { key: 150, value: "150%" } + { key: 50, label: "50%" }, + { key: 75, label: "75%" }, + { key: 100, label: "100%", hint: t("generic.default") }, + { key: 125, label: "125%" }, + { key: 150, label: "150%" } ] const [selectedScale, setSelectedScale] = useState(Number(window.localStorage.getItem("uiScale")) || 100) @@ -572,52 +542,7 @@ function UIScale(): JSX.Element { window.localStorage.setItem("uiScale", selectedScale.toString()) }, [selectedScale]) - return ( - - {({ open }) => ( - <> - {SCALE_OPTIONS.filter((scale) => scale.key === selectedScale).map((scale) => ( - -

- {scale.value} - {scale.key === 100 && {t("generic.default")}} -

- -
- ))} - - - {open && ( - - - {SCALE_OPTIONS.map((scale) => ( - -

- {scale.value} - {scale.key === 100 && {t("generic.default")}} -

-
- ))} -
-
- )} -
- - )} -
- ) + return } export default ConfigPage From 464e135d3c55edebfd984c2b02760a986b19ce70 Mon Sep 17 00:00:00 2001 From: Pixnop <77785313+Pixnop@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:49:08 +0200 Subject: [PATCH 3/5] Inline the ModDB option label helper It had one caller left once the picker moved onto SelectMenu. --- src/renderer/src/features/config/pages/ConfigPage.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/renderer/src/features/config/pages/ConfigPage.tsx b/src/renderer/src/features/config/pages/ConfigPage.tsx index b8d5d3d7..5ce86511 100644 --- a/src/renderer/src/features/config/pages/ConfigPage.tsx +++ b/src/renderer/src/features/config/pages/ConfigPage.tsx @@ -301,13 +301,12 @@ function ModDbCountPicker(): JSX.Element { const options: ModDbVisibilityPolicy[] = [MODDB_VISIBILITY_ASK, MODDB_VISIBILITY_ALWAYS, MODDB_VISIBILITY_NEVER] // A pending "count me in" for the running version is still the ask policy for every later one. const selected: ModDbVisibilityPolicy = options.includes(moddbVisibility.policy) ? moddbVisibility.policy : MODDB_VISIBILITY_ASK - const label = (policy: ModDbVisibilityPolicy): string => t(`features.config.moddbCountOptions.${policy}`) return ( ({ key: policy, label: label(policy) }))} + options={options.map((policy) => ({ key: policy, label: t(`features.config.moddbCountOptions.${policy}`) }))} onChange={(policy) => configDispatch({ type: CONFIG_ACTIONS.SET_MODDB_VISIBILITY, payload: { ...moddbVisibility, policy } })} size="w-full" title={t("features.config.moddbCountDesc")} From d0ab3ff7f3471d6f704cf76921d839630cc229de Mon Sep 17 00:00:00 2001 From: Pixnop <77785313+Pixnop@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:55:29 +0200 Subject: [PATCH 4/5] Stop the Activity Center Escape test racing the exit animation This PR does not touch ActivityCenter.tsx or its test, but CI on ubuntu still caught this test racing AnimatePresence's exit animation: the region only leaves the DOM once the 200ms exit finishes, and under CI load that outran even the 5s waitFor timeout (same root cause as #504, already fixed the same way on the unrelated PR #509). Popover.Panel's own Escape handler closes and returns focus synchronously; assert on aria-expanded and focus, which is what Escape actually controls, instead of waiting on the animation's timing. --- tests/renderer-dom/activityCenter.test.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/renderer-dom/activityCenter.test.tsx b/tests/renderer-dom/activityCenter.test.tsx index 28cc0d54..deed6555 100644 --- a/tests/renderer-dom/activityCenter.test.tsx +++ b/tests/renderer-dom/activityCenter.test.tsx @@ -1353,10 +1353,15 @@ describe("Activity Center keyboard reach", () => { render(, { wrapper }) openCenter() + const trigger = screen.getByRole("button", { name: /^Activity Center:/ }) fireEvent.keyDown(panel(), { key: "Escape" }) - await waitFor(() => expect(screen.queryByRole("region", { name: "Activity Center" })).toBeNull(), { timeout: 5_000 }) - expect(document.activeElement).toBe(screen.getByRole("button", { name: /^Activity Center:/ })) + // Escape closes the popover and returns focus synchronously (see Popover.Panel's onKeyDown + // in @headlessui/react); the region only leaves the DOM once AnimatePresence's exit animation + // finishes, which is what made this case flake under CI load (#504). Assert on what Escape + // actually controls instead of on that animation's timing. + await waitFor(() => expect(trigger.getAttribute("aria-expanded")).toBe("false")) + expect(document.activeElement).toBe(trigger) }) }) From 93d5d9de75f3c67c16841bf55bcc4e9c2c023c7c Mon Sep 17 00:00:00 2001 From: Pixnop <77785313+Pixnop@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:09:23 +0200 Subject: [PATCH 5/5] Tolerate the idempotent trailing rescan in the profile switch test Like the Escape case, this test is untouched by this PR but still caught a CI load flake: useManageInstalledMods's rescan effect occasionally fires an extra, idempotent time once a switch releases _updatingMods, on top of the one it is expected to. Nothing about that changes what lands on the folder or what the player sees, so pin the meaningful event sequence (the scan the switch itself does, both writes, the renames) and only require every event after it to be another harmless rescan, instead of an exact count that races the same load-dependent timing. Root-causing that extra rescan is tracked separately; it is not part of this refactor's renderer shells. --- tests/renderer-dom/modProfiles.test.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/renderer-dom/modProfiles.test.tsx b/tests/renderer-dom/modProfiles.test.tsx index 5b036692..e0530351 100644 --- a/tests/renderer-dom/modProfiles.test.tsx +++ b/tests/renderer-dom/modProfiles.test.tsx @@ -366,8 +366,12 @@ describe("Mod profiles", { timeout: 20000 }, () => { await switchLanded() // A fresh scan, the first write, only the four renames that differ, the second write, and the - // page's own rescan once the folder is let go. - expect(events).toEqual(["scan", "save null", `rename ${BETA} false`, `rename ${GAMMA} false`, `rename ${DELTA} false`, `rename ${EPSILON} true`, "save solo", "scan"]) + // page's own rescan once the folder is let go. That rescan effect (useManageInstalledMods) can + // fire itself an extra, idempotent time under a loaded CI runner, so only pin the meaningful + // sequence and allow any number of trailing rescans instead of an exact count. + const CORE_EVENTS = ["scan", "save null", `rename ${BETA} false`, `rename ${GAMMA} false`, `rename ${DELTA} false`, `rename ${EPSILON} true`, "save solo", "scan"] + expect(events.slice(0, CORE_EVENTS.length)).toEqual(CORE_EVENTS) + expect(events.slice(CORE_EVENTS.length).every((event) => event === "scan")).toBe(true) // Server kept what the folder held when it was left, not its stale stored pair. expect(stored()).toEqual(aDocument([{ ...SERVER, mods: LIVE }, SOLO], "solo"))