Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions src/renderer/src/components/ui/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>) => void | Promise<unknown>
/** 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 (
<PopupDialogPanel title={title} isOpen={isOpen} close={close}>
<>
{question !== undefined && <p>{question}</p>}
{children}
{consequence !== undefined && <p className="text-zinc-400">{consequence}</p>}
{beforeActions}
<ButtonsWrapper className="text-base" bgDark={false} equalWidth flush>
<FormButton title={t("generic.cancel")} onClick={close} variant="secondary" size="md" icon={<PiXCircleDuotone />} />
<FormButton title={confirmLabel} onClick={onConfirm} variant={confirmVariant} size="md" icon={confirmIcon} />
</ButtonsWrapper>
</>
</PopupDialogPanel>
)
}

export default ConfirmDialog
77 changes: 10 additions & 67 deletions src/renderer/src/components/ui/LanguagesMenu.tsx
Original file line number Diff line number Diff line change
@@ -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<string>(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<void> => {
async function handleLanguageChange(lang: string): Promise<void> {
if (!(await applyLanguageChange(lang))) return
localStorage.setItem("lang", lang)
setSelectedLanguage(lang)
}

return (
<Listbox value={selectedLanguage} onChange={handleLanguageChange}>
{({ open }) => (
<>
{languages
.filter((lang) => lang.code === selectedLanguage)
.map((lang) => (
<ListboxButton key={lang.code} className={clsx(MENU_TRIGGER_STYLES, "w-full")}>
<p className="flex gap-2 items-center overflow-hidden whitespace-nowrap">
<span className="text-sm">{lang.name}</span>
<span className="text-ellipsis overflow-hidden text-zinc-400 text-xs">{lang.credits}</span>
</p>
<PiCaretDownDuotone className={clsx("caret-optical shrink-0 duration-200", open && "-rotate-180")} />
</ListboxButton>
))}

<AnimatePresence>
{open && (
<ListboxOptions static anchor="bottom" className="w-[var(--button-width)] z-600 mt-1 select-none rounded-sm overflow-hidden">
<motion.ul
variants={DROPDOWN_MENU_WRAPPER_VARIANTS}
initial="initial"
animate="animate"
exit="exit"
className="h-40 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 overflow-y-scroll"
>
{languages.map((lang) => (
<ListboxOption
key={lang.code}
value={lang.code}
as={motion.li}
variants={DROPDOWN_MENU_ITEM_VARIANTS}
className={clsx(MENU_OPTION_STYLES, "odd:bg-zinc-800/30 even:bg-zinc-950/30")}
>
<p className="flex gap-2 items-center overflow-hidden whitespace-nowrap">
<span className="text-sm">{lang.name}</span>
<span className="text-ellipsis overflow-hidden text-zinc-400 text-xs">{lang.credits}</span>
</p>
</ListboxOption>
))}
</motion.ul>
</ListboxOptions>
)}
</AnimatePresence>
</>
)}
</Listbox>
)
// The list outgrows the window at fourteen locales and counting, so this one scrolls.
return <SelectMenu value={selectedLanguage} options={languages} onChange={handleLanguageChange} size="w-full" listSize="h-40 overflow-y-scroll" />
}

export default LanguagesMenu
61 changes: 40 additions & 21 deletions src/renderer/src/components/ui/SelectMenu.tsx
Original file line number Diff line number Diff line change
@@ -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<T extends string> {
export interface SelectMenuOption<T extends string | number> {
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<T extends string>({
function SelectMenu<T extends string | number>({
value,
options,
onChange,
size = "w-full h-8"
size = "w-full h-8",
listSize,
title
}: Readonly<{
value: T
options: SelectMenuOption<T>[]
onChange: Dispatch<SetStateAction<T>>
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 (
<Listbox value={value} onChange={onChange}>
{({ open }) => (
<>
{options
.filter((option) => option.key === value)
.map((selected) => (
<ListboxButton key={selected.key} className={clsx(MENU_TRIGGER_STYLES, size)}>
<p className="flex gap-2 items-center overflow-hidden whitespace-nowrap text-sm">{selected.label}</p>
<PiCaretDownDuotone className={clsx("caret-optical shrink-0 duration-200", open && "-rotate-180")} />
</ListboxButton>
))}
<ListboxButton className={clsx(MENU_TRIGGER_STYLES, size)} title={title}>
<OptionLabel option={selected} />
<PiCaretDownDuotone className={clsx("caret-optical shrink-0 duration-200", open && "-rotate-180")} />
</ListboxButton>

<AnimatePresence>
{open && (
Expand All @@ -50,7 +61,7 @@ function SelectMenu<T extends string>({
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) => (
<ListboxOption
Expand All @@ -60,9 +71,7 @@ function SelectMenu<T extends string>({
variants={DROPDOWN_MENU_ITEM_VARIANTS}
className={clsx(MENU_OPTION_STYLES, "odd:bg-zinc-800/30 even:bg-zinc-950/30")}
>
<p className="flex gap-2 items-center overflow-hidden whitespace-nowrap text-sm" title={option.label}>
{option.label}
</p>
<OptionLabel option={option} title={option.label} />
</ListboxOption>
))}
</motion.ul>
Expand All @@ -75,4 +84,14 @@ function SelectMenu<T extends string>({
)
}

/** 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 (
<p className="flex gap-2 items-center overflow-hidden whitespace-nowrap" title={title}>
<span className="text-sm">{option?.label}</span>
{option?.hint !== undefined && <span className="text-ellipsis overflow-hidden text-zinc-400 text-xs">{option.hint}</span>}
</p>
)
}

export default SelectMenu
Loading
Loading