feat(web): complete i18n coverage for UI primitives (#268)

Replace hardcoded strings in dialog, sheet, and sidebar components
with t() calls. Adds common.close, common.sidebar,
common.sidebarDescription, and common.toggleSidebar keys to both
en.json and zh.json. All 124 keys now have parity across locales.

Agent-Profile: https://agent-kanban.dev/agents/b724a773425e397c
This commit is contained in:
Jasper Van
2026-04-08 21:40:41 -04:00
committed by GitHub
parent f9a36e4afc
commit e66c2e508c
6 changed files with 169 additions and 10 deletions
+5 -2
View File
@@ -1,6 +1,7 @@
import { XIcon } from 'lucide-react'
import { Dialog as DialogPrimitive } from 'radix-ui'
import type * as React from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
@@ -41,6 +42,7 @@ function DialogContent({
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
const { t } = useTranslation()
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
@@ -59,7 +61,7 @@ function DialogContent({
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
<span className="sr-only">{t('common.close')}</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
@@ -85,6 +87,7 @@ function DialogFooter({
}: React.ComponentProps<'div'> & {
showCloseButton?: boolean
}) {
const { t } = useTranslation()
return (
<div
data-slot="dialog-footer"
@@ -94,7 +97,7 @@ function DialogFooter({
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
<Button variant="outline">{t('common.close')}</Button>
</DialogPrimitive.Close>
)}
</div>
+3 -1
View File
@@ -3,6 +3,7 @@
import { XIcon } from 'lucide-react'
import { Dialog as SheetPrimitive } from 'radix-ui'
import type * as React from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
@@ -45,6 +46,7 @@ function SheetContent({
side?: 'top' | 'right' | 'bottom' | 'left'
showCloseButton?: boolean
}) {
const { t } = useTranslation()
return (
<SheetPortal>
<SheetOverlay />
@@ -68,7 +70,7 @@ function SheetContent({
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
<span className="sr-only">{t('common.close')}</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
+9 -5
View File
@@ -2,6 +2,7 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { PanelLeftIcon } from 'lucide-react'
import { Slot } from 'radix-ui'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
@@ -144,6 +145,7 @@ function Sidebar({
collapsible?: 'offcanvas' | 'icon' | 'none'
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
const { t } = useTranslation()
if (collapsible === 'none') {
return (
@@ -173,8 +175,8 @@ function Sidebar({
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
<SheetTitle>{t('common.sidebar')}</SheetTitle>
<SheetDescription>{t('common.sidebarDescription')}</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
@@ -232,6 +234,7 @@ function Sidebar({
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
const { t } = useTranslation()
return (
<Button
@@ -247,22 +250,23 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
<span className="sr-only">{t('common.toggleSidebar')}</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
const { toggleSidebar } = useSidebar()
const { t } = useTranslation()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
aria-label={t('common.toggleSidebar')}
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
title={t('common.toggleSidebar')}
className={cn(
'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
+142
View File
@@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest'
import en from './locales/en.json'
import zh from './locales/zh.json'
const enLocale = en as Record<string, string>
const zhLocale = zh as Record<string, string>
const NEW_COMMON_KEYS = ['common.close', 'common.sidebar', 'common.sidebarDescription', 'common.toggleSidebar']
describe('common locale keys — presence', () => {
for (const key of NEW_COMMON_KEYS) {
it(`en.json contains key "${key}"`, () => {
expect(Object.hasOwn(enLocale, key)).toBe(true)
})
it(`zh.json contains key "${key}"`, () => {
expect(Object.hasOwn(zhLocale, key)).toBe(true)
})
}
})
describe('common locale keys — non-empty values', () => {
for (const key of NEW_COMMON_KEYS) {
it(`en.json value for "${key}" is not empty`, () => {
expect(enLocale[key]).toBeTruthy()
})
it(`zh.json value for "${key}" is not empty`, () => {
expect(zhLocale[key]).toBeTruthy()
})
}
})
describe('common locale keys — English values contract', () => {
it('common.close is "Close"', () => {
expect(enLocale['common.close']).toBe('Close')
})
it('common.sidebar is "Sidebar"', () => {
expect(enLocale['common.sidebar']).toBe('Sidebar')
})
it('common.sidebarDescription is "Displays the mobile sidebar."', () => {
expect(enLocale['common.sidebarDescription']).toBe('Displays the mobile sidebar.')
})
it('common.toggleSidebar is "Toggle Sidebar"', () => {
expect(enLocale['common.toggleSidebar']).toBe('Toggle Sidebar')
})
})
describe('common locale keys — Chinese values contract', () => {
it('common.close is "关闭"', () => {
expect(zhLocale['common.close']).toBe('关闭')
})
it('common.sidebar is "侧边栏"', () => {
expect(zhLocale['common.sidebar']).toBe('侧边栏')
})
it('common.sidebarDescription is "显示移动端侧边栏。"', () => {
expect(zhLocale['common.sidebarDescription']).toBe('显示移动端侧边栏。')
})
it('common.toggleSidebar is "切换侧边栏"', () => {
expect(zhLocale['common.toggleSidebar']).toBe('切换侧边栏')
})
})
describe('common locale keys — i18n runtime translation', () => {
it('translates common.close to English', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('en')
expect(i18n.t('common.close')).toBe('Close')
})
it('translates common.close to Chinese', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('zh')
expect(i18n.t('common.close')).toBe('关闭')
})
it('translates common.sidebar to English', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('en')
expect(i18n.t('common.sidebar')).toBe('Sidebar')
})
it('translates common.sidebar to Chinese', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('zh')
expect(i18n.t('common.sidebar')).toBe('侧边栏')
})
it('translates common.sidebarDescription to English', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('en')
expect(i18n.t('common.sidebarDescription')).toBe('Displays the mobile sidebar.')
})
it('translates common.sidebarDescription to Chinese', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('zh')
expect(i18n.t('common.sidebarDescription')).toBe('显示移动端侧边栏。')
})
it('translates common.toggleSidebar to English', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('en')
expect(i18n.t('common.toggleSidebar')).toBe('Toggle Sidebar')
})
it('translates common.toggleSidebar to Chinese', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('zh')
expect(i18n.t('common.toggleSidebar')).toBe('切换侧边栏')
})
})
describe('locale key parity — en.json and zh.json have identical key sets', () => {
it('en.json and zh.json have the same number of keys', () => {
expect(Object.keys(enLocale).length).toBe(Object.keys(zhLocale).length)
})
it('every key in en.json exists in zh.json', () => {
const enKeys = Object.keys(enLocale).sort()
const zhKeys = Object.keys(zhLocale).sort()
expect(enKeys).toEqual(zhKeys)
})
it('no key has an empty value in en.json', () => {
for (const key of Object.keys(enLocale)) {
expect(enLocale[key], `en.json key "${key}" must not be empty`).toBeTruthy()
}
})
it('no key has an empty value in zh.json', () => {
for (const key of Object.keys(zhLocale)) {
expect(zhLocale[key], `zh.json key "${key}" must not be empty`).toBeTruthy()
}
})
})
+5 -1
View File
@@ -139,7 +139,11 @@
"common.delete": "Delete",
"common.edit": "Edit",
"common.confirm": "Confirm",
"common.close": "Close",
"common.loading": "Loading...",
"common.error": "Error",
"common.success": "Success"
"common.success": "Success",
"common.sidebar": "Sidebar",
"common.sidebarDescription": "Displays the mobile sidebar.",
"common.toggleSidebar": "Toggle Sidebar"
}
+5 -1
View File
@@ -139,7 +139,11 @@
"common.delete": "删除",
"common.edit": "编辑",
"common.confirm": "确认",
"common.close": "关闭",
"common.loading": "加载中...",
"common.error": "错误",
"common.success": "成功"
"common.success": "成功",
"common.sidebar": "侧边栏",
"common.sidebarDescription": "显示移动端侧边栏。",
"common.toggleSidebar": "切换侧边栏"
}