diff --git a/.changeset/connector-i18n.md b/.changeset/connector-i18n.md new file mode 100644 index 000000000..f98c1e449 --- /dev/null +++ b/.changeset/connector-i18n.md @@ -0,0 +1,12 @@ +--- +"@ckb-ccc/connector": minor +"@ckb-ccc/connector-react": minor +--- + +feat(connector): add localization support + +- New `locale` property on `` and `ccc.Provider`; English by default, `zh-Hans` built in. The value must be an exact built-in tag; anything else falls back to English. +- `ccc.connectorLocales` lists the built-in language tags at runtime. +- Fee rate options are selected by a stable id instead of their display label. +- Errors raised by the connector itself (camera access, Khie pairing) are translated at render time via `ConnectorError`. + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index be2768bdf..b9d3e3c67 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -129,6 +129,18 @@ To help your pull request get accepted, please consider the following: - Follow our existing style. - Write a good commit message. +### Contributing a connector language + +The connector UI (`@ckb-ccc/connector`) ships its translations in `packages/connector/src/i18n/locales/` as a flat key-value map. `en.ts` is the source of truth and `types.ts` documents the naming rules. To add a language: + +1. Copy `en.ts` to `.ts` (for example `ja-JP.ts`) and translate every value. Keep `{name}`-style placeholders and leave wallet / chain brand names untranslated. +2. Register it in `locales/index.ts`; `ConnectorLocale` (`keyof typeof locales`) picks it up automatically. Compilation fails if any key is missing or extra. +3. Run `pnpm test` in `packages/connector`; the locale test verifies the key set and placeholders match `en`. +4. Run `pnpm build` and `pnpm lint` in `packages/connector` to make sure it compiles and lints cleanly. +5. Verify in the app: run `pnpm dev` in `packages/app`, open the dev server in a browser, and check the language dropdown in the header — the new language shows up automatically because the list is built from `ccc.connectorLocales`, which is derived from the `locales` registry. Then select it and walk through the connect, connected, fee rate and Khie pairing dialogs to check for overflow. + +To fix a wrong translation, edit the value in the locale file directly; never reuse a key for a different meaning — add a new key instead. + ### Publishing a Canary Release (for maintainers) Maintainers can publish a canary release to npm by commenting `/canary` on a pull request. This will trigger a workflow that builds and publishes a new version to npm with the `canary` tag. diff --git a/packages/app/src/app/dropdown.module.css b/packages/app/src/app/dropdown.module.css new file mode 100644 index 000000000..94edcca64 --- /dev/null +++ b/packages/app/src/app/dropdown.module.css @@ -0,0 +1,140 @@ +/* Shares the visual language of the activity console: monospace uppercase + labels, hairline borders, gridded dark panel and the mechanical easing. */ + +.root { + position: relative; + display: inline-flex; +} + +.trigger { + display: flex; + min-height: 28px; + align-items: center; + gap: 6px; + padding: 0 9px; + border: 1px solid var(--line); + color: var(--muted); + background: transparent; + font: + 700 10px/1 "Courier New", + monospace; + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; + cursor: pointer; + transition: + border-color 160ms ease, + color 160ms ease; +} + +.trigger:hover, +.trigger:focus-visible, +.isOpen .trigger { + border-color: var(--signal); + color: var(--signal); +} + +.trigger:focus-visible { + outline: none; +} + +.trigger svg { + width: 14px; + height: 14px; + flex: 0 0 auto; + stroke-width: 2; +} + +.chevron { + transition: transform 300ms var(--easing-mechanical); +} + +.isOpen .chevron { + transform: rotate(180deg); +} + +.menu { + position: absolute; + z-index: 30; + top: calc(100% + 6px); + right: 0; + min-width: max(100%, 180px); + margin: 0; + padding: 6px 0; + border: 1px solid var(--line-dark); + background-color: rgb(17 24 28 / 98%); + background-image: + linear-gradient(90deg, rgb(230 238 242 / 2%) 1px, transparent 1px), + linear-gradient(rgb(230 238 242 / 2%) 1px, transparent 1px); + background-position: center; + background-size: 18px 18px; + list-style: none; + opacity: 0; + transform: translateY(-6px); + pointer-events: none; + transition: + opacity 160ms ease, + transform 300ms var(--easing-mechanical); +} + +.menu.alignStart { + right: auto; + left: 0; +} + +.isOpen .menu { + opacity: 1; + transform: none; + pointer-events: auto; +} + +.option { + display: grid; + min-height: 32px; + align-items: center; + grid-template-columns: 14px minmax(0, 1fr) auto; + gap: 10px; + padding: 7px 14px 7px 12px; + border-left: 2px solid transparent; + color: var(--ink); + font: + 400 11px/1.45 "Courier New", + monospace; + white-space: nowrap; + cursor: pointer; +} + +.option.isActive { + background: rgb(230 238 242 / 3%); +} + +.option.isSelected { + border-left-color: var(--signal); +} + +.check { + display: grid; + width: 14px; + height: 14px; + place-items: center; + color: var(--signal); +} + +.check svg { + width: 12px; + height: 12px; + stroke-width: 2.5; +} + +.optionLabel { + overflow: hidden; + text-overflow: ellipsis; +} + +.optionHint { + color: var(--muted); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} diff --git a/packages/app/src/app/dropdown.tsx b/packages/app/src/app/dropdown.tsx new file mode 100644 index 000000000..1f0ad6bbf --- /dev/null +++ b/packages/app/src/app/dropdown.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { Check, ChevronDown } from "lucide-react"; +import { + useCallback, + useEffect, + useId, + useRef, + useState, + type KeyboardEvent, + type ReactNode, +} from "react"; +import styles from "./dropdown.module.css"; + +export type DropdownOption = { + value: T; + label: string; + /** Small muted text shown at the right of the option, e.g. a code. */ + hint?: string; +}; + +export function Dropdown({ + align = "end", + className, + icon, + label, + onChange, + options, + value, +}: { + /** Which edge of the trigger the menu aligns to. */ + align?: "start" | "end"; + className?: string; + /** Optional leading icon rendered inside the trigger. */ + icon?: ReactNode; + /** Accessible name of the control; also used as the tooltip. */ + label: string; + onChange: (value: T) => void; + options: readonly DropdownOption[]; + value: T; +}) { + const id = useId(); + const rootRef = useRef(null); + const [open, setOpen] = useState(false); + const [active, setActive] = useState(() => + Math.max( + 0, + options.findIndex((option) => option.value === value), + ), + ); + const selected = options.find((option) => option.value === value); + + const close = useCallback(() => setOpen(false), []); + const openMenu = useCallback(() => { + setActive( + Math.max( + 0, + options.findIndex((option) => option.value === value), + ), + ); + setOpen(true); + }, [options, value]); + + const select = useCallback( + (index: number) => { + const option = options[index]; + if (option && option.value !== value) { + onChange(option.value); + } + close(); + }, + [close, onChange, options, value], + ); + + useEffect(() => { + if (!open) { + return; + } + const onPointerDown = (event: PointerEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + close(); + } + }; + document.addEventListener("pointerdown", onPointerDown); + return () => document.removeEventListener("pointerdown", onPointerDown); + }, [close, open]); + + const onKeyDown = (event: KeyboardEvent) => { + const last = options.length - 1; + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + if (!open) { + openMenu(); + } else { + setActive((index) => Math.min(last, index + 1)); + } + break; + case "ArrowUp": + event.preventDefault(); + if (!open) { + openMenu(); + } else { + setActive((index) => Math.max(0, index - 1)); + } + break; + case "Home": + if (open) { + event.preventDefault(); + setActive(0); + } + break; + case "End": + if (open) { + event.preventDefault(); + setActive(last); + } + break; + case "Enter": + case " ": + event.preventDefault(); + if (open) { + select(active); + } else { + openMenu(); + } + break; + case "Escape": + if (open) { + event.preventDefault(); + close(); + } + break; + case "Tab": + close(); + break; + } + }; + + return ( +
+ + +
    + {options.map((option, index) => { + const isSelected = option.value === value; + return ( +
  • setActive(index)} + onClick={() => select(index)} + > + + {option.label} + {option.hint ? ( + {option.hint} + ) : null} +
  • + ); + })} +
+
+ ); +} diff --git a/packages/app/src/app/header-links.module.css b/packages/app/src/app/header-links.module.css index 0dfe29b15..9bd306ac6 100644 --- a/packages/app/src/app/header-links.module.css +++ b/packages/app/src/app/header-links.module.css @@ -34,13 +34,3 @@ fill: currentColor; stroke: none; } - -@media (max-width: 680px) { - .root { - gap: 1px; - } - - .link { - width: 25px; - } -} diff --git a/packages/app/src/app/header-links.tsx b/packages/app/src/app/header-links.tsx index 6b7935525..c66645093 100644 --- a/packages/app/src/app/header-links.tsx +++ b/packages/app/src/app/header-links.tsx @@ -1,7 +1,9 @@ import type { ccc } from "@ckb-ccc/connector-react"; -import { Droplets, Search, SquareTerminal } from "lucide-react"; +import { Droplets, Languages, Search, SquareTerminal } from "lucide-react"; import type { ReactNode } from "react"; +import { Dropdown } from "./dropdown"; import styles from "./header-links.module.css"; +import { CONNECTOR_LOCALES, useConnectorLocale } from "./provider"; export function HeaderLinks({ client }: { client: ccc.Client }) { const explorer = @@ -31,10 +33,30 @@ export function HeaderLinks({ client }: { client: ccc.Client }) { + ); } +function LocaleToggle() { + const { locale, setLocale } = useConnectorLocale(); + + return ( +