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/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..5ce86511 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" @@ -304,44 +301,16 @@ 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 ( - configDispatch({ type: CONFIG_ACTIONS.SET_MODDB_VISIBILITY, payload: { ...moddbVisibility, policy } })}> - {({ open }) => ( - <> - -

- {label(selected)} -

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

- {label(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")} + />
@@ -558,11 +527,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 +541,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 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 ?? []) })} +
+
) 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) }) }) 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"))