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
+14 -8
View File
@@ -4,11 +4,18 @@ import { useRender } from "@base-ui/react/use-render"
import { cn } from "@workspace/ui/lib/utils"
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 (
<nav
aria-label="breadcrumb"
aria-label={ariaLabel ?? defaultLabel}
data-slot="breadcrumb"
className={cn(className)}
{...props}
@@ -85,9 +92,7 @@ function BreadcrumbSeparator({
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? (
<ChevronRightIcon className="rtl:rotate-180" />
)}
{children ?? <ChevronRightIcon className="rtl:rotate-180" />}
</li>
)
}
@@ -107,9 +112,10 @@ function BreadcrumbEllipsis({
)}
{...props}
>
<MoreHorizontalIcon
/>
<span className="sr-only">More</span>
<MoreHorizontalIcon />
<span className="sr-only">
<LocalizedText message={uiMessages.more} />
</span>
</span>
)
}
+25 -9
View File
@@ -5,12 +5,16 @@ import {
DayPicker,
getDefaultClassNames,
type DayButton,
type Locale,
} from "react-day-picker"
import { cn } from "@workspace/ui/lib/utils"
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({
className,
@@ -18,7 +22,8 @@ function Calendar({
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
locale,
locale: providedLocale,
lang,
formatters,
components,
...props
@@ -26,6 +31,10 @@ function Calendar({
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
const contextLocale = useLocale()
const contextCalendarLocale = useCalendarLocale()
const locale = providedLocale ?? contextCalendarLocale
const localeCode = locale?.code ?? contextLocale
return (
<DayPicker
@@ -38,9 +47,10 @@ function Calendar({
)}
captionLayout={captionLayout}
locale={locale}
lang={lang ?? localeCode}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString(locale?.code, { month: "short" }),
date.toLocaleString(localeCode, { month: "short" }),
...formatters,
}}
classNames={{
@@ -147,13 +157,19 @@ function Calendar({
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("rtl:rotate-180 size-4", className)} {...props} />
<ChevronLeftIcon
className={cn("size-4 rtl:rotate-180", className)}
{...props}
/>
)
}
if (orientation === "right") {
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 }) => (
<CalendarDayButton locale={locale} {...props} />
<CalendarDayButton locale={localeCode} {...props} />
),
WeekNumber: ({ children, ...props }) => {
return (
@@ -186,7 +202,7 @@ function CalendarDayButton({
modifiers,
locale,
...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
}: React.ComponentProps<typeof DayButton> & { locale?: string }) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
@@ -198,7 +214,7 @@ function CalendarDayButton({
<Button
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(locale?.code)}
data-day={day.date.toLocaleDateString(locale)}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
+9 -4
View File
@@ -6,6 +6,7 @@ import useEmblaCarousel, {
import { cn } from "@workspace/ui/lib/utils"
import { Button } from "@workspace/ui/components/button"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
import { LocalizedText, uiMessages } from "./i18n"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
@@ -186,7 +187,7 @@ function CarouselPrevious({
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "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
)}
disabled={!canScrollPrev}
@@ -194,7 +195,9 @@ function CarouselPrevious({
{...props}
>
<ChevronLeftIcon className="rtl:rotate-180" />
<span className="sr-only">Previous slide</span>
<span className="sr-only">
<LocalizedText message={uiMessages.previousSlide} />
</span>
</Button>
)
}
@@ -216,7 +219,7 @@ function CarouselNext({
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "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
)}
disabled={!canScrollNext}
@@ -224,7 +227,9 @@ function CarouselNext({
{...props}
>
<ChevronRightIcon className="rtl:rotate-180" />
<span className="sr-only">Next slide</span>
<span className="sr-only">
<LocalizedText message={uiMessages.nextSlide} />
</span>
</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 { Button } from "@workspace/ui/components/button"
import { XIcon } from "lucide-react"
import { LocalizedText, uiMessages } from "./i18n"
type DialogHandle<Payload> = DialogPrimitive.Handle<Payload>
@@ -59,7 +60,7 @@ function DialogContent({
<DialogPrimitive.Popup
data-slot="dialog-content"
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
)}
{...props}
@@ -71,14 +72,15 @@ function DialogContent({
render={
<Button
variant="ghost"
className="absolute top-4 end-4"
className="absolute end-4 top-4"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
<XIcon />
<span className="sr-only">
<LocalizedText message={uiMessages.close} />
</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
@@ -116,7 +118,7 @@ function DialogFooter({
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
<LocalizedText message={uiMessages.close} />
</DialogPrimitive.Close>
)}
</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 { 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 (
<nav
role="navigation"
aria-label="pagination"
aria-label={ariaLabel ?? defaultLabel}
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
@@ -64,35 +75,43 @@ function PaginationLink({
function PaginationPrevious({
className,
text = "Previous",
text,
"aria-label": ariaLabel,
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
const previousLabel = useMessage(uiMessages.previous)
const previousPageLabel = useMessage(uiMessages.previousPage)
return (
<PaginationLink
aria-label="Go to previous page"
aria-label={ariaLabel ?? previousPageLabel}
size="default"
className={cn("ps-2!", className)}
{...props}
>
<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>
)
}
function PaginationNext({
className,
text = "Next",
text,
"aria-label": ariaLabel,
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
const nextLabel = useMessage(uiMessages.next)
const nextPageLabel = useMessage(uiMessages.nextPage)
return (
<PaginationLink
aria-label="Go to next page"
aria-label={ariaLabel ?? nextPageLabel}
size="default"
className={cn("pe-2!", className)}
{...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" />
</PaginationLink>
)
@@ -112,9 +131,10 @@ function PaginationEllipsis({
)}
{...props}
>
<MoreHorizontalIcon
/>
<span className="sr-only">More pages</span>
<MoreHorizontalIcon />
<span className="sr-only">
<LocalizedText message={uiMessages.morePages} />
</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 { Button } from "@workspace/ui/components/button"
import { XIcon } from "lucide-react"
import { LocalizedText, uiMessages } from "./i18n"
function createSheetHandle<Payload>(): SheetPrimitive.Handle<Payload> {
return SheetPrimitive.createHandle<Payload>()
@@ -75,7 +76,9 @@ function SheetContent({
}
>
<XIcon />
<span className="sr-only">Close</span>
<span className="sr-only">
<LocalizedText message={uiMessages.close} />
</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
+25 -12
View File
@@ -22,6 +22,7 @@ import {
TooltipTrigger,
} from "@workspace/ui/components/tooltip"
import { PanelLeftIcon } from "lucide-react"
import { LocalizedText, uiMessages, useMessage } from "./i18n"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
@@ -194,8 +195,12 @@ function Sidebar({
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
<SheetTitle>
<LocalizedText message={uiMessages.sidebar} />
</SheetTitle>
<SheetDescription>
<LocalizedText message={uiMessages.sidebarDescription} />
</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
@@ -270,27 +275,35 @@ function SidebarTrigger({
{...props}
>
<PanelLeftIcon className="rtl:rotate-180" />
<span className="sr-only">Toggle Sidebar</span>
<span className="sr-only">
<LocalizedText message={uiMessages.toggleSidebar} />
</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
function SidebarRail({
"aria-label": ariaLabel,
className,
title,
...props
}: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
const toggleSidebarLabel = useMessage(uiMessages.toggleSidebar)
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
aria-label={ariaLabel ?? toggleSidebarLabel}
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
title={title ?? toggleSidebarLabel}
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",
"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",
"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=right][data-collapsible=offcanvas]_&]:-start-2",
className
@@ -422,7 +435,7 @@ function SidebarGroupAction({
props: mergeProps<"button">(
{
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
),
},
@@ -562,7 +575,7 @@ function SidebarMenuAction({
props: mergeProps<"button">(
{
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 &&
"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
@@ -639,7 +652,7 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
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
)}
{...props}
@@ -677,7 +690,7 @@ function SidebarMenuSubButton({
props: mergeProps<"a">(
{
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
),
},
+10 -1
View File
@@ -1,9 +1,18 @@
import { cn } from "@workspace/ui/lib/utils"
import { Loader2Icon } from "lucide-react"
import { uiMessages, useMessage } from "./i18n"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
const loadingLabel = useMessage(uiMessages.loading)
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 { 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()
@@ -20,7 +28,7 @@ function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) {
<ToastPrimitive.Viewport
data-slot="toast-viewport"
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
)}
{...props}
@@ -35,19 +43,19 @@ function Toast({ className, ...props }: ToastPrimitive.Root.Props) {
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",
"[--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]",
"after:absolute after:top-full after:start-0 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-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: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=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-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=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%))]",
"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: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-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: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=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-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=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%))]",
className
)}
{...props}
@@ -107,15 +115,18 @@ function ToastAction({
}
function ToastClose({
"aria-label": ariaLabel,
className,
children,
render = <Button variant="ghost" size="icon-sm" />,
...props
}: ToastPrimitive.Close.Props) {
const closeLabel = useMessage(uiMessages.closeToast)
return (
<ToastPrimitive.Close
data-slot="toast-close"
aria-label="Close toast"
aria-label={ariaLabel ?? closeLabel}
render={render}
className={cn(
"relative shrink-0 text-muted-foreground after:absolute after:-inset-2 after:content-[''] hover:text-foreground",
@@ -123,9 +134,7 @@ function ToastClose({
)}
{...props}
>
{children ?? (
<XIcon aria-hidden="true" />
)}
{children ?? <XIcon aria-hidden="true" />}
</ToastPrimitive.Close>
)
}
@@ -134,33 +143,23 @@ function ToastIcon({ type }: { type: string | undefined }) {
let icon: React.ReactNode = null
if (type === "success") {
icon = (
<CircleCheckIcon aria-hidden="true" />
)
icon = <CircleCheckIcon aria-hidden="true" />
}
if (type === "info") {
icon = (
<InfoIcon aria-hidden="true" />
)
icon = <InfoIcon aria-hidden="true" />
}
if (type === "warning") {
icon = (
<TriangleAlertIcon aria-hidden="true" />
)
icon = <TriangleAlertIcon aria-hidden="true" />
}
if (type === "error") {
icon = (
<OctagonXIcon className="text-destructive" aria-hidden="true" />
)
icon = <OctagonXIcon className="text-destructive" aria-hidden="true" />
}
if (type === "loading") {
icon = (
<Loader2Icon className="animate-spin" aria-hidden="true" />
)
icon = <Loader2Icon className="animate-spin" aria-hidden="true" />
}
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