Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/connector-i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@ckb-ccc/connector": minor
"@ckb-ccc/connector-react": minor
---

feat(connector): add localization support

- New `locale` property on `<ccc-connector>` 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`.

12 changes: 12 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<bcp47-tag>.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`.
Comment thread
yixyxiu marked this conversation as resolved.
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.
140 changes: 140 additions & 0 deletions packages/app/src/app/dropdown.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
195 changes: 195 additions & 0 deletions packages/app/src/app/dropdown.tsx
Original file line number Diff line number Diff line change
@@ -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<T extends string> = {
value: T;
label: string;
/** Small muted text shown at the right of the option, e.g. a code. */
hint?: string;
};

export function Dropdown<T extends string>({
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<T>[];
value: T;
}) {
const id = useId();
const rootRef = useRef<HTMLDivElement>(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<HTMLElement>) => {
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 (
<div
className={`${styles.root} ${open ? styles.isOpen : ""} ${className ?? ""}`}
ref={rootRef}
onKeyDown={onKeyDown}
>
<button
type="button"
className={styles.trigger}
role="combobox"
title={label}
aria-label={label}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={`${id}-listbox`}
aria-activedescendant={open ? `${id}-option-${active}` : undefined}
onClick={() => (open ? close() : openMenu())}
>
{icon}
<span className={styles.value}>{selected?.label ?? value}</span>
<ChevronDown className={styles.chevron} aria-hidden="true" />
</button>

<ul
id={`${id}-listbox`}
role="listbox"
aria-label={label}
aria-hidden={!open}
className={`${styles.menu} ${align === "start" ? styles.alignStart : ""}`}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
>
{options.map((option, index) => {
const isSelected = option.value === value;
return (
<li
key={option.value}
id={`${id}-option-${index}`}
role="option"
aria-selected={isSelected}
className={`${styles.option} ${isSelected ? styles.isSelected : ""} ${index === active ? styles.isActive : ""}`}
onPointerMove={() => setActive(index)}
onClick={() => select(index)}
>
<span className={styles.check} aria-hidden="true">
{isSelected ? <Check /> : null}
</span>
<span className={styles.optionLabel}>{option.label}</span>
{option.hint ? (
<span className={styles.optionHint}>{option.hint}</span>
) : null}
</li>
);
})}
</ul>
</div>
);
}
10 changes: 0 additions & 10 deletions packages/app/src/app/header-links.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,3 @@
fill: currentColor;
stroke: none;
}

@media (max-width: 680px) {
.root {
gap: 1px;
}

.link {
width: 25px;
}
}
Loading
Loading