feat(i18n): expand catalog runtime and developer tooling

- add Intl-backed locale definitions, formatters, catalog loaders, and runtime hooks\n- compile hashed production catalogs from package-scoped catalog sources\n- drive Lingui extraction and compilation directly from project JSON config\n- redesign the devtool with localized controls, draggable persistence, settings, navigation, copy actions, ANSI output, and fullscreen support\n- extend repository APIs, CLI coverage, documentation, and runtime tests
This commit is contained in:
Maofeng
2026-07-30 14:47:42 +08:00
parent 0d0aad1d47
commit 0768e8fa29
55 changed files with 7553 additions and 495 deletions
@@ -0,0 +1,29 @@
import * as React from "react"
import { renderToStaticMarkup } from "react-dom/server"
import { describe, expect, it } from "vitest"
import { AnsiText, resolveAnsiComponent } from "./ansi-text"
describe("AnsiText", () => {
it("resolves direct, CommonJS, and nested default exports", () => {
const Component = ({ children }: { children?: string }) => (
<code>{children}</code>
)
expect(resolveAnsiComponent(Component)).toBe(Component)
expect(resolveAnsiComponent({ default: Component })).toBe(Component)
expect(resolveAnsiComponent({ default: { default: Component } })).toBe(
Component
)
})
it("renders ANSI SGR output during server rendering", () => {
const markup = renderToStaticMarkup(
<AnsiText>{"\u001b[32mDone\u001b[39m"}</AnsiText>
)
expect(markup).toContain("Done")
expect(markup).toContain("color:")
expect(markup).not.toContain("\u001b")
})
})
+39
View File
@@ -0,0 +1,39 @@
import * as React from "react"
import * as AnsiModule from "ansi-to-react"
export interface AnsiTextProps {
children?: string
className?: string
linkify?: boolean | "fuzzy"
useClasses?: boolean
}
type AnsiComponent = React.ComponentType<AnsiTextProps>
export function resolveAnsiComponent(moduleValue: unknown): AnsiComponent {
let candidate = moduleValue
for (let depth = 0; depth < 3; depth += 1) {
if (typeof candidate === "function") {
return candidate as AnsiComponent
}
if (
typeof candidate !== "object" ||
candidate === null ||
!("default" in candidate)
) {
break
}
candidate = candidate.default
}
throw new TypeError("ansi-to-react did not export a React component.")
}
const AnsiComponent = resolveAnsiComponent(AnsiModule)
export function AnsiText(props: AnsiTextProps) {
return <AnsiComponent {...props} />
}
+11
View File
@@ -1,5 +1,10 @@
export type DevtoolAction = "compile" | "extract"
export interface DevtoolProject {
locales: readonly string[]
sourceLocale: string
}
interface ApiError {
error?: string
}
@@ -29,3 +34,9 @@ export async function runDevtoolAction(
{ method: "POST" }
)
}
export async function requestDevtoolProject(baseUrl = "/__i18n") {
const normalizedBaseUrl = baseUrl.replace(/\/+$/, "")
return requestDevtoolJson<DevtoolProject>(`${normalizedBaseUrl}/project`)
}
+11 -4
View File
@@ -40,19 +40,26 @@ export function DevtoolApp() {
)
}
const locale = state.project.locales[0]
const locale =
state.project.locales.find(
(locale) => locale !== state.project.sourceLocale
) ?? state.project.sourceLocale
if (!locale) {
if (!state.project.locales.includes(locale)) {
return (
<main className="devtool-app__state">
Add a locale with <code>workspace-i18n new &lt;locale&gt;</code>.
Add a target locale with <code>workspace-i18n new &lt;locale&gt;</code>.
</main>
)
}
return (
<I18nProvider locale={locale} locales={state.project.locales}>
<I18nDevtool defaultOpen mode="standalone" />
<I18nDevtool
defaultOpen
mode="standalone"
sourceLocale={state.project.sourceLocale}
/>
</I18nProvider>
)
}
@@ -0,0 +1,65 @@
import {
ChevronDownIcon,
ChevronUpIcon,
TerminalIcon,
XIcon,
} from "lucide-react"
import { AnsiText } from "./ansi-text"
import { useDevtoolLocalization } from "./devtool-localization"
export interface DevtoolActionOutputProps {
error?: boolean
expanded: boolean
onClose: () => void
onExpandedChange: (expanded: boolean) => void
output: string
}
export function DevtoolActionOutput({
error = false,
expanded,
onClose,
onExpandedChange,
output,
}: DevtoolActionOutputProps) {
const ToggleIcon = expanded ? ChevronUpIcon : ChevronDownIcon
const { messages } = useDevtoolLocalization()
return (
<section className="i18n-devtool__output" data-error={error || undefined}>
<header className="i18n-devtool__output-header">
<span>
<TerminalIcon aria-hidden="true" />
{messages.commandOutput.title}
</span>
<div>
<button
type="button"
aria-expanded={expanded}
aria-label={
expanded
? messages.commandOutput.collapse
: messages.commandOutput.expand
}
onClick={() => onExpandedChange(!expanded)}
>
<ToggleIcon aria-hidden="true" />
</button>
<button
type="button"
aria-label={messages.commandOutput.close}
onClick={onClose}
>
<XIcon aria-hidden="true" />
</button>
</div>
</header>
{expanded && (
<pre>
<AnsiText>{output}</AnsiText>
</pre>
)}
</section>
)
}
@@ -0,0 +1,50 @@
import type { LocaleDefinition } from "../runtime"
import { useDevtoolLocalization } from "./devtool-localization"
import { DevtoolSelect } from "./devtool-select"
export interface DevtoolLocaleSelectProps {
locale: string
locales: readonly LocaleDefinition[]
onLocaleChange: (locale: string) => void
sourceLocaleLabel?: string
}
export function DevtoolLocaleSelect({
locale,
locales,
onLocaleChange,
sourceLocaleLabel,
}: DevtoolLocaleSelectProps) {
const { messages } = useDevtoolLocalization()
return (
<>
{sourceLocaleLabel && (
<div className="i18n-devtool__source-locale-field">
<span>{messages.locale.sourceLabel}</span>
<output className="i18n-devtool__source-locale">
{sourceLocaleLabel}
</output>
</div>
)}
<label className="i18n-devtool__locale-field">
<span>{messages.locale.label}</span>
<DevtoolSelect
aria-label={messages.locale.controlLabel}
className="i18n-devtool__locale-select"
disabled={locales.length === 0}
options={
locales.length === 0
? [{ label: messages.locale.noTargetLocales, value: "" }]
: locales.map((definition) => ({
label: definition.label ?? definition.locale,
value: definition.locale,
}))
}
value={locale}
onChange={(event) => onLocaleChange(event.target.value)}
/>
</label>
</>
)
}
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest"
import { resolveI18nDevtoolLocale } from "./devtool-localization"
describe("resolveI18nDevtoolLocale", () => {
it("normalizes common BCP 47 language tags", () => {
expect(resolveI18nDevtoolLocale("zh-CN")).toBe("zh-Hans")
expect(resolveI18nDevtoolLocale("zh-TW")).toBe("zh-Hant")
expect(resolveI18nDevtoolLocale("es-419")).toBe("es")
})
it("selects the first supported preferred locale", () => {
expect(
resolveI18nDevtoolLocale(undefined, ["pt-BR", "fr-CA", "en-US"])
).toBe("fr")
})
it("falls back to English", () => {
expect(resolveI18nDevtoolLocale(undefined, ["pt-BR"])).toBe("en")
})
})
@@ -0,0 +1,933 @@
import * as React from "react"
export const I18N_DEVTOOL_LOCALES = [
"en",
"de",
"es",
"fr",
"ja",
"ko",
"zh-Hans",
"zh-Hant",
] as const
export type I18nDevtoolLocale = (typeof I18N_DEVTOOL_LOCALES)[number]
interface DevtoolMessages {
actions: {
compile: string
compileCompleted: string
compileFailed: string
compiling: string
enterFullscreen: string
exitFullscreen: string
extract: string
extractCompleted: string
extractFailed: string
extracting: string
label: string
}
commandOutput: {
close: string
collapse: string
expand: string
title: string
}
locale: {
controlLabel: string
label: string
noTargetLocales: string
sourceLabel: string
}
messagePanel: {
additionalSourceLocations: (count: number) => string
chooseCopyContent: string
collapseNamespace: (path: string) => string
collapsePackage: (packageName: string) => string
copied: string
copyDescriptor: string
copyFailed: string
copyMessageId: string
copySource: string
copySourceLocation: string
copyTranslation: string
couldNotSave: string
emptyCatalog: string
emptyPackage: string
expandNamespace: (path: string) => string
expandPackage: (packageName: string) => string
loading: (locale: string) => string
messageCount: (count: number) => string
messageEditor: string
messageNavigation: string
missingCount: (count: number) => string
missingOnly: string
noMatches: string
noMissingMessages: string
noSearchResults: string
reset: string
retry: string
save: string
saved: string
saving: string
search: string
selectMessage: string
source: string
sourceLocations: string
translation: string
translationFor: (id: string, locale: string) => string
unknownSourceLocation: string
}
settings: {
auto: string
autoTheme: string
center: string
dark: string
darkTheme: string
interfaceLanguage: string
interfaceLanguageControl: string
label: string
left: string
light: string
lightTheme: string
open: string
panelPosition: string
panelPositionControl: string
right: string
theme: string
themeControl: string
}
trigger: {
close: string
open: string
}
}
const messagesByLocale: Record<I18nDevtoolLocale, DevtoolMessages> = {
en: {
actions: {
compile: "Compile",
compileCompleted: "Compilation completed.",
compileFailed: "Compilation failed.",
compiling: "Compiling…",
enterFullscreen: "Enter full screen",
exitFullscreen: "Exit full screen",
extract: "Extract",
extractCompleted: "Extraction completed.",
extractFailed: "Extraction failed.",
extracting: "Extracting…",
label: "Actions",
},
commandOutput: {
close: "Close command output",
collapse: "Collapse command output",
expand: "Expand command output",
title: "Command output",
},
locale: {
controlLabel: "Target message locale",
label: "Target locale",
noTargetLocales: "No target locales configured.",
sourceLabel: "Source locale",
},
messagePanel: {
additionalSourceLocations: (count) =>
`${count} additional source locations`,
chooseCopyContent: "Choose content to copy",
collapseNamespace: (path) => `Collapse ${path}`,
collapsePackage: (packageName) => `Collapse ${packageName}`,
copied: "Copied",
copyDescriptor: "Copy message descriptor",
copyFailed: "Could not copy",
copyMessageId: "Copy message ID",
copySource: "Copy source text",
copySourceLocation: "Copy source location",
copyTranslation: "Copy current translation",
couldNotSave: "Could not save",
emptyCatalog:
"No messages are available yet. Extract the project to populate this catalog.",
emptyPackage: "No messages were found in this package.",
expandNamespace: (path) => `Expand ${path}`,
expandPackage: (packageName) => `Expand ${packageName}`,
loading: (locale) => `Loading ${locale}`,
messageCount: (count) => `${count} messages`,
messageEditor: "Message editor",
messageNavigation: "Message navigation",
missingCount: (count) => `${count} missing`,
missingOnly: "Missing only",
noMatches: "No messages match the current filters.",
noMissingMessages: "All messages have translations.",
noSearchResults: "No messages match your search.",
reset: "Reset",
retry: "Retry",
save: "Save",
saved: "Saved",
saving: "Saving…",
search: "Search messages",
selectMessage: "Select a message to view and edit its translation.",
source: "Source",
sourceLocations: "Source locations",
translation: "Translation",
translationFor: (id, locale) => `${id} translation for ${locale}`,
unknownSourceLocation: "Unknown source location",
},
settings: {
auto: "Auto",
autoTheme: "Auto theme",
center: "Center",
dark: "Dark",
darkTheme: "Dark theme",
interfaceLanguage: "Interface language",
interfaceLanguageControl: "I18n Devtool interface language",
label: "I18n Devtool settings",
left: "Left",
light: "Light",
lightTheme: "Light theme",
open: "Open I18n Devtool settings",
panelPosition: "Panel position",
panelPositionControl: "I18n Devtool panel position",
right: "Right",
theme: "Theme",
themeControl: "I18n Devtool theme",
},
trigger: {
close: "Close I18n Devtool",
open: "Open I18n Devtool",
},
},
de: {
actions: {
compile: "Kompilieren",
compileCompleted: "Kompilierung abgeschlossen.",
compileFailed: "Kompilierung fehlgeschlagen.",
compiling: "Wird kompiliert…",
enterFullscreen: "Vollbildmodus aktivieren",
exitFullscreen: "Vollbildmodus beenden",
extract: "Extrahieren",
extractCompleted: "Extraktion abgeschlossen.",
extractFailed: "Extraktion fehlgeschlagen.",
extracting: "Wird extrahiert…",
label: "Aktionen",
},
commandOutput: {
close: "Befehlsausgabe schließen",
collapse: "Befehlsausgabe einklappen",
expand: "Befehlsausgabe ausklappen",
title: "Befehlsausgabe",
},
locale: {
controlLabel: "Zielsprache der Nachrichten",
label: "Zielsprache",
noTargetLocales: "Keine Zielsprachen konfiguriert.",
sourceLabel: "Ausgangssprache",
},
messagePanel: {
additionalSourceLocations: (count) => `${count} weitere Quellorte`,
chooseCopyContent: "Inhalt zum Kopieren auswählen",
collapseNamespace: (path) => `${path} einklappen`,
collapsePackage: (packageName) => `${packageName} einklappen`,
copied: "Kopiert",
copyDescriptor: "Nachrichten-Deskriptor kopieren",
copyFailed: "Kopieren fehlgeschlagen",
copyMessageId: "Nachrichten-ID kopieren",
copySource: "Quelltext kopieren",
copySourceLocation: "Quellort kopieren",
copyTranslation: "Aktuelle Übersetzung kopieren",
couldNotSave: "Speichern fehlgeschlagen",
emptyCatalog:
"Es sind noch keine Nachrichten verfügbar. Extrahieren Sie das Projekt, um diesen Katalog zu füllen.",
emptyPackage: "In diesem Paket wurden keine Nachrichten gefunden.",
expandNamespace: (path) => `${path} ausklappen`,
expandPackage: (packageName) => `${packageName} ausklappen`,
loading: (locale) => `${locale} wird geladen…`,
messageCount: (count) => `${count} Nachrichten`,
messageEditor: "Nachrichteneditor",
messageNavigation: "Nachrichtennavigation",
missingCount: (count) => `${count} fehlen`,
missingOnly: "Nur fehlende",
noMatches: "Keine Nachrichten entsprechen den aktuellen Filtern.",
noMissingMessages: "Alle Nachrichten sind übersetzt.",
noSearchResults: "Keine Nachrichten entsprechen Ihrer Suche.",
reset: "Zurücksetzen",
retry: "Erneut versuchen",
save: "Speichern",
saved: "Gespeichert",
saving: "Wird gespeichert…",
search: "Nachrichten durchsuchen",
selectMessage:
"Wählen Sie eine Nachricht aus, um ihre Übersetzung anzuzeigen und zu bearbeiten.",
source: "Quelle",
sourceLocations: "Quellorte",
translation: "Übersetzung",
translationFor: (id, locale) => `${id}: Übersetzung für ${locale}`,
unknownSourceLocation: "Unbekannter Quellort",
},
settings: {
auto: "Automatisch",
autoTheme: "Automatisches Design",
center: "Mitte",
dark: "Dunkel",
darkTheme: "Dunkles Design",
interfaceLanguage: "Oberflächensprache",
interfaceLanguageControl: "Oberflächensprache des I18n Devtools",
label: "I18n-Devtool-Einstellungen",
left: "Links",
light: "Hell",
lightTheme: "Helles Design",
open: "I18n-Devtool-Einstellungen öffnen",
panelPosition: "Panelposition",
panelPositionControl: "Position des I18n-Devtool-Panels",
right: "Rechts",
theme: "Design",
themeControl: "I18n-Devtool-Design",
},
trigger: {
close: "I18n Devtool schließen",
open: "I18n Devtool öffnen",
},
},
es: {
actions: {
compile: "Compilar",
compileCompleted: "Compilación completada.",
compileFailed: "Error de compilación.",
compiling: "Compilando…",
enterFullscreen: "Entrar en pantalla completa",
exitFullscreen: "Salir de pantalla completa",
extract: "Extraer",
extractCompleted: "Extracción completada.",
extractFailed: "Error de extracción.",
extracting: "Extrayendo…",
label: "Acciones",
},
commandOutput: {
close: "Cerrar salida del comando",
collapse: "Contraer salida del comando",
expand: "Expandir salida del comando",
title: "Salida del comando",
},
locale: {
controlLabel: "Idioma de destino de los mensajes",
label: "Idioma de destino",
noTargetLocales: "No hay idiomas de destino configurados.",
sourceLabel: "Idioma de origen",
},
messagePanel: {
additionalSourceLocations: (count) =>
`${count} ubicaciones de origen adicionales`,
chooseCopyContent: "Elegir contenido para copiar",
collapseNamespace: (path) => `Contraer ${path}`,
collapsePackage: (packageName) => `Contraer ${packageName}`,
copied: "Copiado",
copyDescriptor: "Copiar descriptor del mensaje",
copyFailed: "No se pudo copiar",
copyMessageId: "Copiar ID del mensaje",
copySource: "Copiar texto de origen",
copySourceLocation: "Copiar ubicación de origen",
copyTranslation: "Copiar traducción actual",
couldNotSave: "No se pudo guardar",
emptyCatalog:
"Aún no hay mensajes disponibles. Extraiga el proyecto para completar este catálogo.",
emptyPackage: "No se encontraron mensajes en este paquete.",
expandNamespace: (path) => `Expandir ${path}`,
expandPackage: (packageName) => `Expandir ${packageName}`,
loading: (locale) => `Cargando ${locale}`,
messageCount: (count) => `${count} mensajes`,
messageEditor: "Editor de mensajes",
messageNavigation: "Navegación de mensajes",
missingCount: (count) => `${count} faltantes`,
missingOnly: "Solo faltantes",
noMatches: "Ningún mensaje coincide con los filtros actuales.",
noMissingMessages: "Todos los mensajes tienen traducción.",
noSearchResults: "Ningún mensaje coincide con la búsqueda.",
reset: "Restablecer",
retry: "Reintentar",
save: "Guardar",
saved: "Guardado",
saving: "Guardando…",
search: "Buscar mensajes",
selectMessage: "Seleccione un mensaje para ver y editar su traducción.",
source: "Origen",
sourceLocations: "Ubicaciones de origen",
translation: "Traducción",
translationFor: (id, locale) => `Traducción de ${id} para ${locale}`,
unknownSourceLocation: "Ubicación de origen desconocida",
},
settings: {
auto: "Automático",
autoTheme: "Tema automático",
center: "Centro",
dark: "Oscuro",
darkTheme: "Tema oscuro",
interfaceLanguage: "Idioma de la interfaz",
interfaceLanguageControl: "Idioma de la interfaz de I18n Devtool",
label: "Ajustes de I18n Devtool",
left: "Izquierda",
light: "Claro",
lightTheme: "Tema claro",
open: "Abrir ajustes de I18n Devtool",
panelPosition: "Posición del panel",
panelPositionControl: "Posición del panel de I18n Devtool",
right: "Derecha",
theme: "Tema",
themeControl: "Tema de I18n Devtool",
},
trigger: {
close: "Cerrar I18n Devtool",
open: "Abrir I18n Devtool",
},
},
fr: {
actions: {
compile: "Compiler",
compileCompleted: "Compilation terminée.",
compileFailed: "Échec de la compilation.",
compiling: "Compilation…",
enterFullscreen: "Passer en plein écran",
exitFullscreen: "Quitter le plein écran",
extract: "Extraire",
extractCompleted: "Extraction terminée.",
extractFailed: "Échec de lextraction.",
extracting: "Extraction…",
label: "Actions",
},
commandOutput: {
close: "Fermer la sortie de commande",
collapse: "Réduire la sortie de commande",
expand: "Développer la sortie de commande",
title: "Sortie de commande",
},
locale: {
controlLabel: "Langue cible des messages",
label: "Langue cible",
noTargetLocales: "Aucune langue cible nest configurée.",
sourceLabel: "Langue source",
},
messagePanel: {
additionalSourceLocations: (count) =>
`${count} emplacements source supplémentaires`,
chooseCopyContent: "Choisir le contenu à copier",
collapseNamespace: (path) => `Réduire ${path}`,
collapsePackage: (packageName) => `Réduire ${packageName}`,
copied: "Copié",
copyDescriptor: "Copier le descripteur du message",
copyFailed: "Échec de la copie",
copyMessageId: "Copier lidentifiant du message",
copySource: "Copier le texte source",
copySourceLocation: "Copier lemplacement source",
copyTranslation: "Copier la traduction actuelle",
couldNotSave: "Échec de lenregistrement",
emptyCatalog:
"Aucun message nest encore disponible. Extrayez le projet pour remplir ce catalogue.",
emptyPackage: "Aucun message na été trouvé dans ce package.",
expandNamespace: (path) => `Développer ${path}`,
expandPackage: (packageName) => `Développer ${packageName}`,
loading: (locale) => `Chargement de ${locale}`,
messageCount: (count) => `${count} messages`,
messageEditor: "Éditeur de messages",
messageNavigation: "Navigation des messages",
missingCount: (count) => `${count} manquants`,
missingOnly: "Manquants uniquement",
noMatches: "Aucun message ne correspond aux filtres actuels.",
noMissingMessages: "Tous les messages sont traduits.",
noSearchResults: "Aucun message ne correspond à votre recherche.",
reset: "Réinitialiser",
retry: "Réessayer",
save: "Enregistrer",
saved: "Enregistré",
saving: "Enregistrement…",
search: "Rechercher des messages",
selectMessage:
"Sélectionnez un message pour afficher et modifier sa traduction.",
source: "Source",
sourceLocations: "Emplacements source",
translation: "Traduction",
translationFor: (id, locale) => `Traduction de ${id} pour ${locale}`,
unknownSourceLocation: "Emplacement source inconnu",
},
settings: {
auto: "Auto",
autoTheme: "Thème automatique",
center: "Centre",
dark: "Sombre",
darkTheme: "Thème sombre",
interfaceLanguage: "Langue de linterface",
interfaceLanguageControl: "Langue de linterface de loutil I18n",
label: "Paramètres de I18n Devtool",
left: "Gauche",
light: "Clair",
lightTheme: "Thème clair",
open: "Ouvrir les paramètres de I18n Devtool",
panelPosition: "Position du panneau",
panelPositionControl: "Position du panneau I18n Devtool",
right: "Droite",
theme: "Thème",
themeControl: "Thème de I18n Devtool",
},
trigger: {
close: "Fermer I18n Devtool",
open: "Ouvrir I18n Devtool",
},
},
ja: {
actions: {
compile: "コンパイル",
compileCompleted: "コンパイルが完了しました。",
compileFailed: "コンパイルに失敗しました。",
compiling: "コンパイル中…",
enterFullscreen: "全画面表示にする",
exitFullscreen: "全画面表示を終了",
extract: "抽出",
extractCompleted: "抽出が完了しました。",
extractFailed: "抽出に失敗しました。",
extracting: "抽出中…",
label: "操作",
},
commandOutput: {
close: "コマンド出力を閉じる",
collapse: "コマンド出力を折りたたむ",
expand: "コマンド出力を展開する",
title: "コマンド出力",
},
locale: {
controlLabel: "メッセージの翻訳先言語",
label: "翻訳先言語",
noTargetLocales: "翻訳先言語が設定されていません。",
sourceLabel: "原文言語",
},
messagePanel: {
additionalSourceLocations: (count) => `${count} 件のソース位置`,
chooseCopyContent: "コピーする内容を選択",
collapseNamespace: (path) => `${path} を折りたたむ`,
collapsePackage: (packageName) => `${packageName} を折りたたむ`,
copied: "コピーしました",
copyDescriptor: "メッセージ記述子をコピー",
copyFailed: "コピーできませんでした",
copyMessageId: "メッセージ ID をコピー",
copySource: "原文をコピー",
copySourceLocation: "ソース位置をコピー",
copyTranslation: "現在の翻訳をコピー",
couldNotSave: "保存できませんでした",
emptyCatalog:
"メッセージはまだありません。プロジェクトを抽出してカタログを作成してください。",
emptyPackage: "このパッケージにはメッセージがありません。",
expandNamespace: (path) => `${path} を展開`,
expandPackage: (packageName) => `${packageName} を展開`,
loading: (locale) => `${locale} を読み込み中…`,
messageCount: (count) => `${count}`,
messageEditor: "メッセージエディター",
messageNavigation: "メッセージナビゲーション",
missingCount: (count) => `未翻訳 ${count}`,
missingOnly: "未翻訳のみ",
noMatches: "現在のフィルターに一致するメッセージはありません。",
noMissingMessages: "すべてのメッセージが翻訳済みです。",
noSearchResults: "検索に一致するメッセージはありません。",
reset: "リセット",
retry: "再試行",
save: "保存",
saved: "保存済み",
saving: "保存中…",
search: "メッセージを検索",
selectMessage: "翻訳を表示・編集するメッセージを選択してください。",
source: "原文",
sourceLocations: "ソース位置",
translation: "翻訳",
translationFor: (id, locale) => `${id}${locale} 翻訳`,
unknownSourceLocation: "ソース位置不明",
},
settings: {
auto: "自動",
autoTheme: "自動テーマ",
center: "中央",
dark: "ダーク",
darkTheme: "ダークテーマ",
interfaceLanguage: "表示言語",
interfaceLanguageControl: "I18n Devtool の表示言語",
label: "I18n Devtool の設定",
left: "左",
light: "ライト",
lightTheme: "ライトテーマ",
open: "I18n Devtool の設定を開く",
panelPosition: "パネル位置",
panelPositionControl: "I18n Devtool のパネル位置",
right: "右",
theme: "テーマ",
themeControl: "I18n Devtool のテーマ",
},
trigger: {
close: "I18n Devtool を閉じる",
open: "I18n Devtool を開く",
},
},
ko: {
actions: {
compile: "컴파일",
compileCompleted: "컴파일이 완료되었습니다.",
compileFailed: "컴파일에 실패했습니다.",
compiling: "컴파일 중…",
enterFullscreen: "전체 화면으로 전환",
exitFullscreen: "전체 화면 종료",
extract: "추출",
extractCompleted: "추출이 완료되었습니다.",
extractFailed: "추출에 실패했습니다.",
extracting: "추출 중…",
label: "작업",
},
commandOutput: {
close: "명령 출력을 닫기",
collapse: "명령 출력을 접기",
expand: "명령 출력을 펼치기",
title: "명령 출력",
},
locale: {
controlLabel: "메시지 대상 언어",
label: "대상 언어",
noTargetLocales: "대상 언어가 설정되지 않았습니다.",
sourceLabel: "원본 언어",
},
messagePanel: {
additionalSourceLocations: (count) => `추가 소스 위치 ${count}`,
chooseCopyContent: "복사할 내용 선택",
collapseNamespace: (path) => `${path} 접기`,
collapsePackage: (packageName) => `${packageName} 접기`,
copied: "복사됨",
copyDescriptor: "메시지 설명자 복사",
copyFailed: "복사하지 못했습니다",
copyMessageId: "메시지 ID 복사",
copySource: "원문 복사",
copySourceLocation: "소스 위치 복사",
copyTranslation: "현재 번역 복사",
couldNotSave: "저장하지 못했습니다",
emptyCatalog:
"아직 사용할 수 있는 메시지가 없습니다. 프로젝트에서 메시지를 추출해 카탈로그를 채우세요.",
emptyPackage: "이 패키지에는 메시지가 없습니다.",
expandNamespace: (path) => `${path} 펼치기`,
expandPackage: (packageName) => `${packageName} 펼치기`,
loading: (locale) => `${locale} 불러오는 중…`,
messageCount: (count) => `${count}개 메시지`,
messageEditor: "메시지 편집기",
messageNavigation: "메시지 탐색",
missingCount: (count) => `${count}개 누락`,
missingOnly: "누락된 항목만",
noMatches: "현재 필터와 일치하는 메시지가 없습니다.",
noMissingMessages: "모든 메시지가 번역되었습니다.",
noSearchResults: "검색어와 일치하는 메시지가 없습니다.",
reset: "재설정",
retry: "다시 시도",
save: "저장",
saved: "저장됨",
saving: "저장 중…",
search: "메시지 검색",
selectMessage: "번역을 확인하고 편집할 메시지를 선택하세요.",
source: "원문",
sourceLocations: "소스 위치",
translation: "번역",
translationFor: (id, locale) => `${id}${locale} 번역`,
unknownSourceLocation: "알 수 없는 소스 위치",
},
settings: {
auto: "자동",
autoTheme: "자동 테마",
center: "가운데",
dark: "다크",
darkTheme: "다크 테마",
interfaceLanguage: "인터페이스 언어",
interfaceLanguageControl: "I18n Devtool 인터페이스 언어",
label: "I18n Devtool 설정",
left: "왼쪽",
light: "라이트",
lightTheme: "라이트 테마",
open: "I18n Devtool 설정 열기",
panelPosition: "패널 위치",
panelPositionControl: "I18n Devtool 패널 위치",
right: "오른쪽",
theme: "테마",
themeControl: "I18n Devtool 테마",
},
trigger: {
close: "I18n Devtool 닫기",
open: "I18n Devtool 열기",
},
},
"zh-Hans": {
actions: {
compile: "编译",
compileCompleted: "编译完成。",
compileFailed: "编译失败。",
compiling: "正在编译…",
enterFullscreen: "进入全屏",
exitFullscreen: "退出全屏",
extract: "提取",
extractCompleted: "提取完成。",
extractFailed: "提取失败。",
extracting: "正在提取…",
label: "操作",
},
commandOutput: {
close: "关闭命令输出",
collapse: "收起命令输出",
expand: "展开命令输出",
title: "命令输出",
},
locale: {
controlLabel: "消息目标语言",
label: "目标语言",
noTargetLocales: "尚未配置目标语言。",
sourceLabel: "源语言",
},
messagePanel: {
additionalSourceLocations: (count) => `另外 ${count} 个来源位置`,
chooseCopyContent: "选择要复制的内容",
collapseNamespace: (path) => `折叠 ${path}`,
collapsePackage: (packageName) => `折叠 ${packageName}`,
copied: "已复制",
copyDescriptor: "复制 Message Descriptor",
copyFailed: "复制失败",
copyMessageId: "复制 Message ID",
copySource: "复制原文",
copySourceLocation: "复制来源位置",
copyTranslation: "复制当前译文",
couldNotSave: "保存失败",
emptyCatalog: "尚无可用消息。请先提取项目消息以生成目录。",
emptyPackage: "这个包中暂无消息。",
expandNamespace: (path) => `展开 ${path}`,
expandPackage: (packageName) => `展开 ${packageName}`,
loading: (locale) => `正在加载 ${locale}`,
messageCount: (count) => `${count}`,
messageEditor: "消息编辑器",
messageNavigation: "消息导航",
missingCount: (count) => `缺失 ${count}`,
missingOnly: "仅显示缺失项",
noMatches: "没有符合当前筛选条件的消息。",
noMissingMessages: "所有消息均已完成翻译。",
noSearchResults: "没有与搜索内容匹配的消息。",
reset: "重置",
retry: "重试",
save: "保存",
saved: "已保存",
saving: "正在保存…",
search: "搜索消息",
selectMessage: "请选择一条消息以查看和编辑译文。",
source: "原文",
sourceLocations: "来源位置",
translation: "译文",
translationFor: (id, locale) => `${id}${locale} 翻译`,
unknownSourceLocation: "未知来源位置",
},
settings: {
auto: "自动",
autoTheme: "自动主题",
center: "居中",
dark: "深色",
darkTheme: "深色主题",
interfaceLanguage: "界面语言",
interfaceLanguageControl: "I18n 开发工具界面语言",
label: "I18n 开发工具设置",
left: "左侧",
light: "浅色",
lightTheme: "浅色主题",
open: "打开 I18n 开发工具设置",
panelPosition: "面板位置",
panelPositionControl: "I18n 开发工具面板位置",
right: "右侧",
theme: "主题",
themeControl: "I18n 开发工具主题",
},
trigger: {
close: "关闭 I18n 开发工具",
open: "打开 I18n 开发工具",
},
},
"zh-Hant": {
actions: {
compile: "編譯",
compileCompleted: "編譯完成。",
compileFailed: "編譯失敗。",
compiling: "正在編譯…",
enterFullscreen: "進入全螢幕",
exitFullscreen: "退出全螢幕",
extract: "擷取",
extractCompleted: "擷取完成。",
extractFailed: "擷取失敗。",
extracting: "正在擷取…",
label: "操作",
},
commandOutput: {
close: "關閉命令輸出",
collapse: "收合命令輸出",
expand: "展開命令輸出",
title: "命令輸出",
},
locale: {
controlLabel: "訊息目標語言",
label: "目標語言",
noTargetLocales: "尚未設定目標語言。",
sourceLabel: "來源語言",
},
messagePanel: {
additionalSourceLocations: (count) => `另外 ${count} 個來源位置`,
chooseCopyContent: "選擇要複製的內容",
collapseNamespace: (path) => `收合 ${path}`,
collapsePackage: (packageName) => `收合 ${packageName}`,
copied: "已複製",
copyDescriptor: "複製 Message Descriptor",
copyFailed: "複製失敗",
copyMessageId: "複製 Message ID",
copySource: "複製原文",
copySourceLocation: "複製來源位置",
copyTranslation: "複製目前譯文",
couldNotSave: "儲存失敗",
emptyCatalog: "尚無可用訊息。請先擷取專案訊息以產生目錄。",
emptyPackage: "這個套件中暫無訊息。",
expandNamespace: (path) => `展開 ${path}`,
expandPackage: (packageName) => `展開 ${packageName}`,
loading: (locale) => `正在載入 ${locale}`,
messageCount: (count) => `${count}`,
messageEditor: "訊息編輯器",
messageNavigation: "訊息導覽",
missingCount: (count) => `缺漏 ${count}`,
missingOnly: "僅顯示缺漏項目",
noMatches: "沒有符合目前篩選條件的訊息。",
noMissingMessages: "所有訊息皆已完成翻譯。",
noSearchResults: "沒有符合搜尋內容的訊息。",
reset: "重設",
retry: "重試",
save: "儲存",
saved: "已儲存",
saving: "正在儲存…",
search: "搜尋訊息",
selectMessage: "請選擇一則訊息以檢視和編輯譯文。",
source: "原文",
sourceLocations: "來源位置",
translation: "譯文",
translationFor: (id, locale) => `${id}${locale} 翻譯`,
unknownSourceLocation: "未知來源位置",
},
settings: {
auto: "自動",
autoTheme: "自動主題",
center: "置中",
dark: "深色",
darkTheme: "深色主題",
interfaceLanguage: "介面語言",
interfaceLanguageControl: "I18n 開發工具介面語言",
label: "I18n 開發工具設定",
left: "左側",
light: "淺色",
lightTheme: "淺色主題",
open: "開啟 I18n 開發工具設定",
panelPosition: "面板位置",
panelPositionControl: "I18n 開發工具面板位置",
right: "右側",
theme: "主題",
themeControl: "I18n 開發工具主題",
},
trigger: {
close: "關閉 I18n 開發工具",
open: "開啟 I18n 開發工具",
},
},
}
const defaultContextValue: {
locale: I18nDevtoolLocale
messages: DevtoolMessages
} = {
locale: "en",
messages: messagesByLocale.en,
}
const DevtoolLocalizationContext = React.createContext(defaultContextValue)
function matchDevtoolLocale(locale: string): I18nDevtoolLocale | undefined {
const normalizedLocale = locale.trim().replaceAll("_", "-").toLowerCase()
if (!normalizedLocale) {
return undefined
}
if (
normalizedLocale === "zh-hant" ||
normalizedLocale.startsWith("zh-hant-")
) {
return "zh-Hant"
}
if (
normalizedLocale === "zh-tw" ||
normalizedLocale.startsWith("zh-tw-") ||
normalizedLocale === "zh-hk" ||
normalizedLocale.startsWith("zh-hk-") ||
normalizedLocale === "zh-mo" ||
normalizedLocale.startsWith("zh-mo-")
) {
return "zh-Hant"
}
if (normalizedLocale === "zh" || normalizedLocale.startsWith("zh-")) {
return "zh-Hans"
}
const language = normalizedLocale.split("-")[0]
return I18N_DEVTOOL_LOCALES.find((candidate) => candidate === language)
}
function getNavigatorLocales() {
if (typeof navigator === "undefined") {
return []
}
if (navigator.languages.length > 0) {
return navigator.languages
}
return navigator.language ? [navigator.language] : []
}
export function resolveI18nDevtoolLocale(
locale?: string,
preferredLocales: readonly string[] = getNavigatorLocales()
): I18nDevtoolLocale {
const candidates = locale ? [locale] : preferredLocales
for (const candidate of candidates) {
const matchedLocale = matchDevtoolLocale(candidate)
if (matchedLocale) {
return matchedLocale
}
}
return "en"
}
export function getI18nDevtoolMessages(locale: I18nDevtoolLocale) {
return messagesByLocale[locale]
}
export function DevtoolLocalizationProvider({
children,
locale,
}: {
children: React.ReactNode
locale: I18nDevtoolLocale
}) {
const value = React.useMemo(
() => ({
locale,
messages: messagesByLocale[locale],
}),
[locale]
)
return (
<DevtoolLocalizationContext.Provider value={value}>
{children}
</DevtoolLocalizationContext.Provider>
)
}
export function useDevtoolLocalization() {
return React.useContext(DevtoolLocalizationContext)
}
@@ -0,0 +1,124 @@
import * as React from "react"
import {
I18N_DEVTOOL_LOCALES,
type I18nDevtoolLocale,
} from "./devtool-localization"
export type I18nDevtoolPanelPlacement = "center" | "left" | "right"
export type I18nDevtoolTheme = "auto" | "dark" | "light"
interface DevtoolPreferences {
interfaceLocale: I18nDevtoolLocale
panelPlacement: I18nDevtoolPanelPlacement
theme: I18nDevtoolTheme
version: 1
}
interface UseDevtoolPreferencesOptions {
defaultInterfaceLocale: I18nDevtoolLocale
defaultPanelPlacement: I18nDevtoolPanelPlacement
defaultTheme: I18nDevtoolTheme
}
const STORAGE_KEY = "i18n-devtool:preferences"
function isPanelPlacement(value: unknown): value is I18nDevtoolPanelPlacement {
return value === "center" || value === "left" || value === "right"
}
function isTheme(value: unknown): value is I18nDevtoolTheme {
return value === "auto" || value === "dark" || value === "light"
}
function isInterfaceLocale(value: unknown): value is I18nDevtoolLocale {
return (
typeof value === "string" &&
I18N_DEVTOOL_LOCALES.includes(value as I18nDevtoolLocale)
)
}
function readStoredPreferences() {
if (typeof window === "undefined") {
return null
}
try {
const parsed = JSON.parse(
window.localStorage.getItem(STORAGE_KEY) ?? ""
) as Partial<DevtoolPreferences>
if (
parsed.version !== 1 ||
!isPanelPlacement(parsed.panelPlacement) ||
!isTheme(parsed.theme)
) {
return null
}
return {
interfaceLocale: isInterfaceLocale(parsed.interfaceLocale)
? parsed.interfaceLocale
: undefined,
panelPlacement: parsed.panelPlacement,
theme: parsed.theme,
version: 1 as const,
}
} catch {
return null
}
}
export function useDevtoolPreferences({
defaultInterfaceLocale,
defaultPanelPlacement,
defaultTheme,
}: UseDevtoolPreferencesOptions) {
const [preferences, setPreferences] = React.useState<DevtoolPreferences>(
() => {
const storedPreferences = readStoredPreferences()
return {
interfaceLocale:
storedPreferences?.interfaceLocale ?? defaultInterfaceLocale,
panelPlacement:
storedPreferences?.panelPlacement ?? defaultPanelPlacement,
theme: storedPreferences?.theme ?? defaultTheme,
version: 1,
}
}
)
React.useEffect(() => {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences))
} catch {
// Storage can be unavailable in privacy-restricted browser contexts.
}
}, [preferences])
const setPanelPlacement = React.useCallback(
(panelPlacement: I18nDevtoolPanelPlacement) => {
setPreferences((current) => ({ ...current, panelPlacement }))
},
[]
)
const setTheme = React.useCallback((theme: I18nDevtoolTheme) => {
setPreferences((current) => ({ ...current, theme }))
}, [])
const setInterfaceLocale = React.useCallback(
(interfaceLocale: I18nDevtoolLocale) => {
setPreferences((current) => ({ ...current, interfaceLocale }))
},
[]
)
return {
interfaceLocale: preferences.interfaceLocale,
panelPlacement: preferences.panelPlacement,
setInterfaceLocale,
setPanelPlacement,
setTheme,
theme: preferences.theme,
}
}
@@ -0,0 +1,38 @@
import type * as React from "react"
export interface DevtoolSelectOption {
disabled?: boolean
label: string
value: string
}
export interface DevtoolSelectProps extends Omit<
React.ComponentProps<"select">,
"children"
> {
options: readonly DevtoolSelectOption[]
}
export function DevtoolSelect({
className,
options,
...props
}: DevtoolSelectProps) {
const resolvedClassName = ["i18n-devtool__select", className]
.filter(Boolean)
.join(" ")
return (
<select className={resolvedClassName} {...props}>
{options.map((option) => (
<option
key={option.value}
disabled={option.disabled}
value={option.value}
>
{option.label}
</option>
))}
</select>
)
}
@@ -0,0 +1,159 @@
import * as React from "react"
import {
AlignCenterIcon,
AlignLeftIcon,
AlignRightIcon,
SettingsIcon,
} from "lucide-react"
import type {
I18nDevtoolPanelPlacement,
I18nDevtoolTheme,
} from "./devtool-preferences"
import {
I18N_DEVTOOL_LOCALES,
type I18nDevtoolLocale,
useDevtoolLocalization,
} from "./devtool-localization"
import { DevtoolSelect } from "./devtool-select"
import { DevtoolThemeSwitcher } from "./devtool-theme-switcher"
export interface DevtoolSettingsProps {
interfaceLocale: I18nDevtoolLocale
onInterfaceLocaleChange: (locale: I18nDevtoolLocale) => void
onPanelPlacementChange: (placement: I18nDevtoolPanelPlacement) => void
onThemeChange: (theme: I18nDevtoolTheme) => void
panelPlacement: I18nDevtoolPanelPlacement
showPanelPlacement?: boolean
theme: I18nDevtoolTheme
}
const panelPlacements = [
{ icon: AlignLeftIcon, label: "left", value: "left" },
{ icon: AlignCenterIcon, label: "center", value: "center" },
{ icon: AlignRightIcon, label: "right", value: "right" },
] as const
const interfaceLocaleLabels: Record<I18nDevtoolLocale, string> = {
de: "Deutsch",
en: "English",
es: "Español",
fr: "Français",
ja: "日本語",
ko: "한국어",
"zh-Hans": "简体中文",
"zh-Hant": "繁體中文",
}
export function DevtoolSettings({
interfaceLocale,
onInterfaceLocaleChange,
onPanelPlacementChange,
onThemeChange,
panelPlacement,
showPanelPlacement = true,
theme,
}: DevtoolSettingsProps) {
const [isOpen, setOpen] = React.useState(false)
const rootRef = React.useRef<HTMLDivElement>(null)
const { messages } = useDevtoolLocalization()
React.useEffect(() => {
if (!isOpen) {
return
}
const handlePointerDown = (event: PointerEvent) => {
if (
event.target instanceof Node &&
!rootRef.current?.contains(event.target)
) {
setOpen(false)
}
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") {
return
}
event.stopPropagation()
setOpen(false)
}
document.addEventListener("pointerdown", handlePointerDown, true)
document.addEventListener("keydown", handleKeyDown, true)
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true)
document.removeEventListener("keydown", handleKeyDown, true)
}
}, [isOpen])
return (
<div className="i18n-devtool__settings" ref={rootRef}>
<button
type="button"
aria-controls="i18n-devtool-settings-panel"
aria-expanded={isOpen}
aria-label={messages.settings.open}
className="i18n-devtool__settings-trigger"
onClick={() => setOpen((current) => !current)}
>
<SettingsIcon aria-hidden="true" />
</button>
{isOpen && (
<section
id="i18n-devtool-settings-panel"
aria-label={messages.settings.label}
className="i18n-devtool__settings-panel"
>
<label className="i18n-devtool__settings-field">
<span>{messages.settings.interfaceLanguage}</span>
<DevtoolSelect
aria-label={messages.settings.interfaceLanguageControl}
options={I18N_DEVTOOL_LOCALES.map((locale) => ({
label: interfaceLocaleLabels[locale],
value: locale,
}))}
value={interfaceLocale}
onChange={(event) =>
onInterfaceLocaleChange(event.target.value as I18nDevtoolLocale)
}
/>
</label>
<div className="i18n-devtool__settings-field">
<span>{messages.settings.theme}</span>
<DevtoolThemeSwitcher theme={theme} onThemeChange={onThemeChange} />
</div>
{showPanelPlacement && (
<div className="i18n-devtool__settings-field">
<span>{messages.settings.panelPosition}</span>
<div
aria-label={messages.settings.panelPositionControl}
className="i18n-devtool__settings-options"
role="group"
>
{panelPlacements.map(
({ icon: PlacementIcon, label, value }) => (
<button
key={value}
type="button"
aria-pressed={panelPlacement === value}
onClick={() => onPanelPlacementChange(value)}
>
<PlacementIcon aria-hidden="true" />
<span>{messages.settings[label]}</span>
</button>
)
)}
</div>
</div>
)}
</section>
)}
</div>
)
}
@@ -0,0 +1,58 @@
import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react"
import type { I18nDevtoolTheme } from "./devtool-preferences"
import { useDevtoolLocalization } from "./devtool-localization"
export interface DevtoolThemeSwitcherProps {
onThemeChange: (theme: I18nDevtoolTheme) => void
theme: I18nDevtoolTheme
}
const themes = [
{
accessibleLabel: "autoTheme",
icon: MonitorIcon,
label: "auto",
value: "auto",
},
{
accessibleLabel: "lightTheme",
icon: SunIcon,
label: "light",
value: "light",
},
{
accessibleLabel: "darkTheme",
icon: MoonIcon,
label: "dark",
value: "dark",
},
] as const
export function DevtoolThemeSwitcher({
onThemeChange,
theme,
}: DevtoolThemeSwitcherProps) {
const { messages } = useDevtoolLocalization()
return (
<div
aria-label={messages.settings.themeControl}
className="i18n-devtool__theme-switcher"
role="group"
>
{themes.map(({ accessibleLabel, icon: ThemeIcon, label, value }) => (
<button
key={value}
type="button"
aria-label={messages.settings[accessibleLabel]}
aria-pressed={theme === value}
onClick={() => onThemeChange(value)}
>
<ThemeIcon aria-hidden="true" />
<span>{messages.settings[label]}</span>
</button>
))}
</div>
)
}
@@ -1,5 +1,6 @@
import type {
DevtoolMessage,
DevtoolMessagePackage,
MessageRepository,
UpdateMessageInput,
} from "./types"
@@ -38,6 +39,11 @@ export function createHttpMessageRepository(
return readResponse<readonly DevtoolMessage[]>(response)
},
async getPackages() {
const response = await fetch(`${normalizedBaseUrl}/packages`)
return readResponse<readonly DevtoolMessagePackage[]>(response)
},
async updateMessage(input: UpdateMessageInput) {
const response = await fetch(`${normalizedBaseUrl}/messages`, {
body: JSON.stringify(input),
@@ -0,0 +1,58 @@
import { LanguagesIcon } from "lucide-react"
import { useDevtoolLocalization } from "./devtool-localization"
import { useFloatingTrigger } from "./use-floating-trigger"
export interface I18nDevtoolTriggerProps {
controls?: string
open?: boolean
onOpen: () => void
storageKey?: string
}
export function I18nDevtoolTrigger({
controls = "i18n-devtool-panel",
open = false,
onOpen,
storageKey,
}: I18nDevtoolTriggerProps) {
const floatingTrigger = useFloatingTrigger({ storageKey })
const { messages } = useDevtoolLocalization()
return (
<button
type="button"
aria-controls={controls}
aria-expanded={open}
aria-label={messages.trigger.open}
className="i18n-devtool__trigger"
data-collapsed={floatingTrigger.isCollapsed || undefined}
data-dock={floatingTrigger.dockEdge}
data-dragging={floatingTrigger.isDragging || undefined}
hidden={open}
ref={floatingTrigger.triggerRef}
style={floatingTrigger.style}
onBlur={floatingTrigger.handleBlur}
onClick={(event) => {
if (floatingTrigger.handleClick()) {
event.preventDefault()
return
}
onOpen()
}}
onFocus={floatingTrigger.handleFocus}
onPointerCancel={floatingTrigger.handlePointerCancel}
onPointerDown={floatingTrigger.handlePointerDown}
onPointerEnter={floatingTrigger.handlePointerEnter}
onPointerLeave={floatingTrigger.handlePointerLeave}
onPointerMove={floatingTrigger.handlePointerMove}
onPointerUp={floatingTrigger.handlePointerUp}
>
<span className="i18n-devtool__trigger-icon" aria-hidden="true">
<LanguagesIcon />
</span>
<span className="i18n-devtool__trigger-label">i18n</span>
</button>
)
}
+687 -31
View File
@@ -5,6 +5,8 @@
--i18n-devtool-muted: #71717a;
--i18n-devtool-panel: #fff;
--i18n-devtool-primary: #2563eb;
--i18n-devtool-control-hover: rgb(15 23 42 / 6%);
--i18n-devtool-control-pressed: rgb(15 23 42 / 11%);
position: fixed;
z-index: 2147483647;
color: var(--i18n-devtool-foreground);
@@ -26,6 +28,8 @@
--i18n-devtool-muted: #a1a1aa;
--i18n-devtool-panel: #181a20;
--i18n-devtool-primary: #60a5fa;
--i18n-devtool-control-hover: rgb(255 255 255 / 8%);
--i18n-devtool-control-pressed: rgb(255 255 255 / 14%);
color-scheme: dark;
}
@@ -46,10 +50,13 @@
right: 1rem;
bottom: 1rem;
display: inline-flex;
height: 2.75rem;
width: max-content;
height: 3rem;
align-items: center;
gap: 0.45rem;
padding: 0 0.85rem;
gap: 0.5rem;
justify-content: center;
overflow: visible;
padding: 0 0.5rem;
border: 1px solid var(--i18n-devtool-border);
border-radius: 999px;
background: var(--i18n-devtool-panel);
@@ -57,28 +64,169 @@
cursor: pointer;
font-size: 0.8125rem;
font-weight: 650;
touch-action: none;
transition:
gap 140ms ease,
border-radius 140ms ease,
background-color 120ms ease,
box-shadow 140ms ease,
transform 160ms ease;
user-select: none;
}
.i18n-devtool__trigger span:first-child {
.i18n-devtool__trigger::before {
position: absolute;
display: none;
content: "";
}
.i18n-devtool__trigger[hidden] {
display: none;
}
.i18n-devtool__trigger:focus-visible {
outline: 2px solid var(--i18n-devtool-primary);
outline-offset: 2px;
}
.i18n-devtool__trigger[data-dragging] {
cursor: grabbing;
box-shadow: 0 16px 36px rgb(0 0 0 / 24%);
}
.i18n-devtool__trigger:hover {
background: color-mix(
in srgb,
var(--i18n-devtool-primary) 7%,
var(--i18n-devtool-panel)
);
}
.i18n-devtool__trigger:active {
background: color-mix(
in srgb,
var(--i18n-devtool-primary) 12%,
var(--i18n-devtool-panel)
);
}
.i18n-devtool__trigger[data-collapsed] {
gap: 0;
}
.i18n-devtool__trigger[data-dock="left"] {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.i18n-devtool__trigger[data-dock="right"] {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.i18n-devtool__trigger[data-dock="top"] {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.i18n-devtool__trigger[data-dock="bottom"] {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.i18n-devtool__trigger[data-dock]:not([data-collapsed]) {
border-radius: 999px;
}
.i18n-devtool__trigger[data-dock="left"]:not([data-collapsed]) {
transform: translateX(0.5rem);
}
.i18n-devtool__trigger[data-dock="right"]:not([data-collapsed]) {
transform: translateX(-0.5rem);
}
.i18n-devtool__trigger[data-dock="top"]:not([data-collapsed]) {
transform: translateY(0.5rem);
}
.i18n-devtool__trigger[data-dock="bottom"]:not([data-collapsed]) {
transform: translateY(-0.5rem);
}
.i18n-devtool__trigger[data-dock="left"]:not([data-collapsed])::before {
inset-block: 0;
right: 100%;
display: block;
width: 0.5rem;
}
.i18n-devtool__trigger[data-dock="right"]:not([data-collapsed])::before {
inset-block: 0;
left: 100%;
display: block;
width: 0.5rem;
}
.i18n-devtool__trigger[data-dock="top"]:not([data-collapsed])::before {
bottom: 100%;
inset-inline: 0;
display: block;
height: 0.5rem;
}
.i18n-devtool__trigger[data-dock="bottom"]:not([data-collapsed])::before {
top: 100%;
inset-inline: 0;
display: block;
height: 0.5rem;
}
.i18n-devtool__trigger-icon {
display: grid;
width: 1.5rem;
height: 1.5rem;
width: 2rem;
height: 2rem;
flex: 0 0 2rem;
place-items: center;
border-radius: 0.4rem;
border-radius: 50%;
background: var(--i18n-devtool-primary);
color: white;
font-size: 0.75rem;
}
.i18n-devtool__trigger-icon svg {
display: block;
width: 1.25rem;
height: 1.25rem;
stroke-width: 2.25;
}
.i18n-devtool__trigger-label {
width: 2.5rem;
max-width: 2.5rem;
overflow: hidden;
opacity: 1;
transform: translateX(0);
transition:
max-width 140ms ease,
opacity 100ms ease,
transform 140ms ease;
white-space: nowrap;
}
.i18n-devtool__trigger[data-collapsed] .i18n-devtool__trigger-label {
max-width: 0;
opacity: 0;
transform: translateX(-0.35rem);
}
.i18n-devtool__panel {
position: fixed;
top: 1rem;
right: 1rem;
bottom: 1rem;
display: grid;
width: min(54rem, calc(100vw - 2rem));
display: flex;
width: min(80rem, calc(100vw - 2rem));
overflow: hidden;
grid-template-rows: auto auto minmax(0, 1fr);
flex-direction: column;
border: 1px solid var(--i18n-devtool-border);
border-radius: 1rem;
outline: none;
@@ -86,6 +234,22 @@
box-shadow: 0 24px 70px rgb(0 0 0 / 28%);
}
.i18n-devtool__panel[data-placement="left"] {
right: auto;
left: 1rem;
}
.i18n-devtool__panel[data-placement="center"] {
right: auto;
left: 50%;
transform: translateX(-50%);
}
.i18n-devtool__panel[data-placement="right"] {
right: 1rem;
left: auto;
}
.i18n-devtool[data-mode="standalone"] {
inset: 0;
}
@@ -96,9 +260,21 @@
border: 0;
border-radius: 0;
box-shadow: none;
transform: none;
}
.i18n-devtool__panel[data-fullscreen] {
inset: 0;
width: 100vw;
border: 0;
border-radius: 0;
box-shadow: none;
transform: none;
}
.i18n-devtool__header {
position: relative;
z-index: 3;
display: flex;
align-items: center;
justify-content: space-between;
@@ -129,58 +305,347 @@
.i18n-devtool__controls {
display: flex;
align-items: end;
gap: 0.4rem;
gap: 1rem;
}
.i18n-devtool__controls label {
.i18n-devtool__locale-field,
.i18n-devtool__source-locale-field,
.i18n-devtool__action-field {
display: grid;
gap: 0.2rem;
}
.i18n-devtool__controls label span {
.i18n-devtool__action-field {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.i18n-devtool__locale-field > span,
.i18n-devtool__source-locale-field > span,
.i18n-devtool__action-field > legend {
padding: 0;
color: var(--i18n-devtool-muted);
font-size: 0.625rem;
font-weight: 650;
text-transform: uppercase;
}
.i18n-devtool__controls select,
.i18n-devtool__controls button {
.i18n-devtool__source-locale {
display: inline-flex;
min-width: 7rem;
height: 2.25rem;
align-items: center;
padding: 0 0.7rem;
border: 1px solid var(--i18n-devtool-border);
border-radius: 0.55rem;
background: color-mix(
in srgb,
var(--i18n-devtool-panel) 82%,
var(--i18n-devtool-muted) 18%
);
color: var(--i18n-devtool-muted);
white-space: nowrap;
}
.i18n-devtool__action-controls {
display: flex;
align-items: center;
gap: 0.4rem;
}
.i18n-devtool__action-button {
display: grid;
place-items: center;
}
.i18n-devtool__action-button > span {
grid-area: 1 / 1;
}
.i18n-devtool__action-label-measure {
visibility: hidden;
}
.i18n-devtool__select,
.i18n-devtool__action-controls > button,
.i18n-devtool__settings-trigger {
height: 2.25rem;
padding: 0 0.7rem;
border: 1px solid var(--i18n-devtool-border);
border-radius: 0.55rem;
outline: none;
background: var(--i18n-devtool-panel);
transition:
background-color 120ms ease,
border-color 120ms ease,
box-shadow 120ms ease,
transform 80ms ease;
}
.i18n-devtool__controls button {
.i18n-devtool__select {
color: var(--i18n-devtool-foreground);
cursor: pointer;
}
.i18n-devtool__controls button:disabled {
.i18n-devtool__locale-select {
min-width: 9rem;
}
.i18n-devtool__action-controls > button,
.i18n-devtool__settings-trigger {
cursor: pointer;
}
.i18n-devtool__select:hover,
.i18n-devtool__action-controls > button:hover:not(:disabled),
.i18n-devtool__settings-trigger:hover {
background: var(--i18n-devtool-control-hover);
}
.i18n-devtool__select:focus-visible,
.i18n-devtool__action-controls > button:focus-visible,
.i18n-devtool__settings-trigger:focus-visible {
border-color: var(--i18n-devtool-primary);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--i18n-devtool-primary) 18%, transparent);
}
.i18n-devtool__action-controls > button:active:not(:disabled),
.i18n-devtool__settings-trigger:active {
background: var(--i18n-devtool-control-pressed);
transform: translateY(1px);
}
.i18n-devtool__action-controls > button:disabled {
cursor: default;
opacity: 0.45;
}
.i18n-devtool__controls .i18n-devtool__close {
.i18n-devtool__controls .i18n-devtool__close,
.i18n-devtool__controls .i18n-devtool__fullscreen {
display: inline-grid;
width: 2.25rem;
place-items: center;
padding: 0;
font-size: 1.25rem;
}
.i18n-devtool__close svg,
.i18n-devtool__fullscreen svg {
width: 1rem;
height: 1rem;
}
.i18n-devtool__fullscreen[aria-pressed="true"] {
border-color: color-mix(
in srgb,
var(--i18n-devtool-primary) 45%,
var(--i18n-devtool-border)
);
background: color-mix(
in srgb,
var(--i18n-devtool-primary) 12%,
var(--i18n-devtool-panel)
);
color: var(--i18n-devtool-primary);
}
.i18n-devtool__theme-switcher {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.25rem;
padding: 0.25rem;
border: 1px solid var(--i18n-devtool-border);
border-radius: 0.625rem;
background: var(--i18n-devtool-panel);
}
.i18n-devtool__theme-switcher button {
display: inline-flex;
min-width: 0;
height: 2.25rem;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0 0.5rem;
border: 0;
border-radius: 0.375rem;
outline: none;
background: transparent;
color: var(--i18n-devtool-muted);
cursor: pointer;
transition:
color 120ms ease,
background-color 120ms ease,
transform 80ms ease;
}
.i18n-devtool__theme-switcher button:hover {
background: var(--i18n-devtool-control-hover);
color: var(--i18n-devtool-foreground);
}
.i18n-devtool__theme-switcher button:active {
transform: scale(0.94);
}
.i18n-devtool__theme-switcher button:focus-visible {
box-shadow: 0 0 0 2px var(--i18n-devtool-primary);
}
.i18n-devtool__theme-switcher button[aria-pressed="true"] {
background: color-mix(
in srgb,
var(--i18n-devtool-primary) 14%,
var(--i18n-devtool-panel)
);
color: var(--i18n-devtool-primary);
}
.i18n-devtool__theme-switcher svg {
width: 1rem;
height: 1rem;
flex: 0 0 1rem;
}
.i18n-devtool__theme-switcher span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.i18n-devtool__settings {
position: relative;
align-self: end;
}
.i18n-devtool__settings-trigger {
display: inline-grid;
width: 2.25rem;
place-items: center;
padding: 0;
}
.i18n-devtool__settings-trigger svg {
width: 1rem;
height: 1rem;
}
.i18n-devtool__settings-trigger[aria-expanded="true"] {
border-color: color-mix(
in srgb,
var(--i18n-devtool-primary) 45%,
var(--i18n-devtool-border)
);
background: color-mix(
in srgb,
var(--i18n-devtool-primary) 12%,
var(--i18n-devtool-panel)
);
color: var(--i18n-devtool-primary);
}
.i18n-devtool__settings-panel {
position: absolute;
z-index: 5;
top: calc(100% + 0.5rem);
right: 0;
display: grid;
width: 20rem;
max-width: calc(100vw - 3rem);
gap: 1rem;
padding: 1rem;
border: 1px solid var(--i18n-devtool-border);
border-radius: 0.875rem;
background: var(--i18n-devtool-panel);
box-shadow: 0 18px 48px rgb(0 0 0 / 22%);
}
.i18n-devtool__settings-field {
display: grid;
gap: 0.45rem;
}
.i18n-devtool__settings-field > span {
color: var(--i18n-devtool-muted);
font-size: 0.675rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.i18n-devtool__settings-panel .i18n-devtool__select {
width: 100%;
height: 2.75rem;
border-radius: 0.625rem;
}
.i18n-devtool__settings-options {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.25rem;
padding: 0.25rem;
border: 1px solid var(--i18n-devtool-border);
border-radius: 0.625rem;
background: var(--i18n-devtool-panel);
}
.i18n-devtool__settings-options button {
display: inline-flex;
min-width: 0;
height: 2.25rem;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0 0.5rem;
border: 0;
border-radius: 0.375rem;
outline: none;
background: transparent;
color: var(--i18n-devtool-muted);
cursor: pointer;
transition:
color 120ms ease,
background-color 120ms ease,
transform 80ms ease;
}
.i18n-devtool__settings-options button:hover {
background: var(--i18n-devtool-control-hover);
color: var(--i18n-devtool-foreground);
}
.i18n-devtool__settings-options button:active {
transform: scale(0.97);
}
.i18n-devtool__settings-options button:focus-visible {
box-shadow: 0 0 0 2px var(--i18n-devtool-primary);
}
.i18n-devtool__settings-options button[aria-pressed="true"] {
background: color-mix(
in srgb,
var(--i18n-devtool-primary) 14%,
var(--i18n-devtool-panel)
);
color: var(--i18n-devtool-primary);
}
.i18n-devtool__settings-options svg {
width: 1rem;
height: 1rem;
flex: 0 0 1rem;
}
.i18n-devtool__output {
max-height: 10rem;
overflow: auto;
margin: 0;
padding: 0.65rem 1rem;
border-bottom: 1px solid var(--i18n-devtool-border);
background: color-mix(
in srgb,
var(--i18n-devtool-primary) 10%,
var(--i18n-devtool-panel)
);
font-size: 0.72rem;
white-space: pre-wrap;
}
.i18n-devtool__output[data-error] {
@@ -188,15 +653,100 @@
color: #ef4444;
}
.i18n-devtool__main {
min-height: 0;
.i18n-devtool__output-header {
display: flex;
min-height: 2.25rem;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.25rem 0.5rem 0.25rem 1rem;
color: var(--i18n-devtool-muted);
font-size: 0.7rem;
font-weight: 650;
text-transform: uppercase;
}
.i18n-devtool__output-header > span,
.i18n-devtool__output-header > div {
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
.i18n-devtool__output-header button {
display: inline-grid;
width: 1.75rem;
height: 1.75rem;
place-items: center;
padding: 0;
border: 0;
border-radius: 0.375rem;
outline: none;
background: transparent;
color: inherit;
cursor: pointer;
}
.i18n-devtool__output-header button:hover {
background: var(--i18n-devtool-control-hover);
color: var(--i18n-devtool-foreground);
}
.i18n-devtool__output-header button:active {
background: var(--i18n-devtool-control-pressed);
}
.i18n-devtool__output-header button:focus-visible {
box-shadow: 0 0 0 2px var(--i18n-devtool-primary);
}
.i18n-devtool__output-header svg {
width: 0.875rem;
height: 0.875rem;
}
.i18n-devtool__output pre {
max-height: 10rem;
overflow: auto;
margin: 0;
padding: 0.65rem 1rem;
border-top: 1px solid var(--i18n-devtool-border);
font:
0.72rem/1.5 ui-monospace,
SFMono-Regular,
Menlo,
Monaco,
Consolas,
monospace;
white-space: pre-wrap;
}
.i18n-devtool__main {
flex: 1;
min-height: 0;
overflow: hidden;
}
.i18n-devtool__empty {
display: grid;
min-height: 100%;
place-items: center;
padding: 2rem;
color: var(--i18n-devtool-muted);
text-align: center;
}
.i18n-devtool .i18n-message-panel {
--i18n-border: var(--i18n-devtool-border);
--i18n-muted: var(--i18n-devtool-muted);
--i18n-surface: var(--i18n-devtool-panel);
height: 100%;
}
.i18n-devtool .i18n-message-workspace {
height: 0;
min-height: 0;
overflow: hidden;
}
.i18n-devtool .i18n-message-panel__toolbar {
@@ -209,15 +759,116 @@
.i18n-devtool .i18n-message-panel input[type="search"],
.i18n-devtool .i18n-message-panel textarea,
.i18n-devtool .i18n-message-panel button {
.i18n-devtool .i18n-message-panel button:not(.i18n-message-copy__trigger) {
background: var(--i18n-devtool-panel);
}
.i18n-devtool .i18n-message-panel button.i18n-message-copy__trigger,
.i18n-devtool
.i18n-message-panel
button.i18n-message-copy__trigger:hover:not(:disabled),
.i18n-devtool
.i18n-message-panel
button.i18n-message-copy__trigger[aria-expanded="true"] {
background: transparent;
}
.i18n-devtool
.i18n-message-panel
.i18n-message-copy__menu
> button:hover:not(:disabled),
.i18n-devtool
.i18n-message-panel
.i18n-message-copy__menu
> button:focus-visible {
background: var(--i18n-devtool-control-hover);
}
.i18n-devtool .i18n-message-copy__feedback {
background: var(--i18n-devtool-panel);
}
.i18n-devtool .i18n-message-panel .i18n-message-navigation button {
background: transparent;
}
@supports (appearance: base-select) {
.i18n-devtool__select,
.i18n-devtool__select::picker(select) {
appearance: base-select;
}
.i18n-devtool__select {
align-items: center;
gap: 0.5rem;
}
.i18n-devtool__select::picker-icon {
width: 1rem;
height: 1rem;
flex: 0 0 1rem;
background: currentColor;
content: "";
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E")
center / contain no-repeat;
transition: rotate 140ms ease;
}
.i18n-devtool__select:open::picker-icon {
rotate: 180deg;
}
.i18n-devtool__select::picker(select) {
margin-block-start: 0.25rem;
padding: 0.25rem;
border: 1px solid var(--i18n-devtool-border);
border-radius: 0.625rem;
background: var(--i18n-devtool-panel);
box-shadow: 0 12px 32px rgb(0 0 0 / 20%);
color: var(--i18n-devtool-foreground);
}
.i18n-devtool__select option {
min-height: 2.25rem;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.625rem;
border: 0;
border-radius: 0.4rem;
background: transparent;
color: inherit;
}
.i18n-devtool__select option:hover,
.i18n-devtool__select option:focus {
background: var(--i18n-devtool-control-hover);
}
.i18n-devtool__select option:checked {
background: color-mix(
in srgb,
var(--i18n-devtool-primary) 12%,
var(--i18n-devtool-panel)
);
color: var(--i18n-devtool-primary);
}
.i18n-devtool__select option::checkmark {
width: 1rem;
height: 1rem;
background: currentColor;
content: "";
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E")
center / contain no-repeat;
}
}
@media (max-width: 42rem) {
.i18n-devtool__panel {
inset: 0;
width: 100vw;
border-radius: 0;
transform: none;
}
.i18n-devtool__header {
@@ -229,8 +880,13 @@
flex-wrap: wrap;
}
.i18n-devtool__controls label {
.i18n-devtool__locale-field,
.i18n-devtool__source-locale-field {
min-width: 10rem;
flex: 1;
}
.i18n-devtool__action-field {
flex: none;
}
}
+586 -3
View File
@@ -1,17 +1,159 @@
// @vitest-environment jsdom
import * as React from "react"
import { act, render, screen, waitFor } from "@testing-library/react"
import { afterEach, describe, expect, it } from "vitest"
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react"
import { afterEach, describe, expect, it, vi } from "vitest"
import { I18nProvider } from "../runtime"
import { I18nDevtool } from "./i18n-devtool"
import { I18nDevtool, type I18nDevtoolProps } from "./i18n-devtool"
afterEach(() => {
cleanup()
document.documentElement.classList.remove("ui:dark")
window.localStorage.clear()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
function renderDevtool(props?: I18nDevtoolProps) {
return render(
<I18nProvider catalogs={{ en: {} }} locale="en" locales={["en"]}>
<I18nDevtool dark="ui:dark" {...props} />
</I18nProvider>
)
}
function mockDevtoolApi(output = "Done") {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input)
return new Response(
JSON.stringify(url.includes("/actions/") ? { output } : []),
{
headers: { "content-type": "application/json" },
status: 200,
}
)
})
)
}
function prepareTriggerForDragging(
trigger: HTMLElement,
{ left, top }: { left: number; top: number }
) {
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
bottom: top + 44,
height: 44,
left,
right: left + 80,
top,
width: 80,
x: left,
y: top,
toJSON: () => ({}),
})
trigger.setPointerCapture = vi.fn()
trigger.hasPointerCapture = vi.fn(() => false)
}
describe("I18nDevtool", () => {
it("uses an explicitly configured control-panel locale", async () => {
mockDevtoolApi()
renderDevtool({ defaultOpen: true, locale: "zh-Hans" })
const dialog = await screen.findByRole("dialog")
const devtool = dialog.closest<HTMLElement>('[data-slot="i18n-devtool"]')
expect(devtool?.getAttribute("lang")).toBe("zh-Hans")
expect(screen.getByText("操作")).toBeTruthy()
expect(screen.getByRole("button", { name: "提取" })).toBeTruthy()
expect(screen.getByRole("button", { name: "编译" })).toBeTruthy()
expect(screen.getByPlaceholderText("搜索消息")).toBeTruthy()
expect(
await screen.findByText("尚无可用消息。请先提取项目消息以生成目录。")
).toBeTruthy()
})
it("detects the first supported navigator locale", async () => {
vi.spyOn(window.navigator, "languages", "get").mockReturnValue([
"pt-BR",
"ja-JP",
"en-US",
])
renderDevtool()
const trigger = await screen.findByRole("button", {
name: "I18n Devtool を開く",
})
const devtool = trigger.closest<HTMLElement>('[data-slot="i18n-devtool"]')
expect(devtool?.getAttribute("lang")).toBe("ja")
})
it("falls back to English when an explicit locale is unsupported", async () => {
vi.spyOn(window.navigator, "languages", "get").mockReturnValue(["zh-CN"])
renderDevtool({ locale: "pt-BR" })
const trigger = await screen.findByRole("button", {
name: "Open I18n Devtool",
})
const devtool = trigger.closest<HTMLElement>('[data-slot="i18n-devtool"]')
expect(devtool?.getAttribute("lang")).toBe("en")
})
it("includes the project source locale as a customization target", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input)
const body = url.endsWith("/project")
? { locales: ["en", "zh-Hans"], sourceLocale: "en" }
: []
return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
status: 200,
})
})
)
render(
<I18nProvider
catalogs={{ en: {}, "zh-Hans": {} }}
locale="en"
locales={[
{ label: "English", locale: "en" },
{ label: "简体中文", locale: "zh-Hans" },
]}
>
<I18nDevtool dark="ui:dark" defaultOpen />
</I18nProvider>
)
const localeSelect = await screen.findByRole("combobox", {
name: "Target message locale",
})
await waitFor(() => {
expect(localeSelect).toHaveProperty("value", "en")
})
expect(screen.getByText("Source locale")).toBeTruthy()
expect(screen.getByText("English", { selector: "output" })).toBeTruthy()
expect(screen.getByRole("option", { name: "English" })).toBeTruthy()
expect(screen.getByRole("option", { name: "简体中文" })).toBeTruthy()
})
it("tracks the configured document dark-mode class", async () => {
render(
<I18nProvider
@@ -39,4 +181,445 @@ describe("I18nDevtool", () => {
expect(devtool?.hasAttribute("data-dark")).toBe(true)
})
})
it("preserves panel state while Activity hides the floating panel", async () => {
mockDevtoolApi()
renderDevtool()
fireEvent.click(
await screen.findByRole("button", {
name: "Open I18n Devtool",
})
)
fireEvent.change(await screen.findByPlaceholderText("Search messages"), {
target: { value: "navigation" },
})
fireEvent.click(screen.getByRole("button", { name: "Close I18n Devtool" }))
fireEvent.click(
await screen.findByRole("button", {
name: "Open I18n Devtool",
})
)
expect(screen.getByPlaceholderText("Search messages")).toHaveProperty(
"value",
"navigation"
)
})
it("toggles full screen and lets Escape restore the floating panel", async () => {
mockDevtoolApi()
renderDevtool({ defaultOpen: true })
const dialog = await screen.findByRole("dialog")
const enterFullscreen = screen.getByRole("button", {
name: "Enter full screen",
})
expect(dialog.hasAttribute("data-fullscreen")).toBe(false)
fireEvent.click(enterFullscreen)
expect(dialog.hasAttribute("data-fullscreen")).toBe(true)
expect(
screen.getByRole("button", { name: "Exit full screen" })
).toBeTruthy()
fireEvent.keyDown(window, { key: "Escape" })
expect(dialog.hasAttribute("data-fullscreen")).toBe(false)
expect(screen.getByRole("dialog")).toBeTruthy()
})
it("expands a docked trigger while it is hovered or focused", async () => {
renderDevtool()
const trigger = await screen.findByRole("button", {
name: "Open I18n Devtool",
})
expect(trigger.getAttribute("data-dock")).toBe("right")
expect(trigger.hasAttribute("data-collapsed")).toBe(true)
fireEvent.pointerEnter(trigger)
expect(trigger.hasAttribute("data-collapsed")).toBe(false)
fireEvent.pointerLeave(trigger)
expect(trigger.hasAttribute("data-collapsed")).toBe(true)
fireEvent.focus(trigger)
expect(trigger.hasAttribute("data-collapsed")).toBe(false)
})
it("keeps a docked trigger stationary when clicked and collapses it after closing", async () => {
renderDevtool()
const trigger = await screen.findByRole("button", {
name: "Open I18n Devtool",
})
prepareTriggerForDragging(trigger, { left: 944, top: 700 })
fireEvent.pointerEnter(trigger)
fireEvent.focus(trigger)
fireEvent.pointerDown(trigger, {
button: 0,
clientX: 964,
clientY: 720,
pointerId: 1,
})
expect(trigger.getAttribute("data-dock")).toBe("right")
expect(trigger.hasAttribute("data-dragging")).toBe(false)
fireEvent.pointerUp(trigger, {
clientX: 964,
clientY: 720,
pointerId: 1,
})
fireEvent.click(trigger)
fireEvent.click(
await screen.findByRole("button", { name: "Close I18n Devtool" })
)
expect(trigger.getAttribute("data-dock")).toBe("right")
expect(trigger.hasAttribute("data-collapsed")).toBe(true)
})
it("only enters dragging state after a long press and does not open on release", async () => {
renderDevtool()
const trigger = await screen.findByRole("button", {
name: "Open I18n Devtool",
})
prepareTriggerForDragging(trigger, { left: 944, top: 700 })
fireEvent.pointerEnter(trigger)
vi.useFakeTimers()
try {
fireEvent.pointerDown(trigger, {
button: 0,
clientX: 964,
clientY: 720,
pointerId: 1,
})
expect(trigger.hasAttribute("data-dragging")).toBe(false)
act(() => {
vi.advanceTimersByTime(400)
})
expect(trigger.hasAttribute("data-dragging")).toBe(true)
fireEvent.pointerUp(trigger, {
clientX: 964,
clientY: 720,
pointerId: 1,
})
fireEvent.click(trigger)
expect(trigger.hasAttribute("data-dragging")).toBe(false)
expect(screen.queryByRole("dialog")).toBeNull()
} finally {
vi.useRealTimers()
}
})
it("keeps a freely dragged trigger in place without opening the panel", async () => {
renderDevtool()
const trigger = await screen.findByRole("button", {
name: "Open I18n Devtool",
})
prepareTriggerForDragging(trigger, { left: 900, top: 700 })
fireEvent.pointerDown(trigger, {
button: 0,
clientX: 920,
clientY: 720,
pointerId: 1,
})
fireEvent.pointerMove(trigger, {
clientX: 420,
clientY: 270,
pointerId: 1,
})
expect(trigger.hasAttribute("data-dragging")).toBe(true)
fireEvent.pointerUp(trigger, {
clientX: 420,
clientY: 270,
pointerId: 1,
})
fireEvent.click(trigger)
expect(trigger.hasAttribute("data-dock")).toBe(false)
expect(trigger.style.left).toBe("400px")
expect(trigger.style.top).toBe("250px")
expect(screen.queryByRole("dialog")).toBeNull()
})
it("docks and collapses after being dragged to a viewport edge", async () => {
renderDevtool()
const trigger = await screen.findByRole("button", {
name: "Open I18n Devtool",
})
prepareTriggerForDragging(trigger, { left: 400, top: 250 })
fireEvent.focus(trigger)
fireEvent.pointerEnter(trigger)
fireEvent.pointerDown(trigger, {
button: 0,
clientX: 420,
clientY: 270,
pointerId: 1,
})
fireEvent.pointerMove(trigger, {
clientX: 20,
clientY: 270,
pointerId: 1,
})
fireEvent.pointerUp(trigger, {
clientX: 20,
clientY: 270,
pointerId: 1,
})
fireEvent.pointerLeave(trigger)
expect(trigger.getAttribute("data-dock")).toBe("left")
expect(trigger.hasAttribute("data-collapsed")).toBe(true)
expect(trigger.style.left).toBe("0px")
})
it("restores its persisted floating position after remounting", async () => {
const firstRender = renderDevtool()
const trigger = await screen.findByRole("button", {
name: "Open I18n Devtool",
})
prepareTriggerForDragging(trigger, { left: 900, top: 700 })
fireEvent.pointerDown(trigger, {
button: 0,
clientX: 920,
clientY: 720,
pointerId: 1,
})
fireEvent.pointerMove(trigger, {
clientX: 420,
clientY: 270,
pointerId: 1,
})
fireEvent.pointerUp(trigger, {
clientX: 420,
clientY: 270,
pointerId: 1,
})
await waitFor(() => {
expect(
window.localStorage.getItem("i18n-devtool:floating-trigger-placement")
).toContain('"x":400')
})
firstRender.unmount()
renderDevtool()
const restoredTrigger = await screen.findByRole("button", {
name: "Open I18n Devtool",
})
expect(restoredTrigger.hasAttribute("data-dock")).toBe(false)
expect(restoredTrigger.style.left).toBe("400px")
expect(restoredTrigger.style.top).toBe("250px")
})
it("switches between automatic, light, and dark themes", async () => {
mockDevtoolApi()
renderDevtool({ defaultOpen: true })
const devtool = await screen
.findByRole("dialog")
.then((dialog) =>
dialog.closest<HTMLElement>('[data-slot="i18n-devtool"]')
)
fireEvent.click(
screen.getByRole("button", {
name: "Open I18n Devtool settings",
})
)
const autoButton = screen.getByRole("button", { name: "Auto theme" })
const lightButton = screen.getByRole("button", { name: "Light theme" })
const darkButton = screen.getByRole("button", { name: "Dark theme" })
expect(autoButton.getAttribute("aria-pressed")).toBe("true")
expect(devtool?.getAttribute("data-theme")).toBe("auto")
fireEvent.click(darkButton)
expect(darkButton.getAttribute("aria-pressed")).toBe("true")
expect(devtool?.hasAttribute("data-dark")).toBe(true)
fireEvent.click(lightButton)
expect(lightButton.getAttribute("aria-pressed")).toBe("true")
expect(devtool?.hasAttribute("data-dark")).toBe(false)
})
it("positions the panel from settings and restores the preference", async () => {
mockDevtoolApi()
const firstRender = renderDevtool({ defaultOpen: true })
const dialog = await screen.findByRole("dialog")
expect(dialog.getAttribute("data-placement")).toBe("right")
fireEvent.click(
screen.getByRole("button", {
name: "Open I18n Devtool settings",
})
)
fireEvent.click(screen.getByRole("button", { name: "Center" }))
expect(dialog.getAttribute("data-placement")).toBe("center")
fireEvent.click(screen.getByRole("button", { name: "Left" }))
expect(dialog.getAttribute("data-placement")).toBe("left")
await waitFor(() => {
expect(window.localStorage.getItem("i18n-devtool:preferences")).toContain(
'"panelPlacement":"left"'
)
})
firstRender.unmount()
renderDevtool({ defaultOpen: true })
expect(
(await screen.findByRole("dialog")).getAttribute("data-placement")
).toBe("left")
})
it("switches and restores the Devtool interface locale from settings", async () => {
mockDevtoolApi()
const firstRender = renderDevtool({ defaultOpen: true })
fireEvent.click(
await screen.findByRole("button", {
name: "Open I18n Devtool settings",
})
)
fireEvent.change(
screen.getByRole("combobox", {
name: "I18n Devtool interface language",
}),
{ target: { value: "zh-Hans" } }
)
const dialog = screen.getByRole("dialog")
const devtool = dialog.closest<HTMLElement>('[data-slot="i18n-devtool"]')
expect(devtool?.getAttribute("lang")).toBe("zh-Hans")
expect(screen.getByText("界面语言")).toBeTruthy()
expect(screen.getByPlaceholderText("搜索消息")).toBeTruthy()
await waitFor(() => {
expect(window.localStorage.getItem("i18n-devtool:preferences")).toContain(
'"interfaceLocale":"zh-Hans"'
)
})
firstRender.unmount()
renderDevtool({ defaultOpen: true })
const restoredDialog = await screen.findByRole("dialog")
expect(
restoredDialog
.closest<HTMLElement>('[data-slot="i18n-devtool"]')
?.getAttribute("lang")
).toBe("zh-Hans")
})
it("hides panel placement settings in standalone mode", async () => {
mockDevtoolApi()
renderDevtool({
defaultOpen: true,
locale: "zh-Hans",
mode: "standalone",
})
fireEvent.click(
await screen.findByRole("button", {
name: "打开 I18n 开发工具设置",
})
)
expect(screen.getByText("主题")).toBeTruthy()
expect(screen.getByText("界面语言")).toBeTruthy()
expect(screen.queryByText("面板位置")).toBeNull()
expect(
screen.queryByRole("group", {
name: "I18n 开发工具面板位置",
})
).toBeNull()
})
it("closes a floating panel when pressing outside it", async () => {
mockDevtoolApi()
renderDevtool({ defaultOpen: true })
const dialog = await screen.findByRole("dialog")
fireEvent.pointerDown(dialog)
expect(screen.getByRole("dialog")).toBeTruthy()
fireEvent.pointerDown(document.body)
await waitFor(() => {
expect(screen.queryByRole("dialog")).toBeNull()
})
expect(
screen.getByRole("button", { name: "Open I18n Devtool" })
).toBeTruthy()
})
it("keeps a standalone panel open when pressing outside it", async () => {
mockDevtoolApi()
renderDevtool({ mode: "standalone" })
const dialog = await screen.findByRole("dialog")
fireEvent.pointerDown(document.body)
expect(screen.getByRole("dialog")).toBe(dialog)
})
it("collapses, expands, and closes command output", async () => {
mockDevtoolApi("\u001b[32mCatalog extraction complete\u001b[39m")
renderDevtool({ defaultOpen: true })
fireEvent.click(await screen.findByRole("button", { name: "Extract" }))
const output = await screen.findByText("Catalog extraction complete")
expect(output.tagName).toBe("SPAN")
expect(output.getAttribute("style")).toContain("color:")
expect(output.textContent).not.toContain("\u001b")
fireEvent.click(
screen.getByRole("button", { name: "Collapse command output" })
)
expect(screen.queryByText("Catalog extraction complete")).toBeNull()
fireEvent.click(
screen.getByRole("button", { name: "Expand command output" })
)
expect(screen.getByText("Catalog extraction complete")).toBeTruthy()
fireEvent.click(
screen.getByRole("button", { name: "Close command output" })
)
expect(screen.queryByText("Command output")).toBeNull()
})
it("refreshes compiled messages without remounting the message panel", async () => {
mockDevtoolApi("Compilation complete")
renderDevtool({ defaultOpen: true })
fireEvent.change(await screen.findByPlaceholderText("Search messages"), {
target: { value: "navigation" },
})
fireEvent.click(screen.getByRole("button", { name: "Compile" }))
await screen.findByText("Compilation complete")
expect(screen.getByPlaceholderText("Search messages")).toHaveProperty(
"value",
"navigation"
)
})
})
+299 -99
View File
@@ -1,9 +1,28 @@
import * as React from "react"
import { Maximize2Icon, Minimize2Icon, XIcon } from "lucide-react"
import { createPortal } from "react-dom"
import { useLocale, useLocales } from "../runtime"
import { runDevtoolAction, type DevtoolAction } from "./api-client"
import {
requestDevtoolProject,
runDevtoolAction,
type DevtoolAction,
} from "./api-client"
import { createHttpMessageRepository } from "./http-message-repository"
import { DevtoolActionOutput } from "./devtool-action-output"
import { DevtoolLocaleSelect } from "./devtool-locale-select"
import {
useDevtoolPreferences,
type I18nDevtoolPanelPlacement,
type I18nDevtoolTheme,
} from "./devtool-preferences"
import {
DevtoolLocalizationProvider,
getI18nDevtoolMessages,
resolveI18nDevtoolLocale,
} from "./devtool-localization"
import { DevtoolSettings } from "./devtool-settings"
import { I18nDevtoolTrigger } from "./i18n-devtool-trigger"
import { MessagePanel } from "./message-panel"
import { MessageRepositoryProvider } from "./message-repository"
import "./i18n-devtool.css"
@@ -13,7 +32,20 @@ export interface I18nDevtoolProps {
className?: string
dark?: string
defaultOpen?: boolean
defaultPanelPlacement?: I18nDevtoolPanelPlacement
defaultTheme?: I18nDevtoolTheme
/**
* Initial locale used by the Devtool controls. The user can override it in
* settings. When omitted, the first supported locale from
* `navigator.languages` is used, with English as the fallback.
*/
locale?: string
mode?: "floating" | "standalone"
/**
* Source locale used by the project. It is excluded from the editable target
* locale list. When omitted, the Devtool reads it from its project endpoint.
*/
sourceLocale?: string
}
function matchesDarkSelector(element: Element, dark: string) {
@@ -73,38 +105,98 @@ export function I18nDevtool({
className,
dark,
defaultOpen = false,
defaultPanelPlacement = "right",
defaultTheme = "auto",
locale: devtoolLocale,
mode = "floating",
sourceLocale,
}: I18nDevtoolProps) {
const activeLocale = useLocale()
const locales = useLocales()
const defaultInterfaceLocale = resolveI18nDevtoolLocale(devtoolLocale)
const [projectSourceLocale, setProjectSourceLocale] =
React.useState(sourceLocale)
const [portalTarget, setPortalTarget] = React.useState<HTMLElement | null>(
null
)
const panelRef = React.useRef<HTMLElement>(null)
const [isOpen, setOpen] = React.useState(defaultOpen || mode === "standalone")
const [locale, setLocale] = React.useState(activeLocale)
const [reloadKey, setReloadKey] = React.useState(0)
const [messageLocale, setMessageLocale] = React.useState(
() => activeLocale ?? locales[0]?.locale ?? ""
)
const [messageRevision, setMessageRevision] = React.useState(0)
const [actionState, setActionState] = React.useState<DevtoolAction | "idle">(
"idle"
)
const [actionOutput, setActionOutput] = React.useState("")
const [isActionOutputExpanded, setActionOutputExpanded] = React.useState(true)
const [isFullscreen, setFullscreen] = React.useState(false)
const [error, setError] = React.useState("")
const isDark = useDarkMode(dark)
const {
interfaceLocale: resolvedDevtoolLocale,
panelPlacement,
setInterfaceLocale,
setPanelPlacement,
setTheme,
theme,
} = useDevtoolPreferences({
defaultInterfaceLocale,
defaultPanelPlacement,
defaultTheme,
})
const messages = getI18nDevtoolMessages(resolvedDevtoolLocale)
const prefersDark = useDarkMode(dark)
const isDark = theme === "dark" || (theme === "auto" && prefersDark)
const repository = React.useMemo(
() => createHttpMessageRepository(apiBaseUrl),
[apiBaseUrl]
)
const sourceLocaleLabel = projectSourceLocale
? (locales.find((definition) => definition.locale === projectSourceLocale)
?.label ?? projectSourceLocale)
: undefined
React.useEffect(() => {
setPortalTarget(document.body)
}, [])
React.useEffect(() => {
if (locales.some((definition) => definition.locale === locale)) {
if (sourceLocale) {
setProjectSourceLocale(sourceLocale)
return
}
setLocale(activeLocale)
}, [activeLocale, locale, locales])
let cancelled = false
setProjectSourceLocale(undefined)
void requestDevtoolProject(apiBaseUrl).then(
(project) => {
if (!cancelled && typeof project.sourceLocale === "string") {
setProjectSourceLocale(project.sourceLocale)
}
},
() => {
// The rest of the Devtool remains usable when project metadata is not
// exposed by a custom backend.
}
)
return () => {
cancelled = true
}
}, [apiBaseUrl, sourceLocale])
React.useEffect(() => {
if (locales.some((definition) => definition.locale === messageLocale)) {
return
}
const nextLocale =
locales.find((definition) => definition.locale === activeLocale) ??
locales[0]
setMessageLocale(nextLocale?.locale ?? "")
}, [activeLocale, locales, messageLocale])
React.useEffect(() => {
if (!isOpen || mode === "standalone") {
@@ -113,25 +205,67 @@ export function I18nDevtool({
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpen(false)
if (isFullscreen) {
setFullscreen(false)
} else {
setOpen(false)
}
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [isFullscreen, isOpen, mode])
React.useEffect(() => {
if (!isOpen || mode === "standalone") {
return
}
const handlePointerDown = (event: PointerEvent) => {
if (
event.target instanceof Node &&
!panelRef.current?.contains(event.target)
) {
setFullscreen(false)
setOpen(false)
}
}
document.addEventListener("pointerdown", handlePointerDown, true)
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true)
}
}, [isOpen, mode])
const runAction = async (action: DevtoolAction) => {
setActionState(action)
setActionOutput("")
setActionOutput(
action === "extract"
? messages.actions.extracting
: messages.actions.compiling
)
setActionOutputExpanded(true)
setError("")
try {
const result = await runDevtoolAction(action, apiBaseUrl)
setActionOutput(result.output.trim() || `${action} completed.`)
setReloadKey((current) => current + 1)
setActionOutput(
result.output.trim() ||
(action === "extract"
? messages.actions.extractCompleted
: messages.actions.compileCompleted)
)
setMessageRevision((current) => current + 1)
} catch (reason) {
setError(reason instanceof Error ? reason.message : `${action} failed.`)
setError(
reason instanceof Error
? reason.message
: action === "extract"
? messages.actions.extractFailed
: messages.actions.compileFailed
)
} finally {
setActionState("idle")
}
@@ -142,99 +276,165 @@ export function I18nDevtool({
}
const rootClassName = ["i18n-devtool", className].filter(Boolean).join(" ")
const closeActionOutput = () => {
setActionOutput("")
setError("")
}
const closePanel = () => {
setFullscreen(false)
setOpen(false)
}
return createPortal(
<div
className={rootClassName}
data-dark={isDark || undefined}
data-mode={mode}
data-slot="i18n-devtool"
>
{mode === "floating" && !isOpen && (
<button
type="button"
aria-controls="i18n-devtool-panel"
aria-expanded="false"
className="i18n-devtool__trigger"
onClick={() => setOpen(true)}
>
<span aria-hidden="true"></span>
<span>i18n</span>
</button>
)}
<DevtoolLocalizationProvider locale={resolvedDevtoolLocale}>
<div
className={rootClassName}
data-dark={isDark || undefined}
data-mode={mode}
data-slot="i18n-devtool"
data-theme={theme}
lang={resolvedDevtoolLocale}
>
{mode === "floating" && (
<I18nDevtoolTrigger open={isOpen} onOpen={() => setOpen(true)} />
)}
{isOpen && (
<section
id="i18n-devtool-panel"
aria-label="I18n Devtool"
className="i18n-devtool__panel"
role="dialog"
<React.Activity
mode={isOpen ? "visible" : "hidden"}
name="I18n Devtool panel"
>
<header className="i18n-devtool__header">
<div>
<p className="i18n-devtool__eyebrow">Lingui</p>
<h2>I18n Devtool</h2>
</div>
<section
id="i18n-devtool-panel"
aria-label="I18n Devtool"
className="i18n-devtool__panel"
data-fullscreen={mode === "standalone" || isFullscreen || undefined}
data-placement={panelPlacement}
ref={panelRef}
role="dialog"
>
<header className="i18n-devtool__header">
<div>
<p className="i18n-devtool__eyebrow">Lingui</p>
<h2>I18n Devtool</h2>
</div>
<div className="i18n-devtool__controls">
<label>
<span>Locale</span>
<select
aria-label="Devtool locale"
value={locale}
onChange={(event) => setLocale(event.target.value)}
>
{locales.map((definition) => (
<option key={definition.locale} value={definition.locale}>
{definition.label ?? definition.locale}
</option>
))}
</select>
</label>
<button
type="button"
disabled={actionState !== "idle"}
onClick={() => void runAction("extract")}
>
{actionState === "extract" ? "Extracting…" : "Extract"}
</button>
<button
type="button"
disabled={actionState !== "idle"}
onClick={() => void runAction("compile")}
>
{actionState === "compile" ? "Compiling…" : "Compile"}
</button>
{mode === "floating" && (
<button
type="button"
aria-label="Close I18n Devtool"
className="i18n-devtool__close"
onClick={() => setOpen(false)}
>
×
</button>
<div className="i18n-devtool__controls">
<DevtoolLocaleSelect
locale={messageLocale}
locales={locales}
sourceLocaleLabel={sourceLocaleLabel}
onLocaleChange={setMessageLocale}
/>
<fieldset className="i18n-devtool__action-field">
<legend>{messages.actions.label}</legend>
<div className="i18n-devtool__action-controls">
<button
type="button"
className="i18n-devtool__action-button"
disabled={actionState !== "idle"}
onClick={() => void runAction("extract")}
>
<span>
{actionState === "extract"
? messages.actions.extracting
: messages.actions.extract}
</span>
<span
aria-hidden="true"
className="i18n-devtool__action-label-measure"
>
{messages.actions.extracting}
</span>
</button>
<button
type="button"
className="i18n-devtool__action-button"
disabled={actionState !== "idle"}
onClick={() => void runAction("compile")}
>
<span>
{actionState === "compile"
? messages.actions.compiling
: messages.actions.compile}
</span>
<span
aria-hidden="true"
className="i18n-devtool__action-label-measure"
>
{messages.actions.compiling}
</span>
</button>
<DevtoolSettings
interfaceLocale={resolvedDevtoolLocale}
panelPlacement={panelPlacement}
showPanelPlacement={mode === "floating"}
theme={theme}
onInterfaceLocaleChange={setInterfaceLocale}
onPanelPlacementChange={setPanelPlacement}
onThemeChange={setTheme}
/>
{mode === "floating" && (
<button
type="button"
aria-label={
isFullscreen
? messages.actions.exitFullscreen
: messages.actions.enterFullscreen
}
aria-pressed={isFullscreen}
className="i18n-devtool__fullscreen"
onClick={() => setFullscreen((current) => !current)}
>
{isFullscreen ? (
<Minimize2Icon aria-hidden="true" />
) : (
<Maximize2Icon aria-hidden="true" />
)}
</button>
)}
{mode === "floating" && (
<button
type="button"
aria-label={messages.trigger.close}
className="i18n-devtool__close"
onClick={closePanel}
>
<XIcon aria-hidden="true" />
</button>
)}
</div>
</fieldset>
</div>
</header>
{(error || actionOutput) && (
<DevtoolActionOutput
error={Boolean(error)}
expanded={isActionOutputExpanded}
output={error || actionOutput}
onClose={closeActionOutput}
onExpandedChange={setActionOutputExpanded}
/>
)}
<main className="i18n-devtool__main">
{messageLocale ? (
<MessageRepositoryProvider repository={repository}>
<MessagePanel
locale={messageLocale}
refreshToken={messageRevision}
/>
</MessageRepositoryProvider>
) : (
<div className="i18n-devtool__empty">
{messages.locale.noTargetLocales}
</div>
)}
</div>
</header>
{(error || actionOutput) && (
<pre
className="i18n-devtool__output"
data-error={error ? "" : undefined}
>
{error || actionOutput}
</pre>
)}
<main className="i18n-devtool__main">
<MessageRepositoryProvider repository={repository}>
<MessagePanel key={`${locale}:${reloadKey}`} locale={locale} />
</MessageRepositoryProvider>
</main>
</section>
)}
</div>,
</main>
</section>
</React.Activity>
</div>
</DevtoolLocalizationProvider>,
portalTarget
)
}
+14
View File
@@ -1,5 +1,18 @@
export { createHttpMessageRepository } from "./http-message-repository"
export { I18nDevtool, type I18nDevtoolProps } from "./i18n-devtool"
export {
I18nDevtoolTrigger,
type I18nDevtoolTriggerProps,
} from "./i18n-devtool-trigger"
export {
I18N_DEVTOOL_LOCALES,
type I18nDevtoolLocale,
resolveI18nDevtoolLocale,
} from "./devtool-localization"
export type {
I18nDevtoolPanelPlacement,
I18nDevtoolTheme,
} from "./devtool-preferences"
export { MessagePanel, type MessagePanelProps } from "./message-panel"
export {
MessageRepositoryProvider,
@@ -8,6 +21,7 @@ export {
} from "./message-repository"
export type {
DevtoolMessage,
DevtoolMessagePackage,
MessageOrigin,
MessageRepository,
UpdateMessageInput,
@@ -0,0 +1,285 @@
import * as React from "react"
import {
CheckIcon,
ChevronDownIcon,
CopyIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useDevtoolLocalization } from "./devtool-localization"
import type { DevtoolMessage, MessageOrigin } from "./types"
function getOriginLocation(origin: MessageOrigin) {
return `${origin.file}${origin.line ? `:${origin.line}` : ""}`
}
export function formatMessageOrigin(origin: MessageOrigin) {
const normalizedFile = origin.file.replaceAll("\\", "/")
const packageRelativeFile =
normalizedFile.match(/(?:^|\/)(?:apps|packages)\/[^/]+\/(.+)$/)?.[1] ??
normalizedFile.replace(/^(?:\.\.\/)+/, "")
return `${packageRelativeFile}${origin.line ? `:${origin.line}` : ""}`
}
function serializeMessageDescriptor(message: DevtoolMessage) {
return [
"{",
` id: ${JSON.stringify(message.id)},`,
` message: ${JSON.stringify(message.source || message.id)},`,
"}",
].join("\n")
}
async function writeClipboard(value: string) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value)
return
}
const textarea = document.createElement("textarea")
textarea.value = value
textarea.style.position = "fixed"
textarea.style.opacity = "0"
document.body.appendChild(textarea)
textarea.select()
try {
if (!document.execCommand("copy")) {
throw new Error("The browser rejected the clipboard operation.")
}
} finally {
textarea.remove()
}
}
export function MessageCopyMenu({
message,
translation,
}: {
message: DevtoolMessage
translation: string
}) {
const { messages } = useDevtoolLocalization()
const [isOpen, setOpen] = React.useState(false)
const [copyState, setCopyState] = React.useState<"idle" | "copied" | "error">(
"idle"
)
const rootRef = React.useRef<HTMLDivElement>(null)
const triggerRef = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (!isOpen) {
return
}
const handlePointerDown = (event: PointerEvent) => {
if (
event.target instanceof Node &&
!rootRef.current?.contains(event.target)
) {
setOpen(false)
}
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault()
setOpen(false)
triggerRef.current?.focus()
}
}
document.addEventListener("pointerdown", handlePointerDown)
document.addEventListener("keydown", handleKeyDown)
return () => {
document.removeEventListener("pointerdown", handlePointerDown)
document.removeEventListener("keydown", handleKeyDown)
}
}, [isOpen])
React.useEffect(() => {
if (copyState === "idle") {
return
}
const timeout = window.setTimeout(() => setCopyState("idle"), 1600)
return () => window.clearTimeout(timeout)
}, [copyState])
const openMenu = () => {
setOpen(true)
window.setTimeout(() => {
rootRef.current
?.querySelector<HTMLButtonElement>('[role="menuitem"]:not(:disabled)')
?.focus()
})
}
const closeMenu = () => {
setOpen(false)
triggerRef.current?.focus()
}
const copy = async (value: string) => {
try {
await writeClipboard(value)
setCopyState("copied")
} catch {
setCopyState("error")
} finally {
closeMenu()
}
}
const handleMenuKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (!["ArrowDown", "ArrowUp", "End", "Home"].includes(event.key)) {
return
}
event.preventDefault()
const items = Array.from(
event.currentTarget.querySelectorAll<HTMLButtonElement>(
'[role="menuitem"]:not(:disabled)'
)
)
if (items.length === 0) {
return
}
const currentIndex = items.indexOf(
document.activeElement as HTMLButtonElement
)
const nextIndex =
event.key === "Home"
? 0
: event.key === "End"
? items.length - 1
: event.key === "ArrowUp"
? (currentIndex - 1 + items.length) % items.length
: (currentIndex + 1) % items.length
items[nextIndex]?.focus()
}
return (
<div className="i18n-message-copy" ref={rootRef}>
<button
type="button"
aria-expanded={isOpen}
aria-haspopup="menu"
aria-label={
copyState === "copied"
? messages.messagePanel.copied
: copyState === "error"
? messages.messagePanel.copyFailed
: messages.messagePanel.chooseCopyContent
}
className="i18n-message-copy__trigger"
data-state={copyState}
ref={triggerRef}
title={messages.messagePanel.chooseCopyContent}
onClick={() => {
if (isOpen) {
closeMenu()
} else {
openMenu()
}
}}
>
{copyState === "copied" ? (
<CheckIcon aria-hidden="true" />
) : copyState === "error" ? (
<TriangleAlertIcon aria-hidden="true" />
) : (
<CopyIcon aria-hidden="true" />
)}
<ChevronDownIcon
aria-hidden="true"
className="i18n-message-copy__chevron"
/>
</button>
{isOpen && (
<div
className="i18n-message-copy__menu"
role="menu"
onKeyDown={handleMenuKeyDown}
>
<button
type="button"
role="menuitem"
onClick={() => void copy(message.id)}
>
{messages.messagePanel.copyMessageId}
</button>
<button
type="button"
role="menuitem"
onClick={() => void copy(message.source || message.id)}
>
{messages.messagePanel.copySource}
</button>
<button
type="button"
disabled={translation.length === 0}
role="menuitem"
onClick={() => void copy(translation)}
>
{messages.messagePanel.copyTranslation}
</button>
{message.origins.length > 0 && (
<>
<div className="i18n-message-copy__separator" role="separator" />
<span className="i18n-message-copy__label">
{messages.messagePanel.sourceLocations}
</span>
{message.origins.map((origin, index) => (
<button
type="button"
key={`${getOriginLocation(origin)}:${index}`}
role="menuitem"
title={getOriginLocation(origin)}
onClick={() => void copy(getOriginLocation(origin))}
>
<span>{messages.messagePanel.copySourceLocation}</span>
<code>{formatMessageOrigin(origin)}</code>
</button>
))}
</>
)}
<div className="i18n-message-copy__separator" role="separator" />
<button
type="button"
role="menuitem"
onClick={() => void copy(serializeMessageDescriptor(message))}
>
{messages.messagePanel.copyDescriptor}
</button>
</div>
)}
{copyState !== "idle" && !isOpen && (
<span
aria-atomic="true"
aria-live="polite"
className="i18n-message-copy__feedback"
data-state={copyState}
role="status"
>
{copyState === "copied" ? (
<CheckIcon aria-hidden="true" />
) : (
<TriangleAlertIcon aria-hidden="true" />
)}
{copyState === "copied"
? messages.messagePanel.copied
: messages.messagePanel.copyFailed}
</span>
)}
</div>
)
}
@@ -0,0 +1,356 @@
import * as React from "react"
import { ChevronDownIcon, FolderIcon, PackageIcon } from "lucide-react"
import { useDevtoolLocalization } from "./devtool-localization"
import type { DevtoolMessage, DevtoolMessagePackage } from "./types"
export interface MessagePackageGroup {
kind: DevtoolMessagePackage["kind"]
messages: readonly DevtoolMessage[]
missingCount: number
packageName: string
}
interface MessageNamespaceNode {
children: Map<string, MessageNamespaceNode>
descendantCount: number
messages: DevtoolMessage[]
path: string
segment: string
}
interface MessagePackageNode extends MessagePackageGroup {
messagesAtRoot: readonly DevtoolMessage[]
namespaces: Map<string, MessageNamespaceNode>
selectionKey: string
}
export interface MessageNavigationSelection {
key: string
label: string
messages: readonly DevtoolMessage[]
packageName: string
}
export interface MessageNavigationModel {
packages: readonly MessagePackageNode[]
selections: ReadonlyMap<string, MessageNavigationSelection>
}
function getPackageSelectionKey(packageName: string) {
return `package:${packageName}`
}
function getNamespaceSelectionKey(packageName: string, path: string) {
return `package:${packageName}:namespace:${path}`
}
export function createMessageNavigationModel(
groups: readonly MessagePackageGroup[]
): MessageNavigationModel {
const selections = new Map<string, MessageNavigationSelection>()
const packages = groups.map<MessagePackageNode>((group) => {
const messagesAtRoot: DevtoolMessage[] = []
const namespaces = new Map<string, MessageNamespaceNode>()
for (const message of group.messages) {
const segments = message.id.split(".").filter(Boolean)
if (segments.length <= 1) {
messagesAtRoot.push(message)
continue
}
const namespaceSegments = segments.slice(0, -1)
const namespacePath = namespaceSegments.join(".")
let children = namespaces
let path = ""
for (const segment of namespaceSegments) {
path = path ? `${path}.${segment}` : segment
let node = children.get(segment)
if (!node) {
node = {
children: new Map(),
descendantCount: 0,
messages: [],
path,
segment,
}
children.set(segment, node)
}
node.descendantCount += 1
children = node.children
if (path === namespacePath) {
node.messages.push(message)
}
}
}
const selectionKey = getPackageSelectionKey(group.packageName)
selections.set(selectionKey, {
key: selectionKey,
label: group.packageName,
messages: messagesAtRoot,
packageName: group.packageName,
})
const addNamespaceSelections = (
nodes: Map<string, MessageNamespaceNode>
) => {
for (const node of nodes.values()) {
const key = getNamespaceSelectionKey(group.packageName, node.path)
selections.set(key, {
key,
label: node.path,
messages: node.messages,
packageName: group.packageName,
})
addNamespaceSelections(node.children)
}
}
addNamespaceSelections(namespaces)
return {
...group,
messagesAtRoot,
namespaces,
selectionKey,
}
})
return { packages, selections }
}
function NamespaceBranch({
nodes,
onSelect,
packageName,
selectedKey,
}: {
nodes: Map<string, MessageNamespaceNode>
onSelect: (key: string) => void
packageName: string
selectedKey?: string
}) {
return (
<ul className="i18n-message-tree__branch">
{Array.from(nodes.values())
.sort((left, right) => left.segment.localeCompare(right.segment))
.map((node) => (
<NamespaceNode
key={node.path}
node={node}
packageName={packageName}
selectedKey={selectedKey}
onSelect={onSelect}
/>
))}
</ul>
)
}
function NamespaceNode({
node,
onSelect,
packageName,
selectedKey,
}: {
node: MessageNamespaceNode
onSelect: (key: string) => void
packageName: string
selectedKey?: string
}) {
const [isExpanded, setExpanded] = React.useState(true)
const { messages } = useDevtoolLocalization()
const selectionKey = getNamespaceSelectionKey(packageName, node.path)
const hasChildren = node.children.size > 0
const hasMessages = node.messages.length > 0
const isSelected = selectionKey === selectedKey
return (
<li>
<div
className="i18n-message-tree__namespace-row"
data-selected={isSelected || undefined}
>
<button
type="button"
aria-expanded={hasChildren ? isExpanded : undefined}
aria-label={
hasChildren
? isExpanded
? messages.messagePanel.collapseNamespace(node.path)
: messages.messagePanel.expandNamespace(node.path)
: undefined
}
className="i18n-message-tree__disclosure"
disabled={!hasChildren}
onClick={() => setExpanded((current) => !current)}
>
{hasChildren && (
<ChevronDownIcon
aria-hidden="true"
className="i18n-message-tree__chevron"
/>
)}
</button>
<button
type="button"
aria-current={isSelected ? "true" : undefined}
className="i18n-message-tree__namespace"
data-selectable={hasMessages || undefined}
title={node.path}
onClick={() => {
if (hasMessages) {
onSelect(selectionKey)
} else if (hasChildren) {
setExpanded((current) => !current)
}
}}
>
<FolderIcon aria-hidden="true" />
<span>{node.segment}</span>
<small>{node.descendantCount}</small>
</button>
</div>
{hasChildren && (
<div hidden={!isExpanded}>
<NamespaceBranch
nodes={node.children}
packageName={packageName}
selectedKey={selectedKey}
onSelect={onSelect}
/>
</div>
)}
</li>
)
}
function PackageTree({
node,
onSelect,
selectedKey,
}: {
node: MessagePackageNode
onSelect: (key: string) => void
selectedKey?: string
}) {
const { messages } = useDevtoolLocalization()
const [isExpanded, setExpanded] = React.useState(true)
const contentId = React.useId()
const hasNamespaces = node.namespaces.size > 0
const hasContent = node.messages.length === 0 || hasNamespaces
const isSelected = node.selectionKey === selectedKey
return (
<section
aria-label={node.packageName}
className="i18n-message-package"
data-collapsed={!isExpanded || undefined}
data-kind={node.kind}
data-selected={isSelected || undefined}
>
<header className="i18n-message-package__header">
<button
type="button"
aria-current={isSelected ? "true" : undefined}
className="i18n-message-package__identity"
onClick={() => onSelect(node.selectionKey)}
>
<span aria-hidden="true" className="i18n-message-package__icon">
<PackageIcon />
</span>
<code>{node.packageName}</code>
</button>
<span className="i18n-message-package__controls">
<span className="i18n-message-package__counts">
<span>
{messages.messagePanel.messageCount(node.messages.length)}
</span>
{node.missingCount > 0 && (
<span data-missing>
{messages.messagePanel.missingCount(node.missingCount)}
</span>
)}
</span>
<button
type="button"
aria-controls={hasContent ? contentId : undefined}
aria-expanded={hasContent ? isExpanded : undefined}
aria-label={
hasContent
? isExpanded
? messages.messagePanel.collapsePackage(node.packageName)
: messages.messagePanel.expandPackage(node.packageName)
: undefined
}
className="i18n-message-package__disclosure"
disabled={!hasContent}
onClick={() => setExpanded((current) => !current)}
>
{hasContent && (
<ChevronDownIcon
aria-hidden="true"
className="i18n-message-package__chevron"
/>
)}
</button>
</span>
</header>
{hasContent && (
<div
id={contentId}
className="i18n-message-package__messages"
hidden={!isExpanded}
>
{node.messages.length === 0 ? (
<div className="i18n-message-package__empty">
{messages.messagePanel.emptyPackage}
</div>
) : (
<NamespaceBranch
nodes={node.namespaces}
packageName={node.packageName}
selectedKey={selectedKey}
onSelect={onSelect}
/>
)}
</div>
)}
</section>
)
}
export function MessageNavigation({
model,
onSelect,
selectedKey,
}: {
model: MessageNavigationModel
onSelect: (key: string) => void
selectedKey?: string
}) {
const { messages } = useDevtoolLocalization()
return (
<nav
aria-label={messages.messagePanel.messageNavigation}
className="i18n-message-navigation"
>
{model.packages.map((node) => (
<PackageTree
key={node.packageName}
node={node}
selectedKey={selectedKey}
onSelect={onSelect}
/>
))}
</nav>
)
}
+776 -13
View File
@@ -3,6 +3,9 @@
--i18n-muted: color-mix(in srgb, currentColor 62%, transparent);
--i18n-surface: color-mix(in srgb, Canvas 96%, currentColor 4%);
color: CanvasText;
display: flex;
min-height: 0;
flex-direction: column;
font-family:
Inter,
ui-sans-serif,
@@ -33,8 +36,21 @@
}
.i18n-message-panel__search {
position: relative;
display: grid;
min-width: 12rem;
flex: 1;
align-items: center;
}
.i18n-message-panel__search > svg {
position: absolute;
left: 0.75rem;
z-index: 1;
width: 1rem;
height: 1rem;
color: var(--i18n-muted);
pointer-events: none;
}
.i18n-message-panel input,
@@ -55,16 +71,24 @@
.i18n-message-panel input[type="search"] {
height: 2.5rem;
padding: 0 0.75rem;
padding: 0 3.25rem 0 2.25rem;
}
.i18n-message-panel textarea {
min-height: 5.5rem;
min-height: 3.25rem;
max-height: 14rem;
field-sizing: content;
resize: vertical;
padding: 0.75rem;
padding: 0.65rem 0.75rem;
line-height: 1.5;
}
.i18n-message-panel textarea:read-only {
background: color-mix(in srgb, Canvas 92%, currentColor 8%);
color: var(--i18n-muted);
cursor: default;
}
.i18n-message-panel input[type="search"]:focus,
.i18n-message-panel textarea:focus {
border-color: AccentColor;
@@ -72,32 +96,464 @@
}
.i18n-message-panel__toggle {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.4rem;
border-radius: 0.45rem;
cursor: pointer;
white-space: nowrap;
font-size: 0.875rem;
transition: background-color 120ms ease;
}
.i18n-message-panel__toggle:hover {
background: color-mix(in srgb, Canvas 90%, currentColor 10%);
}
.i18n-message-panel__toggle:active {
background: color-mix(in srgb, Canvas 84%, currentColor 16%);
}
.i18n-message-panel__toggle input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
.i18n-message-panel__checkbox {
display: inline-grid;
width: 1.125rem;
height: 1.125rem;
flex: 0 0 1.125rem;
place-items: center;
border: 1px solid var(--i18n-border);
border-radius: 0.25rem;
background: Canvas;
color: white;
transition:
background-color 120ms ease,
border-color 120ms ease,
box-shadow 120ms ease,
transform 80ms ease;
}
.i18n-message-panel__checkbox svg {
width: 0.875rem;
height: 0.875rem;
opacity: 0;
stroke-width: 3;
transform: scale(0.6);
transition:
opacity 100ms ease,
transform 120ms ease;
}
.i18n-message-panel__toggle input:checked + .i18n-message-panel__checkbox {
border-color: AccentColor;
background: AccentColor;
}
.i18n-message-panel__toggle input:checked + .i18n-message-panel__checkbox svg {
opacity: 1;
transform: scale(1);
}
.i18n-message-panel__toggle
input:focus-visible
+ .i18n-message-panel__checkbox {
box-shadow: 0 0 0 3px color-mix(in srgb, AccentColor 22%, transparent);
}
.i18n-message-panel__toggle:active .i18n-message-panel__checkbox {
transform: scale(0.92);
}
.i18n-message-panel__count {
display: inline-grid;
min-width: 1.5rem;
height: 1.5rem;
place-items: center;
padding: 0 0.35rem;
border-radius: 999px;
background: color-mix(in srgb, Canvas 86%, currentColor 14%);
color: var(--i18n-muted);
font-variant-numeric: tabular-nums;
font-size: 0.8125rem;
font-size: 0.75rem;
}
.i18n-message-panel__list {
.i18n-message-panel__result-count {
position: absolute;
right: 0.55rem;
display: inline-grid;
min-width: 1.75rem;
height: 1.5rem;
place-items: center;
padding: 0 0.4rem;
border-radius: 999px;
background: color-mix(in srgb, Canvas 84%, currentColor 16%);
color: var(--i18n-muted);
font-variant-numeric: tabular-nums;
font-size: 0.75rem;
pointer-events: none;
}
.i18n-message-workspace {
display: grid;
gap: 0.75rem;
min-height: 30rem;
flex: 1;
grid-template-columns: minmax(18rem, min(28rem, 44%)) minmax(0, 1fr);
}
.i18n-message-navigation,
.i18n-message-workspace__editor {
min-width: 0;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.i18n-message-navigation {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.65rem;
padding: 0.75rem;
border-right: 1px solid var(--i18n-border);
background: color-mix(in srgb, Canvas 94%, currentColor 6%);
}
.i18n-message-workspace__editor {
padding: 0.75rem;
}
.i18n-message-workspace__editor-list {
display: grid;
align-content: start;
gap: 0.75rem;
}
.i18n-message-workspace__empty {
display: grid;
min-height: 100%;
place-items: center;
padding: 2rem;
color: var(--i18n-muted);
text-align: center;
}
.i18n-message-package {
display: grid;
min-width: 0;
flex: none;
overflow: hidden;
border: 1px solid var(--i18n-border);
border-radius: 0.75rem;
background: color-mix(in srgb, Canvas 91%, currentColor 9%);
box-shadow: 0 0.15rem 0.5rem rgb(0 0 0 / 4%);
}
.i18n-message-package__messages[hidden] {
display: none;
}
.i18n-message-package__messages {
position: relative;
display: grid;
padding: 0.45rem 0.45rem 0.55rem 0.7rem;
border-top: 1px solid var(--i18n-border);
background: color-mix(in srgb, Canvas 96%, currentColor 4%);
}
.i18n-message-package__empty {
display: grid;
min-height: 5rem;
place-items: center;
padding: 1rem;
border: 1px dashed color-mix(in srgb, currentColor 18%, transparent);
border-radius: 0.55rem;
background: color-mix(in srgb, Canvas 80%, transparent);
color: var(--i18n-muted);
font-size: 0.8125rem;
text-align: center;
}
.i18n-message-package__header {
display: flex;
min-width: 0;
min-height: 3rem;
align-items: center;
justify-content: space-between;
gap: 0.35rem;
padding: 0.3rem;
background: color-mix(in srgb, Canvas 86%, currentColor 14%);
}
.i18n-message-panel button.i18n-message-package__identity {
display: flex;
min-width: 0;
min-height: 2.35rem;
flex: 1;
justify-content: flex-start;
gap: 0.6rem;
padding: 0.25rem 0.35rem;
border: 0;
border-radius: 0.5rem;
outline: none;
background: transparent;
text-align: start;
}
.i18n-message-panel button.i18n-message-package__identity:hover:not(:disabled),
.i18n-message-panel
.i18n-message-package[data-selected]
button.i18n-message-package__identity {
background: color-mix(in srgb, Canvas 80%, currentColor 20%);
}
.i18n-message-panel button.i18n-message-package__identity:focus-visible,
.i18n-message-panel button.i18n-message-package__disclosure:focus-visible {
box-shadow: inset 0 0 0 2px AccentColor;
}
.i18n-message-package__identity code {
min-width: 0;
overflow: hidden;
font-size: 0.8125rem;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.i18n-message-package__icon {
display: inline-grid;
width: 1.9rem;
height: 1.9rem;
flex: 0 0 1.9rem;
place-items: center;
border-radius: 0.5rem;
background: color-mix(in srgb, AccentColor 14%, Canvas);
color: AccentColor;
}
.i18n-message-package__icon svg {
width: 1rem;
height: 1rem;
}
.i18n-message-package__counts,
.i18n-message-package__controls {
display: inline-flex;
min-width: 0;
align-items: center;
}
.i18n-message-package__counts {
flex: none;
gap: 0.35rem;
}
.i18n-message-package__controls {
flex: none;
gap: 0.25rem;
}
.i18n-message-panel button.i18n-message-package__disclosure {
width: 2rem;
min-height: 0;
height: 2rem;
flex: 0 0 2rem;
padding: 0;
border: 0;
border-radius: 0.45rem;
outline: none;
background: transparent;
}
.i18n-message-package__chevron {
width: 1rem;
height: 1rem;
flex: 0 0 1rem;
color: var(--i18n-muted);
transition: rotate 140ms ease;
}
.i18n-message-package__disclosure[aria-expanded="false"]
.i18n-message-package__chevron {
rotate: -90deg;
}
.i18n-message-package__disclosure[aria-expanded="false"]:dir(rtl)
.i18n-message-package__chevron {
rotate: 90deg;
}
.i18n-message-package__counts span {
padding: 0.2rem 0.45rem;
border-radius: 999px;
background: color-mix(in srgb, Canvas 84%, currentColor 16%);
color: var(--i18n-muted);
font-variant-numeric: tabular-nums;
font-size: 0.6875rem;
white-space: nowrap;
}
.i18n-message-package__counts span[data-missing] {
background: color-mix(in srgb, #e8a317 14%, Canvas);
color: color-mix(in srgb, #b36b00 86%, CanvasText);
}
.i18n-message-tree__branch {
position: relative;
display: grid;
gap: 0.2rem;
margin: 0;
padding: 0;
list-style: none;
}
.i18n-message-tree__branch .i18n-message-tree__branch {
margin-block: 0.15rem 0.25rem;
margin-inline-start: 0.85rem;
padding-inline-start: 0.75rem;
border-inline-start: 1px solid
color-mix(in srgb, currentColor 18%, transparent);
}
.i18n-message-tree__branch li {
min-width: 0;
}
.i18n-message-tree__namespace-row {
display: grid;
min-width: 0;
grid-template-columns: 1.65rem minmax(0, 1fr);
align-items: center;
border-radius: 0.5rem;
transition:
color 120ms ease,
background-color 120ms ease;
}
.i18n-message-tree__namespace-row:hover {
background: color-mix(in srgb, Canvas 82%, currentColor 18%);
}
.i18n-message-tree__namespace-row[data-selected] {
background: color-mix(in srgb, AccentColor 14%, Canvas);
color: color-mix(in srgb, AccentColor 82%, CanvasText);
}
.i18n-message-panel button.i18n-message-tree__disclosure {
width: 1.65rem;
min-height: 1.9rem;
padding: 0;
border: 0;
border-radius: 0.4rem;
outline: none;
background: transparent;
}
.i18n-message-panel button.i18n-message-tree__disclosure:focus-visible,
.i18n-message-panel button.i18n-message-tree__namespace:focus-visible {
box-shadow: inset 0 0 0 2px AccentColor;
}
.i18n-message-panel button.i18n-message-tree__namespace {
display: flex;
width: 100%;
min-width: 0;
min-height: 2.15rem;
align-items: center;
justify-content: flex-start;
gap: 0.45rem;
padding: 0.35rem 0.5rem 0.35rem 0.2rem;
border: 0;
border-radius: 0.5rem;
outline: none;
background: transparent;
text-align: start;
}
.i18n-message-panel button.i18n-message-tree__namespace[data-selectable] {
font-weight: 600;
}
.i18n-message-tree__namespace > span {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.i18n-message-tree__namespace small {
margin-inline-start: auto;
color: var(--i18n-muted);
font-variant-numeric: tabular-nums;
font-size: 0.6875rem;
}
.i18n-message-tree__namespace > svg {
width: 0.9rem;
height: 0.9rem;
flex: 0 0 0.9rem;
color: var(--i18n-muted);
}
.i18n-message-tree__chevron {
width: 0.9rem;
height: 0.9rem;
color: var(--i18n-muted);
transition: rotate 120ms ease;
}
.i18n-message-tree__disclosure[aria-expanded="false"]
.i18n-message-tree__chevron {
rotate: -90deg;
}
.i18n-message-tree__disclosure[aria-expanded="false"]:dir(rtl)
.i18n-message-tree__chevron {
rotate: 90deg;
}
.i18n-message {
display: grid;
gap: 0.875rem;
padding: 1rem;
gap: 0.65rem;
overflow: visible;
padding: 0.875rem;
border: 1px solid var(--i18n-border);
border-radius: 0.875rem;
border-radius: 0.75rem;
background: var(--i18n-surface);
transition:
border-color 120ms ease,
box-shadow 120ms ease,
background-color 120ms ease;
}
.i18n-message:not(:has(.i18n-message-copy__trigger[aria-expanded="true"])) {
content-visibility: auto;
contain-intrinsic-block-size: 13rem;
}
.i18n-message:hover {
border-color: color-mix(in srgb, currentColor 22%, transparent);
}
.i18n-message:focus-within {
border-color: color-mix(in srgb, AccentColor 55%, var(--i18n-border));
box-shadow: 0 0 0 3px color-mix(in srgb, AccentColor 10%, transparent);
}
.i18n-message[data-dirty] {
border-color: color-mix(in srgb, AccentColor 42%, var(--i18n-border));
}
.i18n-message[data-missing] {
@@ -108,6 +564,210 @@
opacity: 0.65;
}
.i18n-message__source-toolbar {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin: -0.875rem -0.875rem 0;
padding: 0.3rem 0.5rem;
border-bottom: 1px solid var(--i18n-border);
border-radius: 0.75rem 0.75rem 0 0;
background: color-mix(in srgb, Canvas 88%, currentColor 12%);
}
.i18n-message__source-location {
display: flex;
min-width: 0;
align-items: center;
gap: 0.4rem;
color: var(--i18n-muted);
}
.i18n-message__source-location > svg {
width: 1rem;
height: 1rem;
flex: 0 0 1rem;
}
.i18n-message__source-location > code {
min-width: 0;
overflow: hidden;
font-size: 0.75rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.i18n-message__source-count {
display: inline-grid;
min-width: 1.35rem;
height: 1.25rem;
flex: none;
place-items: center;
padding: 0 0.3rem;
border-radius: 999px;
background: color-mix(in srgb, Canvas 78%, currentColor 22%);
color: var(--i18n-muted);
font-variant-numeric: tabular-nums;
font-size: 0.6875rem;
}
.i18n-message-copy {
position: relative;
flex: none;
}
.i18n-message-panel button.i18n-message-copy__trigger {
width: auto;
min-height: 1.625rem;
gap: 0.15rem;
padding: 0 0.25rem;
border: 0;
background: transparent;
}
.i18n-message-panel button.i18n-message-copy__trigger:hover:not(:disabled),
.i18n-message-panel button.i18n-message-copy__trigger[aria-expanded="true"] {
background: transparent;
color: color-mix(in srgb, currentColor 78%, AccentColor 22%);
}
.i18n-message-copy__trigger[data-state="copied"] {
color: #16a34a;
}
.i18n-message-copy__trigger[data-state="error"] {
color: #dc2626;
}
.i18n-message-copy__chevron {
width: 0.75rem;
height: 0.75rem;
transition: rotate 120ms ease;
}
.i18n-message-copy__trigger[aria-expanded="true"] .i18n-message-copy__chevron {
rotate: 180deg;
}
.i18n-message-copy__menu {
position: absolute;
z-index: 10;
top: calc(100% + 0.35rem);
right: 0;
display: grid;
width: min(18rem, calc(100vw - 4rem));
padding: 0.3rem;
border: 1px solid var(--i18n-border);
border-radius: 0.65rem;
background: Canvas;
box-shadow:
0 0.75rem 2.5rem rgb(0 0 0 / 14%),
0 0.125rem 0.5rem rgb(0 0 0 / 8%);
}
.i18n-message-panel .i18n-message-copy__menu > button {
width: 100%;
min-height: 2.25rem;
justify-content: flex-start;
padding: 0.45rem 0.55rem;
border: 0;
background: transparent;
text-align: start;
}
.i18n-message-panel .i18n-message-copy__menu > button:hover:not(:disabled),
.i18n-message-panel .i18n-message-copy__menu > button:focus-visible {
outline: 2px solid AccentColor;
outline-offset: -2px;
background: color-mix(in srgb, Canvas 88%, currentColor 12%);
}
.i18n-message-panel .i18n-message-copy__menu > button:hover:not(:disabled) {
outline-color: transparent;
}
.i18n-message-copy__menu > button:has(code) {
display: grid;
justify-items: start;
gap: 0.1rem;
}
.i18n-message-copy__menu button code {
display: block;
width: 100%;
overflow: hidden;
color: var(--i18n-muted);
font-size: 0.6875rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.i18n-message-copy__separator {
height: 1px;
margin: 0.3rem -0.3rem;
background: var(--i18n-border);
}
.i18n-message-copy__label {
padding: 0.35rem 0.55rem 0.2rem;
color: var(--i18n-muted);
font-size: 0.6875rem;
font-weight: 650;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.i18n-message-copy__feedback {
position: absolute;
z-index: 11;
top: calc(100% + 0.35rem);
right: 0;
display: inline-flex;
min-height: 1.875rem;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.55rem;
border: 1px solid var(--i18n-border);
border-radius: 0.5rem;
background: Canvas;
box-shadow:
0 0.5rem 1.5rem rgb(0 0 0 / 12%),
0 0.125rem 0.375rem rgb(0 0 0 / 7%);
color: var(--i18n-muted);
font-size: 0.75rem;
font-weight: 600;
pointer-events: none;
white-space: nowrap;
animation: i18n-message-copy-feedback-in 140ms ease-out;
}
.i18n-message-copy__feedback[data-state="copied"] {
color: #15803d;
}
.i18n-message-copy__feedback[data-state="error"] {
color: #dc2626;
}
.i18n-message-copy__feedback > svg {
width: 0.875rem;
height: 0.875rem;
}
@keyframes i18n-message-copy-feedback-in {
from {
opacity: 0;
transform: translateY(-0.2rem) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.i18n-message__header,
.i18n-message__actions,
.i18n-message__meta {
@@ -118,20 +778,31 @@
.i18n-message__header {
justify-content: space-between;
min-width: 0;
}
.i18n-message__id {
min-width: 0;
overflow-wrap: anywhere;
color: color-mix(in srgb, CanvasText 88%, transparent);
font-size: 0.8125rem;
}
.i18n-message__catalog {
color: var(--i18n-muted);
font-size: 0.75rem;
.i18n-message__content {
display: grid;
grid-template-columns: minmax(10rem, 0.7fr) minmax(14rem, 1.3fr);
gap: 0.75rem;
align-items: start;
}
.i18n-message__source {
min-width: 0;
padding: 0.45rem 0.25rem;
}
.i18n-message__source p {
margin: 0.25rem 0 0;
margin: 0.3rem 0 0;
overflow-wrap: anywhere;
line-height: 1.5;
}
@@ -141,6 +812,9 @@
}
.i18n-message__label {
display: inline-flex;
align-items: baseline;
gap: 0.4rem;
color: var(--i18n-muted);
font-size: 0.75rem;
font-weight: 600;
@@ -148,24 +822,65 @@
text-transform: uppercase;
}
.i18n-message__label code {
padding: 0.05rem 0.3rem;
border-radius: 0.25rem;
background: color-mix(in srgb, Canvas 80%, currentColor 20%);
color: color-mix(in srgb, CanvasText 76%, transparent);
font-size: 0.6875rem;
font-weight: 500;
letter-spacing: normal;
text-transform: none;
}
.i18n-message__meta {
min-width: 0;
flex-wrap: wrap;
color: var(--i18n-muted);
font-size: 0.75rem;
}
.i18n-message__meta span {
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.i18n-message__actions {
justify-content: flex-end;
min-height: 2.25rem;
}
.i18n-message__status {
margin-inline-end: auto;
color: var(--i18n-muted);
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
}
.i18n-message__status[data-state="saved"] {
color: #16a34a;
}
.i18n-message__status[data-state="error"] {
color: #dc2626;
}
.i18n-message__status svg {
width: 0.875rem;
height: 0.875rem;
}
.i18n-message-panel button {
display: inline-flex;
min-height: 2.25rem;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0 0.8rem;
border: 1px solid var(--i18n-border);
border-radius: 0.55rem;
@@ -173,10 +888,29 @@
cursor: pointer;
}
.i18n-message-panel button svg {
width: 0.95rem;
height: 0.95rem;
}
.i18n-message-panel button:hover:not(:disabled) {
background: color-mix(in srgb, Canvas 90%, currentColor 10%);
}
.i18n-message-panel button.i18n-message__save {
border-color: var(--i18n-devtool-primary, #2563eb);
background: var(--i18n-devtool-primary, #2563eb);
color: var(--i18n-devtool-panel, #fff);
}
.i18n-message-panel button.i18n-message__save:hover:not(:disabled) {
background: color-mix(
in srgb,
var(--i18n-devtool-primary, #2563eb) 86%,
black
);
}
.i18n-message-panel button:disabled {
cursor: default;
opacity: 0.45;
@@ -215,4 +949,33 @@
.i18n-message-panel__search {
flex-basis: 100%;
}
.i18n-message-package__counts span {
padding-inline: 0.35rem;
}
.i18n-message__content {
grid-template-columns: minmax(0, 1fr);
gap: 0.5rem;
}
.i18n-message__source {
padding-bottom: 0.1rem;
}
}
@media (max-width: 52rem) {
.i18n-message-workspace {
grid-template-columns: minmax(0, 1fr);
}
.i18n-message-navigation {
max-height: 18rem;
border-right: 0;
border-bottom: 1px solid var(--i18n-border);
}
.i18n-message-workspace__editor {
min-height: 20rem;
}
}
@@ -1,8 +1,15 @@
// @vitest-environment jsdom
import * as React from "react"
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react"
import { afterEach, describe, expect, it, vi } from "vitest"
import { MessagePanel } from "./message-panel"
import type { DevtoolMessage, MessageRepository } from "./types"
@@ -10,14 +17,23 @@ import type { DevtoolMessage, MessageRepository } from "./types"
const message: DevtoolMessage = {
catalog: "messages",
comments: ["Navigation title"],
editable: true,
id: "navigation.home",
missing: true,
obsolete: false,
origins: [{ file: "src/navigation.tsx", line: 12 }],
packageName: "@workspace/ui",
source: "Home",
sourceLocale: "en",
translation: "",
}
afterEach(() => {
cleanup()
vi.restoreAllMocks()
Reflect.deleteProperty(window.navigator, "clipboard")
})
describe("MessagePanel", () => {
it("loads, filters, and saves messages through the repository", async () => {
const updateMessage = vi.fn(async (input) => ({
@@ -33,10 +49,13 @@ describe("MessagePanel", () => {
render(<MessagePanel locale="zh-Hans" repository={repository} />)
expect(await screen.findByText("navigation.home")).toBeTruthy()
expect(screen.getByRole("region", { name: "@workspace/ui" })).toBeTruthy()
const textarea = screen.getByLabelText(
"navigation.home translation for zh-Hans"
)
expect(screen.queryByRole("button", { name: "Save" })).toBeNull()
fireEvent.change(textarea, { target: { value: "首页" } })
fireEvent.click(screen.getByRole("button", { name: "Save" }))
@@ -50,11 +69,323 @@ describe("MessagePanel", () => {
})
expect(await screen.findByText("Saved")).toBeTruthy()
const missingOnly = screen.getByRole("checkbox", {
name: "Missing only",
})
const checkboxIcon = missingOnly.nextElementSibling
expect(checkboxIcon?.querySelector("svg")).toBeTruthy()
fireEvent.click(missingOnly)
expect(missingOnly).toHaveProperty("checked", true)
fireEvent.change(screen.getByPlaceholderText("Search messages"), {
target: { value: "does not exist" },
})
expect(screen.getByText("No messages match your search.")).toBeTruthy()
})
it("keeps the current content visible during a background refresh", async () => {
let resolveRefresh:
((messages: readonly DevtoolMessage[]) => void) | undefined
const repository: MessageRepository = {
getMessages: vi
.fn()
.mockResolvedValueOnce([message])
.mockImplementationOnce(
() =>
new Promise<readonly DevtoolMessage[]>((resolve) => {
resolveRefresh = resolve
})
),
updateMessage: vi.fn(),
}
const view = render(
<MessagePanel locale="zh-Hans" refreshToken={0} repository={repository} />
)
expect(await screen.findByText("navigation.home")).toBeTruthy()
fireEvent.change(screen.getByPlaceholderText("Search messages"), {
target: { value: "home" },
})
view.rerender(
<MessagePanel locale="zh-Hans" refreshToken={1} repository={repository} />
)
expect(screen.queryByText("Loading zh-Hans…")).toBeNull()
expect(screen.getByText("navigation.home")).toBeTruthy()
expect(screen.getByPlaceholderText("Search messages")).toHaveProperty(
"value",
"home"
)
await act(async () => {
resolveRefresh?.([])
})
expect(
screen.getByText("No messages match the current filters.")
await screen.findByText(
"No messages are available yet. Extract the project to populate this catalog."
)
).toBeTruthy()
})
it("collapses a package without discarding an unsaved translation", async () => {
const repository: MessageRepository = {
getMessages: vi.fn(async () => [message]),
updateMessage: vi.fn(),
}
render(<MessagePanel locale="zh-Hans" repository={repository} />)
const textarea = await screen.findByLabelText(
"navigation.home translation for zh-Hans"
)
fireEvent.change(textarea, { target: { value: "未保存的首页" } })
const collapseButton = screen.getByRole("button", {
name: "Collapse @workspace/ui",
})
const packageContent = screen
.getByRole("region", { name: "@workspace/ui" })
.querySelector(".i18n-message-package__messages")
fireEvent.click(collapseButton)
expect(collapseButton.getAttribute("aria-expanded")).toBe("false")
expect(textarea).toHaveProperty("value", "未保存的首页")
expect(packageContent).toHaveProperty("hidden", true)
fireEvent.click(
screen.getByRole("button", {
name: "Expand @workspace/ui",
})
)
expect(textarea).toHaveProperty("value", "未保存的首页")
expect(packageContent).toHaveProperty("hidden", false)
})
it("shows the application package even when it has no messages", async () => {
const repository: MessageRepository = {
getMessages: vi.fn(async () => [message]),
getPackages: vi.fn(async () => [
{
kind: "application" as const,
name: "web",
},
]),
updateMessage: vi.fn(),
}
render(<MessagePanel locale="zh-Hans" repository={repository} />)
expect(await screen.findByRole("region", { name: "web" })).toBeTruthy()
expect(
screen
.getAllByRole("region")
.map((region) => region.getAttribute("aria-label"))
.filter((label) => label !== "Message editor")
).toEqual(["web", "@workspace/ui"])
expect(
screen.getByText("No messages were found in this package.")
).toBeTruthy()
expect(screen.getByRole("region", { name: "web" }).textContent).toContain(
"0 messages"
)
})
it("builds an ID tree and preserves visited editor drafts", async () => {
const activeMessage: DevtoolMessage = {
...message,
id: "blocks.appearance.active",
source: "Active",
}
const darkMessage: DevtoolMessage = {
...message,
id: "blocks.theme.dark",
source: "Dark",
}
const repository: MessageRepository = {
getMessages: vi.fn(async () => [activeMessage, darkMessage]),
updateMessage: vi.fn(),
}
render(<MessagePanel locale="zh-Hans" repository={repository} />)
const activeEditor = await screen.findByLabelText(
"blocks.appearance.active translation for zh-Hans"
)
expect(screen.getByTitle("blocks")).toBeTruthy()
expect(screen.getByTitle("blocks.appearance")).toBeTruthy()
expect(screen.getByTitle("blocks.theme")).toBeTruthy()
expect(screen.queryByTitle("blocks.appearance.active")).toBeNull()
expect(screen.queryByTitle("blocks.theme.dark")).toBeNull()
fireEvent.change(activeEditor, { target: { value: "使用中" } })
fireEvent.click(screen.getByTitle("blocks.theme"))
expect(
await screen.findByLabelText("blocks.theme.dark translation for zh-Hans")
).toBeTruthy()
fireEvent.click(screen.getByTitle("blocks.appearance"))
expect(
await screen.findByLabelText(
"blocks.appearance.active translation for zh-Hans"
)
).toHaveProperty("value", "使用中")
})
it("uses the final ID segment as right-pane content instead of a tree leaf", async () => {
const parentMessage: DevtoolMessage = {
...message,
id: "a.b",
source: "B",
}
const childMessage: DevtoolMessage = {
...message,
id: "a.b.c",
source: "C",
}
const repository: MessageRepository = {
getMessages: vi.fn(async () => [parentMessage, childMessage]),
updateMessage: vi.fn(),
}
render(<MessagePanel locale="zh-Hans" repository={repository} />)
expect(
await screen.findByLabelText("a.b translation for zh-Hans")
).toBeTruthy()
expect(screen.getByTitle("a")).toBeTruthy()
expect(screen.getByTitle("a.b")).toBeTruthy()
expect(screen.queryByTitle("a.b.c")).toBeNull()
expect(screen.queryByLabelText("a.b.c translation for zh-Hans")).toBeNull()
fireEvent.click(screen.getByTitle("a.b"))
expect(
await screen.findByLabelText("a.b.c translation for zh-Hans")
).toBeTruthy()
})
it("resets an unsaved translation to the repository value", async () => {
const updateMessage = vi.fn()
const repository: MessageRepository = {
getMessages: vi.fn(async () => [message]),
updateMessage,
}
render(<MessagePanel locale="zh-Hans" repository={repository} />)
const textarea = await screen.findByLabelText(
"navigation.home translation for zh-Hans"
)
expect(screen.getByText("src/navigation.tsx:12")).toBeTruthy()
fireEvent.change(textarea, { target: { value: "未保存的首页" } })
fireEvent.click(screen.getByRole("button", { name: "Reset" }))
expect(textarea).toHaveProperty("value", "")
expect(screen.queryByRole("button", { name: "Save" })).toBeNull()
expect(updateMessage).not.toHaveBeenCalled()
})
it("keeps application source messages read-only", async () => {
const updateMessage = vi.fn()
const repository: MessageRepository = {
getMessages: vi.fn(async () => [
{
...message,
editable: false,
missing: false,
packageName: "test-app",
translation: "Home",
},
]),
getPackages: vi.fn(async () => [
{ kind: "application" as const, name: "test-app" },
]),
updateMessage,
}
render(<MessagePanel locale="en" repository={repository} />)
const textarea = await screen.findByLabelText(
"navigation.home translation for en"
)
expect(textarea).toHaveProperty("readOnly", true)
fireEvent.change(textarea, { target: { value: "Customized home" } })
expect(textarea).toHaveProperty("value", "Home")
expect(screen.queryByRole("button", { name: "Save" })).toBeNull()
expect(updateMessage).not.toHaveBeenCalled()
})
it("copies an explicitly selected message value", async () => {
const writeText = vi.fn(async () => undefined)
Object.defineProperty(window.navigator, "clipboard", {
configurable: true,
value: { writeText },
})
const repository: MessageRepository = {
getMessages: vi.fn(async () => [message]),
updateMessage: vi.fn(),
}
render(<MessagePanel locale="zh-Hans" repository={repository} />)
await screen.findByText("navigation.home")
fireEvent.click(
screen.getByRole("button", {
name: "Choose content to copy",
})
)
expect(
screen.getByRole("menuitem", { name: "Copy current translation" })
).toHaveProperty("disabled", true)
expect(
screen.getByRole("menuitem", {
name: /Copy source location/,
})
).toBeTruthy()
fireEvent.click(
screen.getByRole("menuitem", {
name: "Copy message ID",
})
)
await waitFor(() => {
expect(writeText).toHaveBeenCalledWith("navigation.home")
})
expect(screen.getByRole("button", { name: "Copied" })).toBeTruthy()
expect(screen.queryByRole("menu")).toBeNull()
expect(screen.getByRole("status").textContent).toContain("Copied")
})
it("distinguishes an empty catalog from a fully translated catalog", async () => {
const translatedMessage = {
...message,
missing: false,
translation: "首页",
}
const repository: MessageRepository = {
getMessages: vi.fn(async () => [translatedMessage]),
updateMessage: vi.fn(),
}
render(<MessagePanel locale="zh-Hans" repository={repository} />)
expect(await screen.findByText("navigation.home")).toBeTruthy()
fireEvent.click(
screen.getByRole("checkbox", {
name: "Missing only",
})
)
expect(screen.getByText("All messages have translations.")).toBeTruthy()
})
})
+410 -105
View File
@@ -1,17 +1,40 @@
import * as React from "react"
import {
CircleAlertIcon,
CheckIcon,
FileCode2Icon,
RotateCcwIcon,
SaveIcon,
SearchIcon,
} from "lucide-react"
import { useDevtoolLocalization } from "./devtool-localization"
import { formatMessageOrigin, MessageCopyMenu } from "./message-copy-menu"
import {
createMessageNavigationModel,
MessageNavigation,
} from "./message-navigation"
import { useOptionalMessageRepository } from "./message-repository"
import type { DevtoolMessage, MessageRepository } from "./types"
import type {
DevtoolMessage,
DevtoolMessagePackage,
MessageRepository,
} from "./types"
import "./message-panel.css"
type LoadState =
| { status: "loading" }
| { error: Error; status: "error" }
| { messages: readonly DevtoolMessage[]; status: "ready" }
| {
messages: readonly DevtoolMessage[]
packages: readonly DevtoolMessagePackage[]
status: "ready"
}
export interface MessagePanelProps {
className?: string
locale: string
refreshToken?: unknown
repository?: MessageRepository
}
@@ -20,6 +43,7 @@ function getMessageSearchText(message: DevtoolMessage) {
message.id,
message.source,
message.translation,
message.packageName,
...message.comments,
...message.origins.map((origin) => origin.file),
]
@@ -36,14 +60,23 @@ function MessageEditor({
message: DevtoolMessage
onSave: (translation: string) => Promise<void>
}) {
const { messages } = useDevtoolLocalization()
const [translation, setTranslation] = React.useState(message.translation)
const [saveState, setSaveState] = React.useState<
"idle" | "saving" | "saved" | "error"
>("idle")
const repositoryTranslationRef = React.useRef(message.translation)
const submittedTranslationRef = React.useRef<string | undefined>(undefined)
const isEditable = message.editable
const isDirty = translation !== message.translation
const primaryOrigin = message.origins[0]
React.useEffect(() => {
if (repositoryTranslationRef.current === message.translation) {
return
}
repositoryTranslationRef.current = message.translation
setTranslation(message.translation)
if (submittedTranslationRef.current === message.translation) {
@@ -54,8 +87,17 @@ function MessageEditor({
setSaveState("idle")
}, [message.translation])
React.useEffect(() => {
if (saveState !== "saved") {
return
}
const timeout = window.setTimeout(() => setSaveState("idle"), 1600)
return () => window.clearTimeout(timeout)
}, [saveState])
const handleSave = async () => {
if (!isDirty || saveState === "saving") {
if (!isEditable || !isDirty || saveState === "saving") {
return
}
@@ -71,69 +113,152 @@ function MessageEditor({
}
}
const handleReset = () => {
submittedTranslationRef.current = undefined
setTranslation(message.translation)
setSaveState("idle")
}
return (
<article
className="i18n-message"
data-dirty={isDirty || undefined}
data-missing={message.missing || undefined}
data-obsolete={message.obsolete || undefined}
data-readonly={!isEditable || undefined}
data-save-state={saveState}
>
<header className="i18n-message__header">
<code className="i18n-message__id">{message.id}</code>
<span className="i18n-message__catalog">{message.catalog}</span>
<header className="i18n-message__source-toolbar">
<div
className="i18n-message__source-location"
title={
primaryOrigin
? `${primaryOrigin.file}${primaryOrigin.line ? `:${primaryOrigin.line}` : ""}`
: messages.messagePanel.unknownSourceLocation
}
>
<FileCode2Icon aria-hidden="true" />
<code>
{primaryOrigin
? formatMessageOrigin(primaryOrigin)
: messages.messagePanel.unknownSourceLocation}
</code>
{message.origins.length > 1 && (
<span
aria-label={messages.messagePanel.additionalSourceLocations(
message.origins.length - 1
)}
className="i18n-message__source-count"
>
+{message.origins.length - 1}
</span>
)}
</div>
<MessageCopyMenu message={message} translation={translation} />
</header>
<div className="i18n-message__source">
<span className="i18n-message__label">Source</span>
<p>{message.source || message.id}</p>
<div className="i18n-message__header">
<code className="i18n-message__id">{message.id}</code>
</div>
<label className="i18n-message__field">
<span className="i18n-message__label">{locale}</span>
<textarea
aria-label={`${message.id} translation for ${locale}`}
value={translation}
onChange={(event) => {
submittedTranslationRef.current = undefined
setTranslation(event.target.value)
setSaveState("idle")
}}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault()
void handleSave()
}
}}
/>
</label>
<div className="i18n-message__content">
<div className="i18n-message__source">
<span className="i18n-message__label">
{messages.messagePanel.source}
<code>{message.sourceLocale}</code>
</span>
<p dir="auto" lang={message.sourceLocale}>
{message.source || message.id}
</p>
</div>
{(message.comments.length > 0 || message.origins.length > 0) && (
<label className="i18n-message__field">
<span className="i18n-message__label">
{messages.messagePanel.translation}
<code>{locale}</code>
</span>
<textarea
aria-label={messages.messagePanel.translationFor(
message.id,
locale
)}
dir="auto"
lang={locale}
rows={1}
readOnly={!isEditable}
value={translation}
onChange={(event) => {
if (!isEditable) {
return
}
submittedTranslationRef.current = undefined
setTranslation(event.target.value)
setSaveState("idle")
}}
onKeyDown={(event) => {
if (
isEditable &&
(event.metaKey || event.ctrlKey) &&
event.key === "Enter"
) {
event.preventDefault()
void handleSave()
}
}}
/>
</label>
</div>
{message.comments.length > 0 && (
<footer className="i18n-message__meta">
{message.comments.map((comment) => (
<span key={comment}>{comment}</span>
))}
{message.origins.map((origin) => (
<span key={`${origin.file}:${origin.line ?? ""}`}>
{origin.file}
{origin.line ? `:${origin.line}` : ""}
</span>
))}
</footer>
)}
<div className="i18n-message__actions">
<span aria-live="polite" className="i18n-message__status">
{saveState === "saving" && "Saving…"}
{saveState === "saved" && "Saved"}
{saveState === "error" && "Could not save"}
</span>
<button
type="button"
disabled={!isDirty || saveState === "saving"}
onClick={() => void handleSave()}
>
Save
</button>
</div>
{(isDirty || saveState !== "idle") && (
<div className="i18n-message__actions">
<span
aria-live="polite"
className="i18n-message__status"
data-state={saveState}
>
{saveState === "saved" && (
<>
<CheckIcon aria-hidden="true" />
{messages.messagePanel.saved}
</>
)}
{saveState === "error" && (
<>
<CircleAlertIcon aria-hidden="true" />
{messages.messagePanel.couldNotSave}
</>
)}
</span>
{isDirty && (
<>
<button type="button" onClick={handleReset}>
<RotateCcwIcon aria-hidden="true" />
{messages.messagePanel.reset}
</button>
<button
type="button"
className="i18n-message__save"
disabled={saveState === "saving"}
onClick={() => void handleSave()}
>
<SaveIcon aria-hidden="true" />
{saveState === "saving"
? messages.messagePanel.saving
: messages.messagePanel.save}
</button>
</>
)}
</div>
)}
</article>
)
}
@@ -141,57 +266,82 @@ function MessageEditor({
export function MessagePanel({
className,
locale,
refreshToken,
repository: repositoryProp,
}: MessagePanelProps) {
const repositoryFromContext = useOptionalMessageRepository()
const repository = repositoryProp ?? repositoryFromContext
const { messages } = useDevtoolLocalization()
const [state, setState] = React.useState<LoadState>({ status: "loading" })
const [query, setQuery] = React.useState("")
const [missingOnly, setMissingOnly] = React.useState(false)
const [selectedNavigationKey, setSelectedNavigationKey] =
React.useState<string>()
const [openedNavigationKeys, setOpenedNavigationKeys] = React.useState<
readonly string[]
>([])
const loadRequestRef = React.useRef(0)
const previousLoadContextRef = React.useRef<{
locale: string
repository: MessageRepository | null
} | null>(null)
const loadMessages = React.useCallback(async () => {
const requestId = loadRequestRef.current + 1
loadRequestRef.current = requestId
const loadMessages = React.useCallback(
async ({ preserveContent = false } = {}) => {
const requestId = loadRequestRef.current + 1
loadRequestRef.current = requestId
if (!repository) {
setState({
error: new Error(
"MessagePanel requires a MessageRepositoryProvider or a repository prop."
),
status: "error",
})
return
}
setState({ status: "loading" })
try {
const messages = await repository.getMessages(locale)
if (requestId !== loadRequestRef.current) {
if (!repository) {
setState({
error: new Error(
"MessagePanel requires a MessageRepositoryProvider or a repository prop."
),
status: "error",
})
return
}
setState({ messages, status: "ready" })
} catch (error) {
if (requestId !== loadRequestRef.current) {
return
if (!preserveContent) {
setState({ status: "loading" })
}
setState({
error:
error instanceof Error
? error
: new Error("Unable to load messages."),
status: "error",
})
}
}, [locale, repository])
try {
const [loadedMessages, packages] = await Promise.all([
repository.getMessages(locale),
repository.getPackages?.() ?? Promise.resolve([]),
])
if (requestId !== loadRequestRef.current) {
return
}
setState({ messages: loadedMessages, packages, status: "ready" })
} catch (error) {
if (requestId !== loadRequestRef.current) {
return
}
setState({
error:
error instanceof Error
? error
: new Error("Unable to load messages."),
status: "error",
})
}
},
[locale, repository]
)
React.useEffect(() => {
void loadMessages()
}, [loadMessages])
const previousLoadContext = previousLoadContextRef.current
const preserveContent =
previousLoadContext?.locale === locale &&
previousLoadContext.repository === repository
previousLoadContextRef.current = { locale, repository }
void loadMessages({ preserveContent })
}, [loadMessages, locale, refreshToken, repository])
const visibleMessages = React.useMemo(() => {
if (state.status !== "ready") {
@@ -211,6 +361,92 @@ export function MessagePanel({
)
})
}, [missingOnly, query, state])
const visiblePackageGroups = React.useMemo(() => {
const groups = new Map<
string,
{
kind: DevtoolMessagePackage["kind"]
messages: DevtoolMessage[]
}
>()
if (state.status === "ready" && query.trim().length === 0 && !missingOnly) {
for (const packageDefinition of state.packages) {
groups.set(packageDefinition.name, {
kind: packageDefinition.kind,
messages: [],
})
}
}
for (const message of visibleMessages) {
const group = groups.get(message.packageName)
if (group) {
group.messages.push(message)
} else {
groups.set(message.packageName, {
kind: "dependency",
messages: [message],
})
}
}
return Array.from(groups, ([packageName, group]) => ({
kind: group.kind,
messages: group.messages,
missingCount: group.messages.filter((message) => message.missing).length,
packageName,
})).sort(
(left, right) =>
Number(right.kind === "application") -
Number(left.kind === "application") ||
left.packageName.localeCompare(right.packageName)
)
}, [missingOnly, query, state, visibleMessages])
const navigationModel = React.useMemo(
() => createMessageNavigationModel(visiblePackageGroups),
[visiblePackageGroups]
)
const navigationSelections = React.useMemo(
() => Array.from(navigationModel.selections.values()),
[navigationModel]
)
const activeSelection =
(selectedNavigationKey
? navigationModel.selections.get(selectedNavigationKey)
: undefined) ??
navigationSelections.find((selection) => selection.messages.length > 0) ??
navigationSelections[0]
const activeNavigationKey = activeSelection?.key
React.useEffect(() => {
if (!activeNavigationKey) {
return
}
setOpenedNavigationKeys((current) =>
current.includes(activeNavigationKey)
? current
: [...current, activeNavigationKey]
)
}, [activeNavigationKey])
const openedSelections = React.useMemo(() => {
const activeKeys =
activeNavigationKey && !openedNavigationKeys.includes(activeNavigationKey)
? [...openedNavigationKeys, activeNavigationKey]
: openedNavigationKeys
return activeKeys.flatMap((key) => {
const selection = navigationModel.selections.get(key)
return selection ? [selection] : []
})
}, [activeNavigationKey, navigationModel, openedNavigationKeys])
const missingMessageCount =
state.status === "ready"
? state.messages.filter((message) => message.missing).length
: 0
const updateMessage = React.useCallback(
async (message: DevtoolMessage, translation: string) => {
@@ -218,6 +454,10 @@ export function MessagePanel({
throw new Error("No message repository is available.")
}
if (!message.editable) {
throw new Error("This source message is read-only.")
}
const updatedMessage = await repository.updateMessage({
catalog: message.catalog,
id: message.id,
@@ -237,6 +477,7 @@ export function MessagePanel({
? updatedMessage
: item
),
packages: current.packages,
status: "ready",
}
})
@@ -247,18 +488,39 @@ export function MessagePanel({
const rootClassName = ["i18n-message-panel", className]
.filter(Boolean)
.join(" ")
const emptyStateMessage =
state.status !== "ready" || visibleMessages.length > 0
? undefined
: state.messages.length === 0
? messages.messagePanel.emptyCatalog
: query.trim().length > 0
? messages.messagePanel.noSearchResults
: missingOnly
? messages.messagePanel.noMissingMessages
: messages.messagePanel.noMatches
return (
<section className={rootClassName}>
<header className="i18n-message-panel__toolbar">
<label className="i18n-message-panel__search">
<span className="i18n-visually-hidden">Search messages</span>
<span className="i18n-visually-hidden">
{messages.messagePanel.search}
</span>
<SearchIcon aria-hidden="true" />
<input
type="search"
placeholder="Search messages"
placeholder={messages.messagePanel.search}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
{state.status === "ready" && (
<span
aria-hidden="true"
className="i18n-message-panel__result-count"
>
{visibleMessages.length}
</span>
)}
</label>
<label className="i18n-message-panel__toggle">
<input
@@ -266,40 +528,83 @@ export function MessagePanel({
checked={missingOnly}
onChange={(event) => setMissingOnly(event.target.checked)}
/>
Missing only
<span aria-hidden="true" className="i18n-message-panel__checkbox">
<CheckIcon />
</span>
<span>{messages.messagePanel.missingOnly}</span>
{state.status === "ready" && (
<span aria-hidden="true" className="i18n-message-panel__count">
{missingMessageCount}
</span>
)}
</label>
{state.status === "ready" && (
<output className="i18n-message-panel__count">
{visibleMessages.length} / {state.messages.length}
</output>
)}
</header>
{state.status === "loading" ? (
<div className="i18n-message-panel__state" role="status">
Loading {locale}
{messages.messagePanel.loading(locale)}
</div>
) : state.status === "error" ? (
<div className="i18n-message-panel__state" role="alert">
<p>{state.error.message}</p>
<button type="button" onClick={() => void loadMessages()}>
Retry
{messages.messagePanel.retry}
</button>
</div>
) : visibleMessages.length === 0 ? (
<div className="i18n-message-panel__state">
No messages match the current filters.
</div>
) : visiblePackageGroups.length === 0 ? (
<div className="i18n-message-panel__state">{emptyStateMessage}</div>
) : (
<div className="i18n-message-panel__list">
{visibleMessages.map((message) => (
<MessageEditor
key={`${message.catalog}:${message.id}`}
locale={locale}
message={message}
onSave={(translation) => updateMessage(message, translation)}
/>
))}
<div className="i18n-message-workspace">
<MessageNavigation
model={navigationModel}
selectedKey={activeNavigationKey}
onSelect={setSelectedNavigationKey}
/>
<section
aria-label={messages.messagePanel.messageEditor}
className="i18n-message-workspace__editor"
>
{activeSelection ? (
openedSelections.map((selection) => {
return (
<React.Activity
key={selection.key}
mode={
selection.key === activeNavigationKey
? "visible"
: "hidden"
}
name={selection.label}
>
{selection.messages.length > 0 ? (
<div className="i18n-message-workspace__editor-list">
{selection.messages.map((message) => (
<MessageEditor
key={`${message.catalog}:${message.id}`}
locale={locale}
message={message}
onSave={(translation) =>
updateMessage(message, translation)
}
/>
))}
</div>
) : (
<div className="i18n-message-workspace__empty">
{visibleMessages.length === 0
? messages.messagePanel.emptyPackage
: messages.messagePanel.selectMessage}
</div>
)}
</React.Activity>
)
})
) : (
<div className="i18n-message-workspace__empty">
{messages.messagePanel.selectMessage}
</div>
)}
</section>
</div>
)}
</section>
+9
View File
@@ -6,14 +6,22 @@ export interface MessageOrigin {
export interface DevtoolMessage {
catalog: string
comments: readonly string[]
editable: boolean
id: string
missing: boolean
obsolete: boolean
origins: readonly MessageOrigin[]
packageName: string
source: string
sourceLocale: string
translation: string
}
export interface DevtoolMessagePackage {
kind: "application" | "dependency"
name: string
}
export interface UpdateMessageInput {
catalog: string
id: string
@@ -23,5 +31,6 @@ export interface UpdateMessageInput {
export interface MessageRepository {
getMessages(locale: string): Promise<readonly DevtoolMessage[]>
getPackages?(): Promise<readonly DevtoolMessagePackage[]>
updateMessage(input: UpdateMessageInput): Promise<DevtoolMessage>
}
@@ -0,0 +1,451 @@
import * as React from "react"
type TriggerDockEdge = "bottom" | "left" | "right" | "top"
interface TriggerPosition {
x: number
y: number
}
interface TriggerDockPlacement {
edge: TriggerDockEdge
offset: number | null
}
interface TriggerDragSession {
height: number
lastPosition: TriggerPosition
longPressed: boolean
longPressTimer: number | null
moved: boolean
offsetX: number
offsetY: number
pointerId: number
previousDockPlacement: TriggerDockPlacement | null
previousPosition: TriggerPosition | null
startX: number
startY: number
width: number
}
interface StoredTriggerPlacement {
dockPlacement: TriggerDockPlacement | null
position: TriggerPosition | null
version: 1
}
interface UseFloatingTriggerOptions {
storageKey?: string
}
const TRIGGER_DEFAULT_INSET = 16
const TRIGGER_DOCK_THRESHOLD = 32
const TRIGGER_DRAG_THRESHOLD = 4
const TRIGGER_LONG_PRESS_DELAY = 400
const DEFAULT_STORAGE_KEY = "i18n-devtool:floating-trigger-placement"
const DEFAULT_PLACEMENT: StoredTriggerPlacement = {
dockPlacement: {
edge: "right",
offset: null,
},
position: null,
version: 1,
}
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(Math.max(value, minimum), Math.max(minimum, maximum))
}
function isDockEdge(value: unknown): value is TriggerDockEdge {
return (
value === "bottom" ||
value === "left" ||
value === "right" ||
value === "top"
)
}
function parseStoredPlacement(value: string | null) {
if (!value) {
return null
}
try {
const parsed = JSON.parse(value) as Partial<StoredTriggerPlacement>
const rawDockPlacement = parsed.dockPlacement
const rawPosition = parsed.position
const dockPlacement =
rawDockPlacement === null
? null
: rawDockPlacement &&
isDockEdge(rawDockPlacement.edge) &&
(rawDockPlacement.offset === null ||
(typeof rawDockPlacement.offset === "number" &&
Number.isFinite(rawDockPlacement.offset)))
? {
edge: rawDockPlacement.edge,
offset: rawDockPlacement.offset,
}
: undefined
const position =
rawPosition === null
? null
: rawPosition &&
typeof rawPosition.x === "number" &&
Number.isFinite(rawPosition.x) &&
typeof rawPosition.y === "number" &&
Number.isFinite(rawPosition.y)
? { x: rawPosition.x, y: rawPosition.y }
: undefined
if (
parsed.version !== 1 ||
dockPlacement === undefined ||
position === undefined
) {
return null
}
return {
dockPlacement,
position,
version: 1,
} satisfies StoredTriggerPlacement
} catch {
return null
}
}
function readStoredPlacement(storageKey: string) {
if (typeof window === "undefined") {
return null
}
try {
return parseStoredPlacement(window.localStorage.getItem(storageKey))
} catch {
return null
}
}
function resolveDockPlacement(
position: TriggerPosition,
width: number,
height: number
): TriggerDockPlacement | null {
const distances: readonly [TriggerDockEdge, number][] = [
["left", position.x],
["right", window.innerWidth - position.x - width],
["top", position.y],
["bottom", window.innerHeight - position.y - height],
]
const [edge, distance] = distances.reduce((nearest, candidate) =>
candidate[1] < nearest[1] ? candidate : nearest
)
if (distance > TRIGGER_DOCK_THRESHOLD) {
return null
}
return {
edge,
offset: edge === "left" || edge === "right" ? position.y : position.x,
}
}
function getTriggerStyle(
dockPlacement: TriggerDockPlacement | null,
position: TriggerPosition | null
): React.CSSProperties | undefined {
if (!dockPlacement) {
return position
? {
left: position.x,
top: position.y,
}
: undefined
}
const { edge, offset } = dockPlacement
if (edge === "left" || edge === "right") {
return {
[edge]: 0,
...(offset === null
? { bottom: TRIGGER_DEFAULT_INSET }
: { top: offset }),
}
}
return {
[edge]: 0,
...(offset === null ? { right: TRIGGER_DEFAULT_INSET } : { left: offset }),
}
}
export function useFloatingTrigger({
storageKey = DEFAULT_STORAGE_KEY,
}: UseFloatingTriggerOptions = {}) {
const [initialPlacement] = React.useState(
() => readStoredPlacement(storageKey) ?? DEFAULT_PLACEMENT
)
const triggerRef = React.useRef<HTMLButtonElement>(null)
const dragSessionRef = React.useRef<TriggerDragSession | null>(null)
const suppressClickRef = React.useRef(false)
const [dockPlacement, setDockPlacement] =
React.useState<TriggerDockPlacement | null>(initialPlacement.dockPlacement)
const [position, setPosition] = React.useState<TriggerPosition | null>(
initialPlacement.position
)
const [isDragging, setDragging] = React.useState(false)
const [isHovered, setHovered] = React.useState(false)
const [isFocused, setFocused] = React.useState(false)
React.useEffect(
() => () => {
const timer = dragSessionRef.current?.longPressTimer
if (timer !== null && timer !== undefined) {
window.clearTimeout(timer)
}
},
[]
)
React.useLayoutEffect(() => {
const keepTriggerInViewport = () => {
const trigger = triggerRef.current
if (!trigger || trigger.hidden) {
return
}
const { width, height } = trigger.getBoundingClientRect()
setPosition((current) =>
current
? {
x: clamp(current.x, 0, window.innerWidth - width),
y: clamp(current.y, 0, window.innerHeight - height),
}
: current
)
setDockPlacement((current) => {
if (!current || current.offset === null) {
return current
}
const maximumOffset =
current.edge === "left" || current.edge === "right"
? window.innerHeight - height
: window.innerWidth - width
const offset = clamp(current.offset, 0, maximumOffset)
return offset === current.offset ? current : { ...current, offset }
})
}
keepTriggerInViewport()
window.addEventListener("resize", keepTriggerInViewport)
return () => window.removeEventListener("resize", keepTriggerInViewport)
}, [])
React.useEffect(() => {
if (isDragging) {
return
}
try {
window.localStorage.setItem(
storageKey,
JSON.stringify({
dockPlacement,
position,
version: 1,
} satisfies StoredTriggerPlacement)
)
} catch {
// Storage can be unavailable in privacy-restricted browser contexts.
}
}, [dockPlacement, isDragging, position, storageKey])
const handlePointerDown = (event: React.PointerEvent<HTMLButtonElement>) => {
if (event.button !== 0) {
return
}
const trigger = event.currentTarget
const rect = trigger.getBoundingClientRect()
const nextPosition = { x: rect.left, y: rect.top }
const session: TriggerDragSession = {
height: rect.height,
lastPosition: nextPosition,
longPressed: false,
longPressTimer: null,
moved: false,
offsetX: event.clientX - rect.left,
offsetY: event.clientY - rect.top,
pointerId: event.pointerId,
previousDockPlacement: dockPlacement,
previousPosition: position,
startX: event.clientX,
startY: event.clientY,
width: rect.width,
}
session.longPressTimer = window.setTimeout(() => {
if (dragSessionRef.current !== session || session.moved) {
return
}
session.longPressed = true
session.longPressTimer = null
setDragging(true)
}, TRIGGER_LONG_PRESS_DELAY)
dragSessionRef.current = session
trigger.setPointerCapture(event.pointerId)
}
const handlePointerMove = (event: React.PointerEvent<HTMLButtonElement>) => {
const session = dragSessionRef.current
if (!session || session.pointerId !== event.pointerId) {
return
}
let startedDragging = false
if (
!session.moved &&
Math.hypot(
event.clientX - session.startX,
event.clientY - session.startY
) >= TRIGGER_DRAG_THRESHOLD
) {
session.moved = true
startedDragging = true
if (session.longPressTimer !== null) {
window.clearTimeout(session.longPressTimer)
session.longPressTimer = null
}
}
if (!session.moved) {
return
}
event.preventDefault()
const nextPosition = {
x: clamp(
event.clientX - session.offsetX,
0,
window.innerWidth - session.width
),
y: clamp(
event.clientY - session.offsetY,
0,
window.innerHeight - session.height
),
}
session.lastPosition = nextPosition
if (startedDragging) {
setDockPlacement(null)
setDragging(true)
}
setPosition(nextPosition)
}
const finishPointerInteraction = (
event: React.PointerEvent<HTMLButtonElement>,
canceled: boolean
) => {
const session = dragSessionRef.current
if (!session || session.pointerId !== event.pointerId) {
return
}
dragSessionRef.current = null
setDragging(false)
if (session.longPressTimer !== null) {
window.clearTimeout(session.longPressTimer)
}
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
if (canceled && session.moved) {
setDockPlacement(session.previousDockPlacement)
setPosition(session.previousPosition)
return
}
if (!session.moved) {
if (session.longPressed && !canceled) {
setFocused(false)
if (document.activeElement === event.currentTarget) {
event.currentTarget.blur()
}
suppressClickRef.current = true
window.setTimeout(() => {
suppressClickRef.current = false
}, 0)
}
return
}
setDockPlacement(
resolveDockPlacement(session.lastPosition, session.width, session.height)
)
setFocused(false)
if (document.activeElement === event.currentTarget) {
event.currentTarget.blur()
}
suppressClickRef.current = true
window.setTimeout(() => {
suppressClickRef.current = false
}, 0)
}
const isCollapsed =
dockPlacement !== null && !isDragging && !isHovered && !isFocused
return {
dockEdge: dockPlacement?.edge,
handleBlur: () => setFocused(false),
handleClick: () => {
if (suppressClickRef.current) {
suppressClickRef.current = false
return true
}
setFocused(false)
setHovered(false)
return false
},
handleFocus: () => setFocused(true),
handlePointerCancel: (event: React.PointerEvent<HTMLButtonElement>) =>
finishPointerInteraction(event, true),
handlePointerDown,
handlePointerEnter: () => setHovered(true),
handlePointerLeave: () => setHovered(false),
handlePointerMove,
handlePointerUp: (event: React.PointerEvent<HTMLButtonElement>) =>
finishPointerInteraction(event, false),
isCollapsed,
isDragging,
style: getTriggerStyle(dockPlacement, position),
triggerRef,
}
}