feat: implement theme switcher with radio buttons and add localization for Traditional Chinese

This commit is contained in:
hamster1963 2025-04-02 13:18:42 +08:00
parent 0837393903
commit 1ab1559c20
10 changed files with 99 additions and 33 deletions

View File

@ -21,7 +21,8 @@
"useSortedClasses": "error" "useSortedClasses": "error"
}, },
"a11y": { "a11y": {
"useKeyWithClickEvents": "off" "useKeyWithClickEvents": "off",
"noLabelWithoutControl": "off"
}, },
"security": { "security": {
"noDangerouslySetInnerHtml": "off" "noDangerouslySetInnerHtml": "off"

BIN
bun.lockb

Binary file not shown.

View File

@ -4,23 +4,28 @@ import { Button } from "@/components/ui/button"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils" import { CheckIcon, MinusIcon, Moon, Sun } from "lucide-react"
import { CheckCircleIcon } from "@heroicons/react/20/solid"
import { Moon, Sun } from "lucide-react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { useTheme } from "next-themes" import { useTheme } from "next-themes"
import { useId } from "react"
import { RadioGroup, RadioGroupItem } from "./ui/radio-group"
const items = [
{ value: "light", label: "Light", image: "/ui-light.png" },
{ value: "dark", label: "Dark", image: "/ui-dark.png" },
{ value: "system", label: "System", image: "/ui-system.png" },
]
export function ModeToggle() { export function ModeToggle() {
const { setTheme, theme } = useTheme() const { setTheme, theme } = useTheme()
const t = useTranslations("ThemeSwitcher") const t = useTranslations("ThemeSwitcher")
const handleSelect = (e: Event, newTheme: string) => { const handleSelect = (newTheme: string) => {
e.preventDefault()
setTheme(newTheme) setTheme(newTheme)
} }
const id = useId()
return ( return (
<DropdownMenu> <DropdownMenu>
@ -35,31 +40,40 @@ export function ModeToggle() {
<span className="sr-only">Toggle theme</span> <span className="sr-only">Toggle theme</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="flex flex-col gap-0.5" align="end"> <DropdownMenuContent className="px-3 pt-3 pb-2" align="end">
<DropdownMenuItem <fieldset className="space-y-4">
className={cn("rounded-b-[5px]", { <RadioGroup className="flex gap-3" defaultValue={theme} onValueChange={handleSelect}>
"gap-3 bg-muted font-semibold": theme === "light", {items.map((item) => (
})} <label key={`${id}-${item.value}`}>
onSelect={(e) => handleSelect(e, "light")} <RadioGroupItem
> id={`${id}-${item.value}`}
{t("Light")} {theme === "light" && <CheckCircleIcon className="size-4" />} value={item.value}
</DropdownMenuItem> className="peer sr-only after:absolute after:inset-0"
<DropdownMenuItem />
className={cn("rounded-[5px]", { <img
"gap-3 bg-muted font-semibold": theme === "dark", src={item.image}
})} alt={item.label}
onSelect={(e) => handleSelect(e, "dark")} width={88}
> height={70}
{t("Dark")} {theme === "dark" && <CheckCircleIcon className="size-4" />} className="relative cursor-pointer overflow-hidden rounded-[8px] border border-neutral-300 shadow-xs outline-none transition-[color,box-shadow] peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 peer-data-disabled:cursor-not-allowed peer-data-[state=checked]:bg-accent peer-data-disabled:opacity-50 dark:border-neutral-700"
</DropdownMenuItem> />
<DropdownMenuItem <span className="group mt-2 flex items-center gap-1 peer-data-[state=unchecked]:text-muted-foreground/70">
className={cn("rounded-t-[5px]", { <CheckIcon
"gap-3 bg-muted font-semibold": theme === "system", size={16}
})} className="group-peer-data-[state=unchecked]:hidden"
onSelect={(e) => handleSelect(e, "system")} aria-hidden="true"
> />
{t("System")} {theme === "system" && <CheckCircleIcon className="size-4" />} <MinusIcon
</DropdownMenuItem> size={16}
className="group-peer-data-[state=checked]:hidden"
aria-hidden="true"
/>
<span className="font-medium text-xs">{t(item.label)}</span>
</span>
</label>
))}
</RadioGroup>
</fieldset>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) )

View File

@ -0,0 +1,50 @@
"use client"
import { cn } from "@/lib/utils"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import type * as React from "react"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"aspect-square size-4 shrink-0 rounded-full border border-input shadow-xs outline-none transition-shadow focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center text-current">
<svg
role="img"
aria-label="Radio indicator"
width="6"
height="6"
viewBox="0 0 6 6"
fill="currentcolor"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="3" cy="3" r="3" />
</svg>
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View File

@ -4,7 +4,7 @@ import getEnv from "./lib/env-entry"
export const localeItems = [ export const localeItems = [
{ code: "en", name: "English" }, { code: "en", name: "English" },
{ code: "ja", name: "日本語" }, { code: "ja", name: "日本語" },
{ code: "zh-t", name: "中文繁體" }, { code: "zh-TW", name: "中文繁體" },
{ code: "zh", name: "中文简体" }, { code: "zh", name: "中文简体" },
] ]

View File

@ -21,6 +21,7 @@
"@radix-ui/react-navigation-menu": "^1.2.5", "@radix-ui/react-navigation-menu": "^1.2.5",
"@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-progress": "^1.1.2", "@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-switch": "^1.1.3",

BIN
public/ui-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

BIN
public/ui-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 B

BIN
public/ui-system.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 B