feat(ui): add framework-neutral localization support

- add a lightweight UI i18n provider with translation and locale contexts\n- localize accessibility labels across breadcrumb, carousel, dialog, pagination, sheet, sidebar, spinner, and toast components\n- integrate calendar locale data without coupling UI components to Lingui\n- publish tree-shakeable catalogs for eight common locales
This commit is contained in:
Maofeng
2026-07-30 14:47:51 +08:00
parent 0768e8fa29
commit ae1798f6f4
20 changed files with 551 additions and 87 deletions
+2
View File
@@ -40,6 +40,8 @@
}, },
"exports": { "exports": {
"./globals.css": "./src/styles/globals.css", "./globals.css": "./src/styles/globals.css",
"./locales": "./src/i18n/catalogs.ts",
"./locales/*": "./src/i18n/locales/*.ts",
"./lib/*": "./src/lib/*.ts", "./lib/*": "./src/lib/*.ts",
"./components/*": "./src/components/*.tsx", "./components/*": "./src/components/*.tsx",
"./hooks/*": "./src/hooks/*.ts" "./hooks/*": "./src/hooks/*.ts"
+14 -8
View File
@@ -4,11 +4,18 @@ import { useRender } from "@base-ui/react/use-render"
import { cn } from "@workspace/ui/lib/utils" import { cn } from "@workspace/ui/lib/utils"
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react" import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
import { LocalizedText, uiMessages, useMessage } from "./i18n"
function Breadcrumb({
"aria-label": ariaLabel,
className,
...props
}: React.ComponentProps<"nav">) {
const defaultLabel = useMessage(uiMessages.breadcrumb)
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return ( return (
<nav <nav
aria-label="breadcrumb" aria-label={ariaLabel ?? defaultLabel}
data-slot="breadcrumb" data-slot="breadcrumb"
className={cn(className)} className={cn(className)}
{...props} {...props}
@@ -85,9 +92,7 @@ function BreadcrumbSeparator({
className={cn("[&>svg]:size-3.5", className)} className={cn("[&>svg]:size-3.5", className)}
{...props} {...props}
> >
{children ?? ( {children ?? <ChevronRightIcon className="rtl:rotate-180" />}
<ChevronRightIcon className="rtl:rotate-180" />
)}
</li> </li>
) )
} }
@@ -107,9 +112,10 @@ function BreadcrumbEllipsis({
)} )}
{...props} {...props}
> >
<MoreHorizontalIcon <MoreHorizontalIcon />
/> <span className="sr-only">
<span className="sr-only">More</span> <LocalizedText message={uiMessages.more} />
</span>
</span> </span>
) )
} }
+25 -9
View File
@@ -5,12 +5,16 @@ import {
DayPicker, DayPicker,
getDefaultClassNames, getDefaultClassNames,
type DayButton, type DayButton,
type Locale,
} from "react-day-picker" } from "react-day-picker"
import { cn } from "@workspace/ui/lib/utils" import { cn } from "@workspace/ui/lib/utils"
import { Button, buttonVariants } from "@workspace/ui/components/button" import { Button, buttonVariants } from "@workspace/ui/components/button"
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react" import { useCalendarLocale, useLocale } from "@workspace/ui/components/i18n"
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronDownIcon,
} from "lucide-react"
function Calendar({ function Calendar({
className, className,
@@ -18,7 +22,8 @@ function Calendar({
showOutsideDays = true, showOutsideDays = true,
captionLayout = "label", captionLayout = "label",
buttonVariant = "ghost", buttonVariant = "ghost",
locale, locale: providedLocale,
lang,
formatters, formatters,
components, components,
...props ...props
@@ -26,6 +31,10 @@ function Calendar({
buttonVariant?: React.ComponentProps<typeof Button>["variant"] buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) { }) {
const defaultClassNames = getDefaultClassNames() const defaultClassNames = getDefaultClassNames()
const contextLocale = useLocale()
const contextCalendarLocale = useCalendarLocale()
const locale = providedLocale ?? contextCalendarLocale
const localeCode = locale?.code ?? contextLocale
return ( return (
<DayPicker <DayPicker
@@ -38,9 +47,10 @@ function Calendar({
)} )}
captionLayout={captionLayout} captionLayout={captionLayout}
locale={locale} locale={locale}
lang={lang ?? localeCode}
formatters={{ formatters={{
formatMonthDropdown: (date) => formatMonthDropdown: (date) =>
date.toLocaleString(locale?.code, { month: "short" }), date.toLocaleString(localeCode, { month: "short" }),
...formatters, ...formatters,
}} }}
classNames={{ classNames={{
@@ -147,13 +157,19 @@ function Calendar({
Chevron: ({ className, orientation, ...props }) => { Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") { if (orientation === "left") {
return ( return (
<ChevronLeftIcon className={cn("rtl:rotate-180 size-4", className)} {...props} /> <ChevronLeftIcon
className={cn("size-4 rtl:rotate-180", className)}
{...props}
/>
) )
} }
if (orientation === "right") { if (orientation === "right") {
return ( return (
<ChevronRightIcon className={cn("rtl:rotate-180 size-4", className)} {...props} /> <ChevronRightIcon
className={cn("size-4 rtl:rotate-180", className)}
{...props}
/>
) )
} }
@@ -162,7 +178,7 @@ function Calendar({
) )
}, },
DayButton: ({ ...props }) => ( DayButton: ({ ...props }) => (
<CalendarDayButton locale={locale} {...props} /> <CalendarDayButton locale={localeCode} {...props} />
), ),
WeekNumber: ({ children, ...props }) => { WeekNumber: ({ children, ...props }) => {
return ( return (
@@ -186,7 +202,7 @@ function CalendarDayButton({
modifiers, modifiers,
locale, locale,
...props ...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) { }: React.ComponentProps<typeof DayButton> & { locale?: string }) {
const defaultClassNames = getDefaultClassNames() const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null) const ref = React.useRef<HTMLButtonElement>(null)
@@ -198,7 +214,7 @@ function CalendarDayButton({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
data-day={day.date.toLocaleDateString(locale?.code)} data-day={day.date.toLocaleDateString(locale)}
data-selected-single={ data-selected-single={
modifiers.selected && modifiers.selected &&
!modifiers.range_start && !modifiers.range_start &&
+9 -4
View File
@@ -6,6 +6,7 @@ import useEmblaCarousel, {
import { cn } from "@workspace/ui/lib/utils" import { cn } from "@workspace/ui/lib/utils"
import { Button } from "@workspace/ui/components/button" import { Button } from "@workspace/ui/components/button"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react" import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
import { LocalizedText, uiMessages } from "./i18n"
type CarouselApi = UseEmblaCarouselType[1] type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel> type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
@@ -186,7 +187,7 @@ function CarouselPrevious({
"absolute touch-manipulation rounded-full", "absolute touch-manipulation rounded-full",
orientation === "horizontal" orientation === "horizontal"
? "inset-y-0 -start-12 my-auto" ? "inset-y-0 -start-12 my-auto"
: "-top-12 start-1/2 -translate-x-1/2 rtl:translate-x-1/2 rotate-90", : "start-1/2 -top-12 -translate-x-1/2 rotate-90 rtl:translate-x-1/2",
className className
)} )}
disabled={!canScrollPrev} disabled={!canScrollPrev}
@@ -194,7 +195,9 @@ function CarouselPrevious({
{...props} {...props}
> >
<ChevronLeftIcon className="rtl:rotate-180" /> <ChevronLeftIcon className="rtl:rotate-180" />
<span className="sr-only">Previous slide</span> <span className="sr-only">
<LocalizedText message={uiMessages.previousSlide} />
</span>
</Button> </Button>
) )
} }
@@ -216,7 +219,7 @@ function CarouselNext({
"absolute touch-manipulation rounded-full", "absolute touch-manipulation rounded-full",
orientation === "horizontal" orientation === "horizontal"
? "inset-y-0 -end-12 my-auto" ? "inset-y-0 -end-12 my-auto"
: "-bottom-12 start-1/2 -translate-x-1/2 rtl:translate-x-1/2 rotate-90", : "start-1/2 -bottom-12 -translate-x-1/2 rotate-90 rtl:translate-x-1/2",
className className
)} )}
disabled={!canScrollNext} disabled={!canScrollNext}
@@ -224,7 +227,9 @@ function CarouselNext({
{...props} {...props}
> >
<ChevronRightIcon className="rtl:rotate-180" /> <ChevronRightIcon className="rtl:rotate-180" />
<span className="sr-only">Next slide</span> <span className="sr-only">
<LocalizedText message={uiMessages.nextSlide} />
</span>
</Button> </Button>
) )
} }
+8 -6
View File
@@ -6,6 +6,7 @@ import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@workspace/ui/lib/utils" import { cn } from "@workspace/ui/lib/utils"
import { Button } from "@workspace/ui/components/button" import { Button } from "@workspace/ui/components/button"
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react"
import { LocalizedText, uiMessages } from "./i18n"
type DialogHandle<Payload> = DialogPrimitive.Handle<Payload> type DialogHandle<Payload> = DialogPrimitive.Handle<Payload>
@@ -59,7 +60,7 @@ function DialogContent({
<DialogPrimitive.Popup <DialogPrimitive.Popup
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"fixed top-1/2 start-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 rtl:translate-x-1/2 -translate-y-1/2 gap-6 rounded-xl bg-popover p-6 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-md data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", "fixed start-1/2 top-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-xl bg-popover p-6 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-md rtl:translate-x-1/2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className className
)} )}
{...props} {...props}
@@ -71,14 +72,15 @@ function DialogContent({
render={ render={
<Button <Button
variant="ghost" variant="ghost"
className="absolute top-4 end-4" className="absolute end-4 top-4"
size="icon-sm" size="icon-sm"
/> />
} }
> >
<XIcon <XIcon />
/> <span className="sr-only">
<span className="sr-only">Close</span> <LocalizedText message={uiMessages.close} />
</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</DialogPrimitive.Popup> </DialogPrimitive.Popup>
@@ -116,7 +118,7 @@ function DialogFooter({
{children} {children}
{showCloseButton && ( {showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}> <DialogPrimitive.Close render={<Button variant="outline" />}>
Close <LocalizedText message={uiMessages.close} />
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</div> </div>
+156
View File
@@ -0,0 +1,156 @@
"use client"
import * as React from "react"
import type { Locale as DayPickerLocale } from "react-day-picker"
export type MessageValues = Record<string, unknown>
export interface MessageDescriptor {
id: string
message: string
comment?: string
values?: MessageValues
}
export type TranslateFunction = (
descriptor: MessageDescriptor,
values?: MessageValues
) => string
export type CalendarLocale = Partial<DayPickerLocale>
type UiMessages = Record<string, MessageDescriptor>
export const uiMessages = {
breadcrumb: /* i18n */ {
id: "ui.breadcrumb.label",
message: "Breadcrumb",
},
close: /* i18n */ {
id: "ui.common.close",
message: "Close",
},
closeToast: /* i18n */ {
id: "ui.toast.close",
message: "Close notification",
},
loading: /* i18n */ {
id: "ui.common.loading",
message: "Loading",
},
more: /* i18n */ {
id: "ui.common.more",
message: "More",
},
morePages: /* i18n */ {
id: "ui.pagination.more",
message: "More pages",
},
next: /* i18n */ {
id: "ui.pagination.next",
message: "Next",
},
nextPage: /* i18n */ {
id: "ui.pagination.nextPage",
message: "Go to next page",
},
nextSlide: /* i18n */ {
id: "ui.carousel.nextSlide",
message: "Next slide",
},
pagination: /* i18n */ {
id: "ui.pagination.label",
message: "Pagination",
},
previous: /* i18n */ {
id: "ui.pagination.previous",
message: "Previous",
},
previousPage: /* i18n */ {
id: "ui.pagination.previousPage",
message: "Go to previous page",
},
previousSlide: /* i18n */ {
id: "ui.carousel.previousSlide",
message: "Previous slide",
},
sidebar: /* i18n */ {
id: "ui.sidebar.title",
message: "Sidebar",
},
sidebarDescription: /* i18n */ {
id: "ui.sidebar.description",
message: "Displays the mobile sidebar.",
},
toggleSidebar: /* i18n */ {
id: "ui.sidebar.toggle",
message: "Toggle sidebar",
},
} satisfies UiMessages
const DEFAULT_LOCALE = "en-US"
const defaultTranslate: TranslateFunction = (descriptor) => descriptor.message
const TranslateContext =
React.createContext<TranslateFunction>(defaultTranslate)
const LocaleContext = React.createContext(DEFAULT_LOCALE)
const CalendarLocaleContext = React.createContext<CalendarLocale | undefined>(
undefined
)
export interface I18nProviderProps {
calendarLocale?: CalendarLocale
children: React.ReactNode
locale?: string
t?: TranslateFunction
}
export function I18nProvider({
calendarLocale,
children,
locale = DEFAULT_LOCALE,
t = defaultTranslate,
}: I18nProviderProps) {
return (
<TranslateContext.Provider value={t}>
<LocaleContext.Provider value={locale}>
<CalendarLocaleContext.Provider value={calendarLocale}>
{children}
</CalendarLocaleContext.Provider>
</LocaleContext.Provider>
</TranslateContext.Provider>
)
}
export function useTranslate(): TranslateFunction {
return React.useContext(TranslateContext)
}
export function useMessage(
descriptor: MessageDescriptor,
values?: MessageValues
) {
const translate = useTranslate()
return React.useMemo(
() => translate(descriptor, values),
[descriptor, translate, values]
)
}
export interface LocalizedTextProps {
message: MessageDescriptor
values?: MessageValues
}
export function LocalizedText({ message, values }: LocalizedTextProps) {
return useMessage(message, values)
}
export function useLocale(): string {
return React.useContext(LocaleContext)
}
export function useCalendarLocale(): CalendarLocale | undefined {
return React.useContext(CalendarLocaleContext)
}
+32 -12
View File
@@ -2,13 +2,24 @@ import * as React from "react"
import { cn } from "@workspace/ui/lib/utils" import { cn } from "@workspace/ui/lib/utils"
import { Button } from "@workspace/ui/components/button" import { Button } from "@workspace/ui/components/button"
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react" import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { LocalizedText, uiMessages, useMessage } from "./i18n"
function Pagination({
"aria-label": ariaLabel,
className,
...props
}: React.ComponentProps<"nav">) {
const defaultLabel = useMessage(uiMessages.pagination)
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return ( return (
<nav <nav
role="navigation" role="navigation"
aria-label="pagination" aria-label={ariaLabel ?? defaultLabel}
data-slot="pagination" data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)} className={cn("mx-auto flex w-full justify-center", className)}
{...props} {...props}
@@ -64,35 +75,43 @@ function PaginationLink({
function PaginationPrevious({ function PaginationPrevious({
className, className,
text = "Previous", text,
"aria-label": ariaLabel,
...props ...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) { }: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
const previousLabel = useMessage(uiMessages.previous)
const previousPageLabel = useMessage(uiMessages.previousPage)
return ( return (
<PaginationLink <PaginationLink
aria-label="Go to previous page" aria-label={ariaLabel ?? previousPageLabel}
size="default" size="default"
className={cn("ps-2!", className)} className={cn("ps-2!", className)}
{...props} {...props}
> >
<ChevronLeftIcon data-icon="inline-start" className="rtl:rotate-180" /> <ChevronLeftIcon data-icon="inline-start" className="rtl:rotate-180" />
<span className="hidden sm:block">{text}</span> <span className="hidden sm:block">{text ?? previousLabel}</span>
</PaginationLink> </PaginationLink>
) )
} }
function PaginationNext({ function PaginationNext({
className, className,
text = "Next", text,
"aria-label": ariaLabel,
...props ...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) { }: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
const nextLabel = useMessage(uiMessages.next)
const nextPageLabel = useMessage(uiMessages.nextPage)
return ( return (
<PaginationLink <PaginationLink
aria-label="Go to next page" aria-label={ariaLabel ?? nextPageLabel}
size="default" size="default"
className={cn("pe-2!", className)} className={cn("pe-2!", className)}
{...props} {...props}
> >
<span className="hidden sm:block">{text}</span> <span className="hidden sm:block">{text ?? nextLabel}</span>
<ChevronRightIcon data-icon="inline-end" className="rtl:rotate-180" /> <ChevronRightIcon data-icon="inline-end" className="rtl:rotate-180" />
</PaginationLink> </PaginationLink>
) )
@@ -112,9 +131,10 @@ function PaginationEllipsis({
)} )}
{...props} {...props}
> >
<MoreHorizontalIcon <MoreHorizontalIcon />
/> <span className="sr-only">
<span className="sr-only">More pages</span> <LocalizedText message={uiMessages.morePages} />
</span>
</span> </span>
) )
} }
+4 -1
View File
@@ -6,6 +6,7 @@ import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@workspace/ui/lib/utils" import { cn } from "@workspace/ui/lib/utils"
import { Button } from "@workspace/ui/components/button" import { Button } from "@workspace/ui/components/button"
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react"
import { LocalizedText, uiMessages } from "./i18n"
function createSheetHandle<Payload>(): SheetPrimitive.Handle<Payload> { function createSheetHandle<Payload>(): SheetPrimitive.Handle<Payload> {
return SheetPrimitive.createHandle<Payload>() return SheetPrimitive.createHandle<Payload>()
@@ -75,7 +76,9 @@ function SheetContent({
} }
> >
<XIcon /> <XIcon />
<span className="sr-only">Close</span> <span className="sr-only">
<LocalizedText message={uiMessages.close} />
</span>
</SheetPrimitive.Close> </SheetPrimitive.Close>
)} )}
</SheetPrimitive.Popup> </SheetPrimitive.Popup>
+25 -12
View File
@@ -22,6 +22,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@workspace/ui/components/tooltip" } from "@workspace/ui/components/tooltip"
import { PanelLeftIcon } from "lucide-react" import { PanelLeftIcon } from "lucide-react"
import { LocalizedText, uiMessages, useMessage } from "./i18n"
const SIDEBAR_COOKIE_NAME = "sidebar_state" const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
@@ -194,8 +195,12 @@ function Sidebar({
side={side} side={side}
> >
<SheetHeader className="sr-only"> <SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle> <SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription> <LocalizedText message={uiMessages.sidebar} />
</SheetTitle>
<SheetDescription>
<LocalizedText message={uiMessages.sidebarDescription} />
</SheetDescription>
</SheetHeader> </SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div> <div className="flex h-full w-full flex-col">{children}</div>
</SheetContent> </SheetContent>
@@ -270,27 +275,35 @@ function SidebarTrigger({
{...props} {...props}
> >
<PanelLeftIcon className="rtl:rotate-180" /> <PanelLeftIcon className="rtl:rotate-180" />
<span className="sr-only">Toggle Sidebar</span> <span className="sr-only">
<LocalizedText message={uiMessages.toggleSidebar} />
</span>
</Button> </Button>
) )
} }
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { function SidebarRail({
"aria-label": ariaLabel,
className,
title,
...props
}: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar() const { toggleSidebar } = useSidebar()
const toggleSidebarLabel = useMessage(uiMessages.toggleSidebar)
return ( return (
<button <button
data-sidebar="rail" data-sidebar="rail"
data-slot="sidebar-rail" data-slot="sidebar-rail"
aria-label="Toggle Sidebar" aria-label={ariaLabel ?? toggleSidebarLabel}
tabIndex={-1} tabIndex={-1}
onClick={toggleSidebar} onClick={toggleSidebar}
title="Toggle Sidebar" title={title ?? toggleSidebarLabel}
className={cn( className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2", "absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize rtl:in-data-[side=left]:cursor-e-resize in-data-[side=right]:cursor-e-resize rtl:in-data-[side=right]:cursor-w-resize", "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize rtl:in-data-[side=left]:cursor-e-resize rtl:in-data-[side=right]:cursor-w-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize rtl:[[data-side=left][data-state=collapsed]_&]:cursor-w-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize rtl:[[data-side=right][data-state=collapsed]_&]:cursor-e-resize", "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize rtl:[[data-side=left][data-state=collapsed]_&]:cursor-w-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize rtl:[[data-side=right][data-state=collapsed]_&]:cursor-e-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 rtl:group-data-[collapsible=offcanvas]:-translate-x-0 group-data-[collapsible=offcanvas]:after:start-full hover:group-data-[collapsible=offcanvas]:bg-sidebar", "group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:start-full hover:group-data-[collapsible=offcanvas]:bg-sidebar rtl:group-data-[collapsible=offcanvas]:-translate-x-0",
"[[data-side=left][data-collapsible=offcanvas]_&]:-end-2", "[[data-side=left][data-collapsible=offcanvas]_&]:-end-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-start-2", "[[data-side=right][data-collapsible=offcanvas]_&]:-start-2",
className className
@@ -422,7 +435,7 @@ function SidebarGroupAction({
props: mergeProps<"button">( props: mergeProps<"button">(
{ {
className: cn( className: cn(
"absolute top-3.5 end-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0", "absolute end-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className className
), ),
}, },
@@ -562,7 +575,7 @@ function SidebarMenuAction({
props: mergeProps<"button">( props: mergeProps<"button">(
{ {
className: cn( className: cn(
"absolute top-1.5 end-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0", "absolute end-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover && showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0", "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className className
@@ -639,7 +652,7 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
data-slot="sidebar-menu-sub" data-slot="sidebar-menu-sub"
data-sidebar="menu-sub" data-sidebar="menu-sub"
className={cn( className={cn(
"mx-3.5 flex min-w-0 translate-x-px rtl:-translate-x-px flex-col gap-1 border-s border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden", "mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-s border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden rtl:-translate-x-px",
className className
)} )}
{...props} {...props}
@@ -677,7 +690,7 @@ function SidebarMenuSubButton({
props: mergeProps<"a">( props: mergeProps<"a">(
{ {
className: cn( className: cn(
"flex h-7 min-w-0 -translate-x-px rtl:translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs rtl:translate-x-px data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className className
), ),
}, },
+10 -1
View File
@@ -1,9 +1,18 @@
import { cn } from "@workspace/ui/lib/utils" import { cn } from "@workspace/ui/lib/utils"
import { Loader2Icon } from "lucide-react" import { Loader2Icon } from "lucide-react"
import { uiMessages, useMessage } from "./i18n"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) { function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
const loadingLabel = useMessage(uiMessages.loading)
return ( return (
<Loader2Icon data-slot="spinner" role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} /> <Loader2Icon
data-slot="spinner"
role="status"
aria-label={loadingLabel}
className={cn("size-4 animate-spin", className)}
{...props}
/>
) )
} }
+33 -34
View File
@@ -3,7 +3,15 @@ import { Toast as ToastPrimitive } from "@base-ui/react/toast"
import { cn } from "@workspace/ui/lib/utils" import { cn } from "@workspace/ui/lib/utils"
import { Button } from "@workspace/ui/components/button" import { Button } from "@workspace/ui/components/button"
import { XIcon, CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react" import {
XIcon,
CircleCheckIcon,
InfoIcon,
TriangleAlertIcon,
OctagonXIcon,
Loader2Icon,
} from "lucide-react"
import { uiMessages, useMessage } from "./i18n"
const toast = ToastPrimitive.createToastManager() const toast = ToastPrimitive.createToastManager()
@@ -20,7 +28,7 @@ function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) {
<ToastPrimitive.Viewport <ToastPrimitive.Viewport
data-slot="toast-viewport" data-slot="toast-viewport"
className={cn( className={cn(
"pointer-events-none fixed inset-x-4 bottom-4 z-50 mx-auto w-auto max-w-sm outline-none sm:end-4 sm:start-auto sm:mx-0 sm:w-full", "pointer-events-none fixed inset-x-4 bottom-4 z-50 mx-auto w-auto max-w-sm outline-none sm:start-auto sm:end-4 sm:mx-0 sm:w-full",
className className
)} )}
{...props} {...props}
@@ -35,19 +43,19 @@ function Toast({ className, ...props }: ToastPrimitive.Root.Props) {
className={cn( className={cn(
"group/toast pointer-events-auto absolute end-0 bottom-0 z-[calc(1000-var(--toast-index))] w-full origin-bottom rounded-2xl border bg-popover text-popover-foreground shadow-lg will-change-transform outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50", "group/toast pointer-events-auto absolute end-0 bottom-0 z-[calc(1000-var(--toast-index))] w-full origin-bottom rounded-2xl border bg-popover text-popover-foreground shadow-lg will-change-transform outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"[--gap:0.75rem] [--height:var(--toast-frontmost-height,var(--toast-height))] [--offset-y:calc(var(--toast-offset-y)*-1+calc(var(--toast-index)*var(--gap)*-1)+var(--toast-swipe-movement-y))] [--peek:0.75rem] [--scale:calc(max(0,1-(var(--toast-index)*0.1)))] [--shrink:calc(1-var(--scale))]", "[--gap:0.75rem] [--height:var(--toast-frontmost-height,var(--toast-height))] [--offset-y:calc(var(--toast-offset-y)*-1+calc(var(--toast-index)*var(--gap)*-1)+var(--toast-swipe-movement-y))] [--peek:0.75rem] [--scale:calc(max(0,1-(var(--toast-index)*0.1)))] [--shrink:calc(1-var(--scale))]",
"h-(--height) [transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--peek))-(var(--shrink)*var(--height))))_scale(var(--scale))] [transition:transform_500ms_cubic-bezier(0.22,1,0.36,1),opacity_500ms,height_150ms]", "h-(--height) transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--peek))-(var(--shrink)*var(--height))))_scale(var(--scale))] [transition:transform_500ms_cubic-bezier(0.22,1,0.36,1),opacity_500ms,height_150ms]",
"after:absolute after:top-full after:start-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-['']", "after:absolute after:start-0 after:top-full after:h-[calc(var(--gap)+1px)] after:w-full after:content-['']",
"data-expanded:h-(--toast-height) data-expanded:[transform:translateX(var(--toast-swipe-movement-x))_translateY(var(--offset-y))]", "data-expanded:h-(--toast-height) data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(var(--offset-y))]",
"data-limited:opacity-0 data-starting-style:[transform:translateY(150%)]", "data-limited:opacity-0 data-starting-style:transform-[translateY(150%)]",
"[&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:[transform:translateY(150%)]", "[&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:transform-[translateY(150%)]",
"data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]", "data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+150%))]",
"data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]", "data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
"data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]", "data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
"data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]", "data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-150%))]",
"data-expanded:data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]", "data-expanded:data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+150%))]",
"data-expanded:data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]", "data-expanded:data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
"data-expanded:data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]", "data-expanded:data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
"data-expanded:data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]", "data-expanded:data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-150%))]",
className className
)} )}
{...props} {...props}
@@ -107,15 +115,18 @@ function ToastAction({
} }
function ToastClose({ function ToastClose({
"aria-label": ariaLabel,
className, className,
children, children,
render = <Button variant="ghost" size="icon-sm" />, render = <Button variant="ghost" size="icon-sm" />,
...props ...props
}: ToastPrimitive.Close.Props) { }: ToastPrimitive.Close.Props) {
const closeLabel = useMessage(uiMessages.closeToast)
return ( return (
<ToastPrimitive.Close <ToastPrimitive.Close
data-slot="toast-close" data-slot="toast-close"
aria-label="Close toast" aria-label={ariaLabel ?? closeLabel}
render={render} render={render}
className={cn( className={cn(
"relative shrink-0 text-muted-foreground after:absolute after:-inset-2 after:content-[''] hover:text-foreground", "relative shrink-0 text-muted-foreground after:absolute after:-inset-2 after:content-[''] hover:text-foreground",
@@ -123,9 +134,7 @@ function ToastClose({
)} )}
{...props} {...props}
> >
{children ?? ( {children ?? <XIcon aria-hidden="true" />}
<XIcon aria-hidden="true" />
)}
</ToastPrimitive.Close> </ToastPrimitive.Close>
) )
} }
@@ -134,33 +143,23 @@ function ToastIcon({ type }: { type: string | undefined }) {
let icon: React.ReactNode = null let icon: React.ReactNode = null
if (type === "success") { if (type === "success") {
icon = ( icon = <CircleCheckIcon aria-hidden="true" />
<CircleCheckIcon aria-hidden="true" />
)
} }
if (type === "info") { if (type === "info") {
icon = ( icon = <InfoIcon aria-hidden="true" />
<InfoIcon aria-hidden="true" />
)
} }
if (type === "warning") { if (type === "warning") {
icon = ( icon = <TriangleAlertIcon aria-hidden="true" />
<TriangleAlertIcon aria-hidden="true" />
)
} }
if (type === "error") { if (type === "error") {
icon = ( icon = <OctagonXIcon className="text-destructive" aria-hidden="true" />
<OctagonXIcon className="text-destructive" aria-hidden="true" />
)
} }
if (type === "loading") { if (type === "loading") {
icon = ( icon = <Loader2Icon className="animate-spin" aria-hidden="true" />
<Loader2Icon className="animate-spin" aria-hidden="true" />
)
} }
if (!icon) { if (!icon) {
+33
View File
@@ -0,0 +1,33 @@
export const uiCatalogLocales = [
"de",
"en",
"es",
"fr",
"ja",
"ko",
"zh-Hans",
"zh-Hant",
] as const
export const uiMessageIds = [
"ui.breadcrumb.label",
"ui.carousel.nextSlide",
"ui.carousel.previousSlide",
"ui.common.close",
"ui.common.loading",
"ui.common.more",
"ui.pagination.label",
"ui.pagination.more",
"ui.pagination.next",
"ui.pagination.nextPage",
"ui.pagination.previous",
"ui.pagination.previousPage",
"ui.sidebar.description",
"ui.sidebar.title",
"ui.sidebar.toggle",
"ui.toast.close",
] as const
export type UiCatalogLocale = (typeof uiCatalogLocales)[number]
export type UiMessageId = (typeof uiMessageIds)[number]
export type UiMessageCatalog = Readonly<Record<UiMessageId, string>>
+25
View File
@@ -0,0 +1,25 @@
export { de as calendarLocale } from "react-day-picker/locale/de"
import type { UiMessageCatalog } from "../catalogs"
export const locale = "de"
export const languageTag = "de-DE"
export const messages = {
"ui.breadcrumb.label": "Breadcrumb-Navigation",
"ui.carousel.nextSlide": "Nächste Folie",
"ui.carousel.previousSlide": "Vorherige Folie",
"ui.common.close": "Schließen",
"ui.common.loading": "Wird geladen",
"ui.common.more": "Mehr",
"ui.pagination.label": "Seitennavigation",
"ui.pagination.more": "Weitere Seiten",
"ui.pagination.next": "Weiter",
"ui.pagination.nextPage": "Zur nächsten Seite",
"ui.pagination.previous": "Zurück",
"ui.pagination.previousPage": "Zur vorherigen Seite",
"ui.sidebar.description": "Zeigt die mobile Seitenleiste an.",
"ui.sidebar.title": "Seitenleiste",
"ui.sidebar.toggle": "Seitenleiste umschalten",
"ui.toast.close": "Benachrichtigung schließen",
} as const satisfies UiMessageCatalog
+25
View File
@@ -0,0 +1,25 @@
export { enUS as calendarLocale } from "react-day-picker/locale/en-US"
import type { UiMessageCatalog } from "../catalogs"
export const locale = "en"
export const languageTag = "en-US"
export const messages = {
"ui.breadcrumb.label": "Breadcrumb",
"ui.carousel.nextSlide": "Next slide",
"ui.carousel.previousSlide": "Previous slide",
"ui.common.close": "Close",
"ui.common.loading": "Loading",
"ui.common.more": "More",
"ui.pagination.label": "Pagination",
"ui.pagination.more": "More pages",
"ui.pagination.next": "Next",
"ui.pagination.nextPage": "Go to next page",
"ui.pagination.previous": "Previous",
"ui.pagination.previousPage": "Go to previous page",
"ui.sidebar.description": "Displays the mobile sidebar.",
"ui.sidebar.title": "Sidebar",
"ui.sidebar.toggle": "Toggle sidebar",
"ui.toast.close": "Close notification",
} as const satisfies UiMessageCatalog
+25
View File
@@ -0,0 +1,25 @@
export { es as calendarLocale } from "react-day-picker/locale/es"
import type { UiMessageCatalog } from "../catalogs"
export const locale = "es"
export const languageTag = "es-ES"
export const messages = {
"ui.breadcrumb.label": "Ruta de navegación",
"ui.carousel.nextSlide": "Diapositiva siguiente",
"ui.carousel.previousSlide": "Diapositiva anterior",
"ui.common.close": "Cerrar",
"ui.common.loading": "Cargando",
"ui.common.more": "Más",
"ui.pagination.label": "Paginación",
"ui.pagination.more": "Más páginas",
"ui.pagination.next": "Siguiente",
"ui.pagination.nextPage": "Ir a la página siguiente",
"ui.pagination.previous": "Anterior",
"ui.pagination.previousPage": "Ir a la página anterior",
"ui.sidebar.description": "Muestra la barra lateral móvil.",
"ui.sidebar.title": "Barra lateral",
"ui.sidebar.toggle": "Alternar barra lateral",
"ui.toast.close": "Cerrar notificación",
} as const satisfies UiMessageCatalog
+25
View File
@@ -0,0 +1,25 @@
export { fr as calendarLocale } from "react-day-picker/locale/fr"
import type { UiMessageCatalog } from "../catalogs"
export const locale = "fr"
export const languageTag = "fr-FR"
export const messages = {
"ui.breadcrumb.label": "Fil dAriane",
"ui.carousel.nextSlide": "Diapositive suivante",
"ui.carousel.previousSlide": "Diapositive précédente",
"ui.common.close": "Fermer",
"ui.common.loading": "Chargement",
"ui.common.more": "Plus",
"ui.pagination.label": "Pagination",
"ui.pagination.more": "Plus de pages",
"ui.pagination.next": "Suivant",
"ui.pagination.nextPage": "Aller à la page suivante",
"ui.pagination.previous": "Précédent",
"ui.pagination.previousPage": "Aller à la page précédente",
"ui.sidebar.description": "Affiche la barre latérale mobile.",
"ui.sidebar.title": "Barre latérale",
"ui.sidebar.toggle": "Afficher ou masquer la barre latérale",
"ui.toast.close": "Fermer la notification",
} as const satisfies UiMessageCatalog
+25
View File
@@ -0,0 +1,25 @@
export { ja as calendarLocale } from "react-day-picker/locale/ja"
import type { UiMessageCatalog } from "../catalogs"
export const locale = "ja"
export const languageTag = "ja-JP"
export const messages = {
"ui.breadcrumb.label": "パンくずリスト",
"ui.carousel.nextSlide": "次のスライド",
"ui.carousel.previousSlide": "前のスライド",
"ui.common.close": "閉じる",
"ui.common.loading": "読み込み中",
"ui.common.more": "その他",
"ui.pagination.label": "ページネーション",
"ui.pagination.more": "その他のページ",
"ui.pagination.next": "次へ",
"ui.pagination.nextPage": "次のページへ移動",
"ui.pagination.previous": "前へ",
"ui.pagination.previousPage": "前のページへ移動",
"ui.sidebar.description": "モバイルサイドバーを表示します。",
"ui.sidebar.title": "サイドバー",
"ui.sidebar.toggle": "サイドバーを切り替える",
"ui.toast.close": "通知を閉じる",
} as const satisfies UiMessageCatalog
+25
View File
@@ -0,0 +1,25 @@
export { ko as calendarLocale } from "react-day-picker/locale/ko"
import type { UiMessageCatalog } from "../catalogs"
export const locale = "ko"
export const languageTag = "ko-KR"
export const messages = {
"ui.breadcrumb.label": "이동 경로",
"ui.carousel.nextSlide": "다음 슬라이드",
"ui.carousel.previousSlide": "이전 슬라이드",
"ui.common.close": "닫기",
"ui.common.loading": "불러오는 중",
"ui.common.more": "더 보기",
"ui.pagination.label": "페이지 탐색",
"ui.pagination.more": "더 많은 페이지",
"ui.pagination.next": "다음",
"ui.pagination.nextPage": "다음 페이지로 이동",
"ui.pagination.previous": "이전",
"ui.pagination.previousPage": "이전 페이지로 이동",
"ui.sidebar.description": "모바일 사이드바를 표시합니다.",
"ui.sidebar.title": "사이드바",
"ui.sidebar.toggle": "사이드바 전환",
"ui.toast.close": "알림 닫기",
} as const satisfies UiMessageCatalog
+25
View File
@@ -0,0 +1,25 @@
export { zhCN as calendarLocale } from "react-day-picker/locale/zh-CN"
import type { UiMessageCatalog } from "../catalogs"
export const locale = "zh-Hans"
export const languageTag = "zh-CN"
export const messages = {
"ui.breadcrumb.label": "面包屑导航",
"ui.carousel.nextSlide": "下一张",
"ui.carousel.previousSlide": "上一张",
"ui.common.close": "关闭",
"ui.common.loading": "正在加载",
"ui.common.more": "更多",
"ui.pagination.label": "分页导航",
"ui.pagination.more": "更多页面",
"ui.pagination.next": "下一页",
"ui.pagination.nextPage": "前往下一页",
"ui.pagination.previous": "上一页",
"ui.pagination.previousPage": "前往上一页",
"ui.sidebar.description": "显示移动端侧栏。",
"ui.sidebar.title": "侧栏",
"ui.sidebar.toggle": "切换侧栏",
"ui.toast.close": "关闭通知",
} as const satisfies UiMessageCatalog
+25
View File
@@ -0,0 +1,25 @@
export { zhTW as calendarLocale } from "react-day-picker/locale/zh-TW"
import type { UiMessageCatalog } from "../catalogs"
export const locale = "zh-Hant"
export const languageTag = "zh-TW"
export const messages = {
"ui.breadcrumb.label": "麵包屑導覽",
"ui.carousel.nextSlide": "下一張",
"ui.carousel.previousSlide": "上一張",
"ui.common.close": "關閉",
"ui.common.loading": "載入中",
"ui.common.more": "更多",
"ui.pagination.label": "分頁導覽",
"ui.pagination.more": "更多頁面",
"ui.pagination.next": "下一頁",
"ui.pagination.nextPage": "前往下一頁",
"ui.pagination.previous": "上一頁",
"ui.pagination.previousPage": "前往上一頁",
"ui.sidebar.description": "顯示行動版側欄。",
"ui.sidebar.title": "側欄",
"ui.sidebar.toggle": "切換側欄",
"ui.toast.close": "關閉通知",
} as const satisfies UiMessageCatalog