@gemboss/ui 0.44.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,206 @@
1
+ "use client";
2
+
3
+ /**
4
+ * LanguageSwitcher — @gemboss/ui (21/10/2026): the one language button of the fleet.
5
+ *
6
+ * Chris 21/10/2026: "bộ switch ngôn ngữ ở gemcommunity, gemnews … chuẩn hoá ở gemboss ui để khi chọn một ngôn ngữ nó
7
+ * hiển thị lên trên cái icon luôn ví dụ là VI, EN hay chữ Zhong của trung quốc … để dễ hiểu hơn là icon quả địa cầu như
8
+ * hiện tại". Every surface drew its own (GemCommunity's topbar a bare globe, its sign-in pages "🌐 EN ⌄", GemNews its own),
9
+ * so the button now SAYS the language it is in: the globe and the short label ("EN", "VI", "中").
10
+ *
11
+ * The kit's menu, not a new one: a `details.ui-menu` whose list is `ui-menu__list gemboss-popover-surface` and whose rows
12
+ * are `ui-menu__item`, floated in the top layer by ./menu-layer exactly like `Menu`, so it opens with no JavaScript,
13
+ * closes the way every kit menu does (outside click, Escape with focus back on the button; kit-plain.js's initMenus too)
14
+ * and looks like the account menu. What it adds is a menu's keys (↑ ↓ Home End, Tab leaves), the current row checked
15
+ * (`menuitemradio`), each name in its own language (`lang`), and an optional suggested row first.
16
+ *
17
+ * The app keeps the storage: `onChange(code)` is all it gets (a cookie, localStorage, the profile, a redirect), and it
18
+ * passes the new `value` back. A language with an `href` is a link instead (a site whose languages are paths), so a
19
+ * server-rendered page switches with no script at all.
20
+ *
21
+ * language-switcher-plain.js draws the same DOM for a page with no React (`gembossUI.languageSwitcher`), held to this file
22
+ * node for node by language-switcher-browser.test.ts. language-switcher.css is its own entry, imported here.
23
+ */
24
+ import "./language-switcher.css";
25
+ import React, { useEffect, useRef } from "react";
26
+ import { Icon } from "./icons";
27
+ import { canFloatMenus, floatMenu, listenForDetachedMenus, sinkMenu } from "./menu-layer";
28
+
29
+ /** One language: `code` (what `onChange` gets, and the `lang` its name is spoken in), `label` (its NATIVE name: the menu
30
+ * lists every language in its own words), `short` (what the button shows; default the code's first part, upper case),
31
+ * `href` (optional: the row is a link to that page in this language). */
32
+ export interface Language {
33
+ code: string;
34
+ label: string;
35
+ short?: string;
36
+ href?: string;
37
+ }
38
+
39
+ /** The fleet's three languages, in this order. "中" for Chinese, not "ZH": the button is read by people, not by a parser. */
40
+ export const FLEET_LANGUAGES: readonly Readonly<Language>[] = Object.freeze([
41
+ Object.freeze({ code: "en", label: "English", short: "EN" }),
42
+ Object.freeze({ code: "vi", label: "Tiếng Việt", short: "VI" }),
43
+ Object.freeze({ code: "zh", label: "中文", short: "中" }),
44
+ ]);
45
+
46
+ /** The words it writes. The button's name is `<trigger>: <the current language's label>` ("Language: Tiếng Việt"), the
47
+ * menu's name is `trigger`, the suggested row's hint is `suggested`. */
48
+ export interface LanguageSwitcherLabels {
49
+ trigger: string;
50
+ suggested: string;
51
+ }
52
+ export const LANGUAGE_SWITCHER_LABELS: Readonly<LanguageSwitcherLabels> = Object.freeze({ trigger: "Language", suggested: "Suggested" });
53
+
54
+ export interface LanguageSwitcherProps {
55
+ /** The current language's code. One not in `languages` still shows (its code, upper case). */
56
+ value: string;
57
+ /** Default `FLEET_LANGUAGES`. */
58
+ languages?: readonly Language[];
59
+ /** A row was chosen that is not the current one. The app stores it and passes the new `value`. */
60
+ onChange?: (code: string) => void;
61
+ /** A code listed first with the `suggested` hint, then a separator (GemCommunity puts the browser's language first). */
62
+ suggested?: string;
63
+ labels?: Partial<LanguageSwitcherLabels>;
64
+ /** `topbar` (default): the topbar end slot's ghost button, 36px (44 under a finger). `inline`: a bordered pill with a
65
+ * caret, for a sign-in page, a site header or a footer. */
66
+ variant?: "topbar" | "inline";
67
+ /** The menu's right edge on the button's (default). `false`: its left edge. */
68
+ end?: boolean;
69
+ /** Added to the `<details>`'s class. */
70
+ className?: string;
71
+ }
72
+
73
+ /** What the button shows for a language: its `short`, else its code's first part upper case ("pt-BR" → "PT"). */
74
+ export function languageShort(language: Language): string {
75
+ return language.short || language.code.split(/[-_]/)[0].toUpperCase();
76
+ }
77
+
78
+ /** The first of `wanted` (`navigator.languages`, an Accept-Language list, one tag) that `languages` has: the exact code,
79
+ * else the same base language ("zh-CN" → "zh", "vi-VN" → "vi"). Undefined when none is. Pure: the app passes the list. */
80
+ export function matchLanguage(languages: readonly Language[], wanted: string | readonly string[] | null | undefined): string | undefined {
81
+ const list = typeof wanted === "string" ? wanted.split(",") : wanted ?? [];
82
+ const base = (s: string) => s.split(/[-_]/)[0];
83
+ for (const raw of list) {
84
+ const tag = String(raw).split(";")[0].trim().toLowerCase();
85
+ if (!tag) continue;
86
+ const hit = languages.find((l) => l.code.toLowerCase() === tag) ?? languages.find((l) => base(l.code.toLowerCase()) === base(tag));
87
+ if (hit) return hit.code;
88
+ }
89
+ return undefined;
90
+ }
91
+
92
+ const ITEM = ".gb-lang__item";
93
+ const rowsOf = (d: HTMLDetailsElement) => Array.from(d.querySelectorAll<HTMLElement>(ITEM));
94
+ /** Focus the checked row (else the first): where a menu opens. */
95
+ function focusCurrent(d: HTMLDetailsElement) {
96
+ const rows = rowsOf(d);
97
+ (rows.find((r) => r.getAttribute("aria-checked") === "true") ?? rows[0])?.focus();
98
+ }
99
+ function openMenu(d: HTMLDetailsElement) {
100
+ d.open = true;
101
+ if (canFloatMenus()) floatMenu(d);
102
+ }
103
+ function closeMenu(d: HTMLDetailsElement) {
104
+ d.open = false;
105
+ d.querySelector<HTMLElement>(":scope > summary")?.focus();
106
+ }
107
+
108
+ export function LanguageSwitcher({ value, languages = FLEET_LANGUAGES, onChange, suggested, labels, variant = "topbar", end = true, className }: LanguageSwitcherProps) {
109
+ const ref = useRef<HTMLDetailsElement>(null);
110
+ const L = { ...LANGUAGE_SWITCHER_LABELS, ...labels };
111
+ const current = languages.find((l) => l.code === value);
112
+ const name = `${L.trigger}: ${current ? current.label : value}`;
113
+ const first = suggested ? languages.find((l) => l.code === suggested) : undefined;
114
+ const rest = first ? languages.filter((l) => l !== first) : languages;
115
+
116
+ useEffect(() => {
117
+ const el = ref.current;
118
+ if (!el) return undefined;
119
+ // Menu's three (menu.tsx): an outside click closes it, Escape closes it with focus back on the button, and an open
120
+ // menu floats. Plus: an opening puts focus on the checked row, as a menu button does.
121
+ const onDoc = (e: MouseEvent) => { if (el.open && !el.contains(e.target as Node)) el.open = false; };
122
+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && el.open && !e.isComposing) closeMenu(el); };
123
+ const onToggle = () => {
124
+ if (!el.open) { sinkMenu(el); return; }
125
+ if (canFloatMenus()) floatMenu(el);
126
+ focusCurrent(el);
127
+ };
128
+ document.addEventListener("click", onDoc);
129
+ document.addEventListener("keydown", onKey);
130
+ el.addEventListener("toggle", onToggle);
131
+ if (canFloatMenus()) {
132
+ listenForDetachedMenus();
133
+ if (el.open) floatMenu(el);
134
+ }
135
+ return () => {
136
+ document.removeEventListener("click", onDoc);
137
+ document.removeEventListener("keydown", onKey);
138
+ el.removeEventListener("toggle", onToggle);
139
+ };
140
+ }, []);
141
+
142
+ // As Menu: the summary opens it and floats it in the same task, so the list is never painted in place first.
143
+ const onSummary = (e: React.MouseEvent<HTMLElement>) => {
144
+ const el = ref.current;
145
+ if (!el || !canFloatMenus()) return;
146
+ e.preventDefault();
147
+ if (el.open) el.open = false; else openMenu(el);
148
+ };
149
+ const onKeyDown = (e: React.KeyboardEvent<HTMLDetailsElement>) => {
150
+ const el = e.currentTarget;
151
+ const t = e.target as HTMLElement;
152
+ if (t.tagName === "SUMMARY") {
153
+ // ↓ / ↑ on the button open the menu (or go into it), on the checked row.
154
+ if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
155
+ e.preventDefault();
156
+ if (el.open) focusCurrent(el); else openMenu(el);
157
+ return;
158
+ }
159
+ const rows = rowsOf(el);
160
+ const i = rows.indexOf(t);
161
+ if (i < 0) return;
162
+ // Tab leaves the menu: it closes and focus goes on from the button, forward or back (not prevented).
163
+ if (e.key === "Tab") { closeMenu(el); return; }
164
+ const next = e.key === "ArrowDown" ? (i + 1) % rows.length : e.key === "ArrowUp" ? (i - 1 + rows.length) % rows.length
165
+ : e.key === "Home" ? 0 : e.key === "End" ? rows.length - 1 : -1;
166
+ if (next < 0) return;
167
+ e.preventDefault();
168
+ rows[next].focus();
169
+ };
170
+ const pick = (code: string) => (e: React.MouseEvent<HTMLElement>) => {
171
+ const el = e.currentTarget.closest("details");
172
+ if (el) closeMenu(el);
173
+ if (code !== value) onChange?.(code);
174
+ };
175
+
176
+ const row = (l: Language, hint?: string) => {
177
+ const on = l.code === value;
178
+ const body = (
179
+ <>
180
+ <span className="gb-lang__check" aria-hidden="true"><Icon name="check" size={16} /></span>
181
+ <span className="gb-lang__name" lang={l.code}>{l.label}</span>
182
+ {hint ? <span className="ui-menu__hint">{hint}</span> : null}
183
+ </>
184
+ );
185
+ const common = { role: "menuitemradio", "aria-checked": on, className: "ui-menu__item gb-lang__item", tabIndex: -1, "data-lang": l.code, onClick: pick(l.code) } as const;
186
+ return l.href
187
+ ? <a key={l.code} {...common} href={l.href} hrefLang={l.code}>{body}</a>
188
+ : <button key={l.code} type="button" {...common}>{body}</button>;
189
+ };
190
+
191
+ return (
192
+ <details ref={ref} onKeyDown={onKeyDown}
193
+ className={["ui-menu", end ? "ui-menu--end" : "", "gb-lang", `gb-lang--${variant}`, className ?? ""].filter(Boolean).join(" ")}>
194
+ <summary className="gb-lang__btn" aria-label={name} title={name} aria-haspopup="menu" onClick={onSummary}>
195
+ <Icon name="globe" size={18} />
196
+ {/* In its language too: "中" drawn with a Chinese font's glyph, not a Japanese one. */}
197
+ <span className="gb-lang__short" lang={current?.code}>{current ? languageShort(current) : value.toUpperCase()}</span>
198
+ {variant === "inline" ? <Icon name="chevronDown" size={14} className="gb-lang__caret" /> : null}
199
+ </summary>
200
+ <div className="ui-menu__list gemboss-popover-surface gb-lang__list" role="menu" aria-label={L.trigger}>
201
+ {first ? <>{row(first, L.suggested)}<hr className="ui-menu__sep" /></> : null}
202
+ {rest.map((l) => row(l))}
203
+ </div>
204
+ </details>
205
+ );
206
+ }
@@ -8,7 +8,8 @@
8
8
  * Plain TypeScript (no React): a server, a plain-HTML surface and the React component all import it.
9
9
  */
10
10
 
11
- /** [group name, [emoji, search words][]][] — the picker draws the groups in this order. */
11
+ /** [group id, [emoji, search words][]][] — the picker draws the groups in this order. The id is English and stays: it is the
12
+ * heading's default and the key a translation is looked up by (`labels.groupName(id)`, reactions.tsx). */
12
13
  export const REACTION_GROUPS: ReadonlyArray<readonly [string, ReadonlyArray<readonly [string, string]>]> = [
13
14
  ["Smileys", [["😀", "grin smile happy"], ["😃", "smile happy"], ["😄", "smile laugh happy"], ["😁", "grin beam"], ["😆", "laugh squint"], ["😅", "sweat smile relief"], ["😂", "joy tears laugh lol"], ["🤣", "rofl rolling laugh"], ["😊", "blush smile"], ["😇", "innocent halo"], ["🙂", "slight smile ok"], ["😉", "wink"], ["😍", "heart eyes love"], ["🥰", "love hearts"], ["😘", "kiss"], ["😋", "yum tasty"], ["😜", "wink tongue"], ["🤪", "zany crazy"], ["🤗", "hug"], ["🤭", "oops giggle"], ["🤔", "think hmm"], ["🤨", "raised eyebrow doubt"], ["😐", "neutral"], ["😶", "no words"], ["🙄", "eye roll"], ["😏", "smirk"], ["😬", "grimace awkward"], ["😌", "relieved"], ["😴", "sleep tired"], ["😷", "mask sick"], ["🤯", "mind blown"], ["🥳", "party celebrate"], ["😎", "cool sunglasses"], ["🤓", "nerd"], ["😕", "confused"], ["😟", "worried"], ["😮", "wow surprised"], ["😲", "astonished"], ["😳", "flushed embarrassed"], ["🥺", "pleading please"], ["😢", "cry sad"], ["😭", "sob crying"], ["😱", "scream shock"], ["😤", "huff frustrated"], ["😡", "angry mad"], ["🤬", "swear"], ["💀", "dead skull"], ["🫠", "melting"], ["🫡", "salute"]]],
14
15
  ["Hands", [["👍", "like thumbs up yes ok"], ["👎", "dislike thumbs down no"], ["👌", "ok perfect"], ["✌️", "peace victory"], ["🤞", "fingers crossed luck"], ["🤟", "love you"], ["🤘", "rock"], ["👏", "clap applause"], ["🙌", "raise hands hooray"], ["👐", "open hands"], ["🤝", "handshake deal"], ["🙏", "thanks please pray"], ["💪", "strong muscle"], ["👀", "eyes looking"], ["🫶", "heart hands"], ["✍️", "writing"], ["👋", "wave hi bye"], ["☝️", "point up"], ["👇", "point down"], ["👉", "point right"], ["👈", "point left"], ["🤙", "call me"], ["🖐️", "stop hand"], ["🤌", "pinched"]]],
@@ -45,10 +46,20 @@ export function reactionWords(e: string): string {
45
46
  return KEYS.get(e) ?? "";
46
47
  }
47
48
 
48
- /** Emoji whose search words contain the query (any case); all of them for an empty query. */
49
- export function searchReactions(query: string): string[] {
49
+ const GROUP_OF = new Map<string, string>(REACTION_GROUPS.flatMap(([id, items]) => items.map(([e]) => [e, id] as [string, string])));
50
+ /** A name as a search compares it: lower case, Vietnamese marks and the like dropped ("Mặt cười" → "mat cuoi"), đ → d. */
51
+ const fold = (s: string) => s.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/\u0111/g, "d");
52
+
53
+ /** Emoji whose search words contain the query (any case); all of them for an empty query. The search words are English.
54
+ * `groupName` (the picker's `labels.groupName`, 21/10/2026): a query found in a TRANSLATED group name ("mặt cười", "mat cuoi"
55
+ * for a group called "Mặt cười") adds that whole group, in picker order. A name left as its English id adds nothing, so a
56
+ * picker in English searches exactly as before. */
57
+ export function searchReactions(query: string, groupName?: (id: string) => string): string[] {
50
58
  const t = query.trim().toLowerCase();
51
- return t ? REACTION_EMOJI.filter((e) => KEYS.get(e)!.includes(t)) : [...REACTION_EMOJI];
59
+ if (!t) return [...REACTION_EMOJI];
60
+ const q = fold(t), groups = new Set<string>();
61
+ if (groupName) for (const [id] of REACTION_GROUPS) { const name = groupName(id); if (name !== id && fold(String(name)).includes(q)) groups.add(id); }
62
+ return REACTION_EMOJI.filter((e) => KEYS.get(e)!.includes(t) || groups.has(GROUP_OF.get(e)!));
52
63
  }
53
64
 
54
65
  /** One emoji on one thing: who put it there. `mine` is the viewer's own. */
package/src/reactions.tsx CHANGED
@@ -18,12 +18,55 @@
18
18
  *
19
19
  * Each target is 44px (a control, CLAUDE.md §9.1) while the visible pill is 26px: transparent padding carries the rest,
20
20
  * so a message with reactions does not grow a whole line.
21
+ *
22
+ * Every word both write is in `REACTION_LABELS` (21/10/2026: GemCommunity's Vietnamese chat showed "Search emoji" and
23
+ * "Smileys"); pass `labels` (any subset) to translate. The team chat's `TEAM_CHAT_LABELS` carries the same keys and its
24
+ * components pass their `labels` on. reactions.test.tsx holds this file to it: no word outside the table.
21
25
  */
22
26
  import "./reactions.css"; // its own stylesheet travels with it (0.23.0): a surface that never renders it never loads it
23
27
  import React, { useEffect, useId, useRef, useState } from "react";
24
28
  import { Icon } from "./icons";
25
29
  import { frequentReactions, REACTION_GROUPS, reactionLabel, reactionWords, searchReactions, type Reaction } from "./reactions-data";
26
30
 
31
+ // labels:begin
32
+ /** Every word `Reactions` and `ReactionPicker` write themselves. Pass `labels` (any subset) to translate. */
33
+ export type ReactionLabels = {
34
+ /** The picker's accessible name (a `label` prop wins). */
35
+ pickReaction: string;
36
+ /** The picker's search field: its placeholder and its accessible name. */
37
+ searchEmoji: string;
38
+ /** What the picker says when the search finds nothing. */
39
+ noEmojiMatches: string;
40
+ /** The heading of this browser's row. */
41
+ frequentlyUsed: string;
42
+ /** A group's heading, by its English id in `REACTION_GROUPS` ("Smileys", "Hands", "Marks", "Work"). The id stays the key;
43
+ * a translated name is also searched (`searchReactions`). The emoji's own search words are English. */
44
+ groupName: (id: string) => string;
45
+ /** A picker button's accessible name where the picker puts reactions (the composer's names a button by the emoji and its
46
+ * search words alone): "Add 🎉 party tada celebrate", "Remove 👍 like thumbs up yes ok". */
47
+ pickReactionEmoji: (emoji: string, words: string, mine: boolean) => string;
48
+ /** The tooltip of the button that opens the picker (the reaction row's, and the team chat's action bar). */
49
+ addReaction: string;
50
+ /** The reaction row's "Add reaction" accessible name; `target` is the `target` prop, absent for none. */
51
+ addReactionTo: (target?: string) => string;
52
+ /** A chip's accessible name: its emoji, who put it (`names`, joined by ", ") and what a click does. */
53
+ reactionChip: (emoji: string, names: string, mine: boolean) => string;
54
+ };
55
+
56
+ export const REACTION_LABELS: ReactionLabels = {
57
+ pickReaction: "Pick a reaction",
58
+ searchEmoji: "Search emoji",
59
+ noEmojiMatches: "No emoji matches.",
60
+ frequentlyUsed: "Frequently used",
61
+ groupName: (id) => id,
62
+ pickReactionEmoji: (e, w, mine) => `${mine ? "Remove" : "Add"} ${e} ${w}`,
63
+ addReaction: "Add reaction",
64
+ addReactionTo: (target) => `Add a reaction to ${target ?? "this"}`,
65
+ reactionChip: (e, names, mine) => `${e}, ${names}. ${mine ? "Remove yours" : "Add yours"}`,
66
+ };
67
+ // labels:end
68
+ const words = (labels?: Partial<ReactionLabels>): ReactionLabels => (labels ? { ...REACTION_LABELS, ...labels } : REACTION_LABELS);
69
+
27
70
  const RECENT_KEY = "gemboss-ui:reactions-recent";
28
71
  const RECENT_MAX = 9;
29
72
 
@@ -47,16 +90,19 @@ export type ReactionsProps = {
47
90
  /** Put or take back the viewer's emoji. The caller decides add or remove from its own state (a click on a chip that is
48
91
  * `mine` takes it back); `toggleReaction` is the optimistic guess to draw before the server answers. */
49
92
  onToggle?: (emoji: string) => void;
50
- /** Who or what is reacted to, for screen readers: "the comment from Linh". */
93
+ /** Who or what is reacted to, for screen readers: "the comment from Linh" (default: `labels.addReactionTo()`'s "this"). */
51
94
  target?: string;
52
95
  /** Show "Add reaction" (default true). A signed-out reader sees the chips only. */
53
96
  canReact?: boolean;
54
97
  /** The text a signed-out reader's click should lead to instead (e.g. open sign-in); chips are buttons either way. */
55
98
  onSignIn?: () => void;
56
99
  className?: string;
100
+ /** The words (`REACTION_LABELS`, any subset), the picker's included. */
101
+ labels?: Partial<ReactionLabels>;
57
102
  };
58
103
 
59
- export function Reactions({ items, onToggle, target = "this", canReact = true, onSignIn, className }: ReactionsProps) {
104
+ export function Reactions({ items, onToggle, target, canReact = true, onSignIn, className, labels }: ReactionsProps) {
105
+ const t = words(labels);
60
106
  const [open, setOpen] = useState(false);
61
107
  const addRef = useRef<HTMLButtonElement>(null);
62
108
  const pickerId = useId();
@@ -71,21 +117,21 @@ export function Reactions({ items, onToggle, target = "this", canReact = true, o
71
117
  <div className="ui-reactions__row">
72
118
  {items.map((r) => (
73
119
  <button key={r.emoji} type="button" className={`ui-reaction${r.mine ? " is-mine" : ""}`} aria-pressed={r.mine}
74
- title={r.names.join(", ")} aria-label={`${r.emoji}, ${r.names.join(", ")}. ${r.mine ? "Remove yours" : "Add yours"}`}
120
+ title={r.names.join(", ")} aria-label={t.reactionChip(r.emoji, r.names.join(", "), r.mine)}
75
121
  onClick={() => act(r.emoji)}>
76
122
  <span className="ui-reaction__emoji">{r.emoji}</span>
77
123
  <span className="ui-reaction__names">{reactionLabel(r.names)}</span>
78
124
  </button>
79
125
  ))}
80
126
  {canReact ? (
81
- <button ref={addRef} type="button" className="ui-reaction-add" title="Add reaction" aria-label={`Add a reaction to ${target}`}
127
+ <button ref={addRef} type="button" className="ui-reaction-add" title={t.addReaction} aria-label={t.addReactionTo(target)}
82
128
  aria-expanded={open} aria-controls={open ? pickerId : undefined} onClick={() => setOpen((o) => !o)}>
83
129
  <Icon name="reaction" size={18} />
84
130
  </button>
85
131
  ) : null}
86
132
  </div>
87
133
  {open ? (
88
- <ReactionPicker id={pickerId} items={items}
134
+ <ReactionPicker id={pickerId} items={items} labels={t}
89
135
  onPick={(emoji) => { setOpen(false); act(emoji); addRef.current?.focus(); }}
90
136
  onClose={() => { setOpen(false); addRef.current?.focus(); }} />
91
137
  ) : null}
@@ -101,15 +147,18 @@ export type ReactionPickerProps = {
101
147
  /** Escape. */
102
148
  onClose: () => void;
103
149
  id?: string;
104
- /** The group's accessible name. Default "Pick a reaction". */
150
+ /** The group's accessible name. Default `labels.pickReaction` ("Pick a reaction"). */
105
151
  label?: string;
106
152
  className?: string;
107
153
  /** Where focus goes when it opens: the search (default), or the first emoji (under a finger, where a focused search
108
154
  * brings the keyboard up over the picker). */
109
155
  initialFocus?: "search" | "emoji";
156
+ /** The words (`REACTION_LABELS`, any subset): the search, "No emoji matches.", "Frequently used", the group headings. */
157
+ labels?: Partial<ReactionLabels>;
110
158
  };
111
159
 
112
- export function ReactionPicker({ items, onPick, onClose, id, label = "Pick a reaction", className, initialFocus = "search" }: ReactionPickerProps) {
160
+ export function ReactionPicker({ items, onPick, onClose, id, label, className, initialFocus = "search", labels }: ReactionPickerProps) {
161
+ const t = words(labels);
113
162
  const [q, setQ] = useState("");
114
163
  const [recent, setRecent] = useState<readonly string[]>(() => frequentReactions([]));
115
164
  const search = useRef<HTMLInputElement>(null);
@@ -124,11 +173,11 @@ export function ReactionPicker({ items, onPick, onClose, id, label = "Pick a rea
124
173
  const mine = (e: string) => Boolean(items?.some((r) => r.emoji === e && r.mine));
125
174
  const btn = (e: string) => (
126
175
  <button key={e} type="button" className="ui-reaction-picker__btn" title={reactionWords(e).split(" ")[0] || e}
127
- aria-label={(items ? `${mine(e) ? "Remove" : "Add"} ${e} ${reactionWords(e)}` : `${e} ${reactionWords(e)}`).trim()}
176
+ aria-label={(items ? t.pickReactionEmoji(e, reactionWords(e), mine(e)) : `${e} ${reactionWords(e)}`).trim()}
128
177
  aria-pressed={items ? mine(e) : undefined} onClick={() => onPick(e)}>{e}</button>
129
178
  );
130
179
  const grid = (list: readonly string[]) => <div className="ui-reaction-picker__grid">{list.map(btn)}</div>;
131
- const found = q.trim() ? searchReactions(q) : null;
180
+ const found = q.trim() ? searchReactions(q, t.groupName) : null;
132
181
  const onKeyDown = (ev: React.KeyboardEvent) => {
133
182
  if (ev.key === "Escape") { ev.preventDefault(); ev.stopPropagation(); onClose(); return; }
134
183
  const all = [...(body.current?.querySelectorAll<HTMLButtonElement>(".ui-reaction-picker__btn") ?? [])];
@@ -148,15 +197,15 @@ export function ReactionPicker({ items, onPick, onClose, id, label = "Pick a rea
148
197
  all[Math.min(all.length - 1, at + step)]?.focus();
149
198
  };
150
199
  return (
151
- <div id={id} className={["ui-reaction-picker", className].filter(Boolean).join(" ")} role="group" aria-label={label} onKeyDown={onKeyDown}>
152
- <input ref={search} type="search" className="gemboss-field ui-reaction-picker__search" placeholder="Search emoji" aria-label="Search emoji"
200
+ <div id={id} className={["ui-reaction-picker", className].filter(Boolean).join(" ")} role="group" aria-label={label ?? t.pickReaction} onKeyDown={onKeyDown}>
201
+ <input ref={search} type="search" className="gemboss-field ui-reaction-picker__search" placeholder={t.searchEmoji} aria-label={t.searchEmoji}
153
202
  autoComplete="off" value={q} onChange={(e) => setQ(e.target.value)} />
154
203
  <div ref={body} className="ui-reaction-picker__body">
155
- {found ? (found.length ? grid(found) : <p className="ui-reaction-picker__empty">No emoji matches.</p>) : (
204
+ {found ? (found.length ? grid(found) : <p className="ui-reaction-picker__empty">{t.noEmojiMatches}</p>) : (
156
205
  <>
157
- <p className="ui-reaction-picker__head">Frequently used</p>{grid(recent)}
158
- {REACTION_GROUPS.map(([name, list]) => (
159
- <React.Fragment key={name}><p className="ui-reaction-picker__head">{name}</p>{grid(list.map(([e]) => e))}</React.Fragment>
206
+ <p className="ui-reaction-picker__head">{t.frequentlyUsed}</p>{grid(recent)}
207
+ {REACTION_GROUPS.map(([gid, list]) => (
208
+ <React.Fragment key={gid}><p className="ui-reaction-picker__head">{t.groupName(gid)}</p>{grid(list.map(([e]) => e))}</React.Fragment>
160
209
  ))}
161
210
  </>
162
211
  )}
@@ -1 +1 @@
1
- .ui-setup,.ui-setup__inner{display:grid;gap:var(--ui-space-3, 12px)}.ui-setup__head,.ui-setup__row{display:flex;align-items:center;gap:var(--ui-space-3, 12px);list-style:none;cursor:pointer;border-radius:var(--ui-radius-md, 8px);color:inherit;transition:background-color .12s ease}.ui-setup__head::-webkit-details-marker,.ui-setup__row::-webkit-details-marker{display:none}div.ui-setup__head{cursor:auto}summary.ui-setup__head{padding:6px 8px;margin:-6px -8px 0}.ui-setup__title{margin:0;font-size:var(--ui-fs-body, 13px);line-height:20px;font-weight:650;color:var(--ui-text-strong, var(--ui-text))}.ui-setup__desc{margin:0;font-size:var(--ui-fs-body, 13px);line-height:20px;color:var(--ui-text-muted)}.ui-setup__row{padding:8px;margin:0 -8px}summary.ui-setup__head:hover,summary.ui-setup__row:hover{background:var(--ui-surface-2, #eceef2)}.ui-setup__row--static{cursor:auto}.ui-setup__row.ui-setup__row--static:after{content:none}summary.ui-setup__head:active,summary.ui-setup__row:active{background:var(--ui-border, #e5e7ea)}summary.ui-setup__head:focus-visible,summary.ui-setup__row:focus-visible{outline:2px solid var(--ui-focus, #5661f5);outline-offset:2px}summary.ui-setup__head:after,.ui-setup__row:after{content:"\25be";margin-left:auto;font-size:11px;color:var(--ui-text-muted);transition:transform .12s ease}.ui-setup:not([open])>summary.ui-setup__head:after,.ui-setup__step:not([open])>.ui-setup__row:after{transform:rotate(-90deg)}.ui-setup__bar{appearance:none;-webkit-appearance:none;display:block;width:100%;height:6px;border:0;border-radius:999px;overflow:hidden;background:var(--ui-border, #e5e7ea);color:var(--ui-accent, #1b1c1e)}.ui-setup__bar::-webkit-progress-bar{background:var(--ui-border, #e5e7ea);border-radius:inherit}.ui-setup__bar::-webkit-progress-value{background:currentColor;border-radius:inherit;transition:width .15s ease}.ui-setup__bar::-moz-progress-bar{background:currentColor;border-radius:inherit}.ui-setup__bar--done{color:var(--ui-success-fg, #007f5f)}.ui-setup__steps{display:grid;gap:2px}.ui-setup__mark{flex:0 0 16px;text-align:center;line-height:20px;color:var(--ui-text-muted)}.ui-setup__step[data-state=done] .ui-setup__mark{color:var(--ui-success-fg, #007f5f)}.ui-setup__label{min-width:0;overflow-wrap:anywhere;font-size:var(--ui-fs-body, 13px);line-height:20px;font-weight:550;color:var(--ui-text)}.ui-setup__step[data-state=done] .ui-setup__label{font-weight:450;color:var(--ui-text-muted)}.ui-setup__body{padding:4px 0 12px 28px;font-size:var(--ui-fs-body, 13px);line-height:20px;color:var(--ui-text)}.ui-setup__body>:first-child{margin-top:0}.ui-setup__body>:last-child{margin-bottom:0}@media (pointer: coarse),(max-width: 47.9975em){summary.ui-setup__head,.ui-setup__row{min-height:44px;box-sizing:border-box}}@media (prefers-reduced-motion: reduce){.ui-setup__head,.ui-setup__row,summary.ui-setup__head:after,.ui-setup__row:after,.ui-setup__bar::-webkit-progress-value{transition:none}}
1
+ .ui-setup{--ui-setup-done: var(--gemboss-success, #007f5f)}.ui-setup,.ui-setup__inner{display:grid;gap:var(--ui-space-3, 12px)}.ui-setup__head,.ui-setup__row{display:flex;align-items:center;gap:var(--ui-space-3, 12px);list-style:none;cursor:pointer;border-radius:var(--ui-radius-md, 8px);color:inherit;transition:background-color .12s ease}.ui-setup__head::-webkit-details-marker,.ui-setup__row::-webkit-details-marker{display:none}div.ui-setup__head{cursor:auto}summary.ui-setup__head{padding:6px 8px;margin:-6px -8px 0}.ui-setup__title{margin:0;font-size:var(--ui-fs-body, 13px);line-height:20px;font-weight:650;color:var(--ui-text-strong, var(--ui-text))}.ui-setup .ui-badge--warning{background:var(--gemboss-warning-soft);color:var(--gemboss-warning)}.ui-setup .ui-badge--success{background:var(--gemboss-success-soft);color:var(--gemboss-success-strong)}.ui-setup__desc{margin:0;font-size:var(--ui-fs-body, 13px);line-height:20px;color:var(--ui-text-muted)}.ui-setup__row{padding:8px;margin:0 -8px}summary.ui-setup__head:hover,summary.ui-setup__row:hover,.ui-setup__x:hover{background:var(--ui-surface-2, #eceef2)}.ui-setup__row--static{cursor:auto}.ui-setup__row.ui-setup__row--static:after{content:none}summary.ui-setup__head:active,summary.ui-setup__row:active,.ui-setup__x:active{background:var(--ui-border, #e5e7ea)}summary.ui-setup__head:focus-visible,summary.ui-setup__row:focus-visible,.ui-setup__x:focus-visible{outline:2px solid var(--ui-focus, #5661f5);outline-offset:2px}summary.ui-setup__head:after,.ui-setup__row:after{content:"\25be";margin-left:auto;font-size:11px;color:var(--ui-text-muted);transition:transform .12s ease}.ui-setup:not([open])>summary.ui-setup__head:after,:is(.ui-setup__step,.ui-setup__done):not([open])>.ui-setup__row:after{transform:rotate(-90deg)}.ui-setup__bar{appearance:none;display:block;width:100%;height:6px;border:0;border-radius:999px;overflow:hidden;background:var(--ui-border, #e5e7ea);color:var(--ui-setup-done)}.ui-setup__bar::-webkit-progress-bar{background:inherit;border-radius:inherit}.ui-setup__bar::-webkit-progress-value{background:currentColor;border-radius:inherit;transition:width .15s ease}.ui-setup__bar::-moz-progress-bar{background:currentColor;border-radius:inherit}.ui-setup__steps{display:grid;gap:2px}.ui-setup__mark{flex:0 0 18px;width:18px;height:18px;box-sizing:border-box;display:inline-grid;place-items:center;position:relative;border:1.5px solid color-mix(in srgb,var(--ui-text-muted, #686b71) 50%,transparent);border-radius:50%;font-size:0;color:var(--ui-text-muted, #686b71)}:is(.ui-setup__step[data-state=done],.ui-setup__done)>.ui-setup__row>.ui-setup__mark{background:var(--ui-setup-done);border-color:var(--ui-setup-done)}:is(.ui-setup__step[data-state=done],.ui-setup__done)>.ui-setup__row>.ui-setup__mark:before{content:"";position:absolute;left:5px;top:2px;width:4px;height:8px;box-sizing:content-box;border:solid #fff;border-width:0 1.75px 1.75px 0;transform:rotate(45deg)}.ui-setup__step[data-state=unknown]>.ui-setup__row>.ui-setup__mark{border-style:dashed;font-size:10px;font-weight:650}.ui-setup__label{min-width:0;overflow-wrap:anywhere;font-size:var(--ui-fs-body, 13px);line-height:20px;font-weight:550;color:var(--ui-text)}:is(.ui-setup__step[data-state=done],.ui-setup__done)>.ui-setup__row>.ui-setup__label,.ui-setup__optional .ui-setup__label{font-weight:450;color:var(--ui-text-muted)}.ui-setup__action{flex:none;display:inline-flex;gap:8px;margin:-4px 0 -4px auto}.ui-setup__actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}.ui-setup__body{padding:4px 0 12px 30px;font-size:var(--ui-fs-body, 13px);line-height:20px;color:var(--ui-text)}.ui-setup__body>:first-child{margin-top:0}.ui-setup__body>:last-child{margin-bottom:0}.ui-setup__optional{display:grid;gap:8px;padding-top:12px;border-top:1px solid var(--ui-border, #e5e7ea)}.ui-setup__subtitle{margin:0;font-size:13px;line-height:20px;font-weight:650;color:var(--ui-text-muted)}.ui-setup__x{flex:none;display:inline-grid;place-items:center;width:28px;height:28px;margin:-4px -4px -4px auto;padding:0;border:0;border-radius:8px;background:none;color:var(--ui-text-muted);cursor:pointer}.ui-setup__x svg{width:16px;height:16px}@media (pointer: coarse),(max-width: 47.9975em){summary.ui-setup__head,.ui-setup__row{min-height:44px;box-sizing:border-box}.ui-setup__x{width:44px;height:44px;margin:-12px -12px -12px auto}:is(.ui-setup__body,.ui-setup__action) :is(button,a.ui-btn){min-height:44px}}@media (prefers-reduced-motion: reduce){.ui-setup__head,.ui-setup__row,summary.ui-setup__head:after,.ui-setup__row:after,.ui-setup__bar::-webkit-progress-value{transition:none}}
@@ -9,6 +9,14 @@
9
9
  * Why it moved here (08/10/2026): gemform's Telegram alerts page drew its own numbered steps (1 The bot, 2 Where alerts
10
10
  * go), the admin has this one in Polaris, and a third surface with a "connect this, then that" page would write a third.
11
11
  *
12
+ * ONE checklist for the fleet (21/10/2026, Chris: "cái onboarding checklist của gemcommunity với của gemboss nút tick
13
+ * màu xanh progress bar nên đồng bộ nhé để dùng cùng một gemboss ui kit"). GemBoss' "Set up your store" and
14
+ * GemCommunity's "Start here" were two drawings of one thing: grey ✓ glyphs and a black bar on one, filled green ticks,
15
+ * an iris bar and a "✓ 2 completed" fold on the other. Both now render this, so the guide took what each did better:
16
+ * the filled green tick (todo is an empty ring), a bar in the same green (it counts the ticks), the steps already done
17
+ * folded behind one "✓ N completed" row, an "Optional" group under the counted steps (the admin's notes), a step's own
18
+ * control at the end of its row (`action`), and a × for a guide that is dismissed rather than folded (`onDismiss`).
19
+ *
12
20
  * Native `<details>` all the way down, so a page that writes the markup by hand gets the same behaviour with no script:
13
21
  * the guide is a `<details>` whose `<summary>` folds it away, each step is a `<details name>` of one group, which the
14
22
  * browser keeps to one open at a time. The ROW reveals; what changes something is a button the surface puts in the
@@ -16,8 +24,10 @@
16
24
  * nothing.
17
25
  *
18
26
  * The step open at first is the first one not done, decided ONCE, like the admin: a step's state changing while the
19
- * person reads it must not slam it shut (finishing step 1 leaves its confirmation on screen). The bar is a <progress>,
20
- * not a width in a style attribute, so it fills under a CSP that forbids inline style.
27
+ * person reads it must not slam it shut (finishing step 1 leaves its confirmation on screen). The fold is decided once
28
+ * for the same reason: a step finished while the guide is on screen stays where it is and turns green, it does not
29
+ * vanish into the fold under the person's eyes; the next visit folds it. The bar is a <progress>, not a width in a
30
+ * style attribute, so it fills under a CSP that forbids inline style.
21
31
  */
22
32
  import "./setup-guide.css"; // its own entry (0.30.0)
23
33
  import React, { useEffect, useId, useRef, useState } from "react";
@@ -33,32 +43,57 @@ export interface SetupStep {
33
43
  state?: SetupState;
34
44
  /** What the open step shows: a sentence and the control that does it. Without one the row is not a toggle. */
35
45
  body?: React.ReactNode;
46
+ /** The control that does the step (21/10/2026): at the end of its row when the step has no body, under the body
47
+ * when it has one (a control inside the row would be a control inside a toggle). */
48
+ action?: React.ReactNode;
49
+ /** A class on the step, for the surface's own rule (GemCommunity hides its Telegram step where the rail asks). */
50
+ className?: string;
51
+ }
52
+
53
+ export interface SetupGuideLabels {
54
+ progress?: string;
55
+ notMeasured?: (n: number) => string;
56
+ /** The fold of the steps already done: "2 completed". */
57
+ completed?: (n: number) => string;
58
+ /** The heading of the `optional` group. */
59
+ optional?: string;
60
+ /** The × of a guide with `onDismiss`. */
61
+ dismiss?: string;
36
62
  }
37
63
 
38
64
  const MARK: Record<SetupState, string> = { done: "✓", todo: "○", unknown: "?" };
39
65
 
40
- export function SetupGuide({ title, description, steps, open, collapsible = true, rememberKey, group, labels, className }: {
66
+ export function SetupGuide({ title, description, steps, optional, open, collapsible = true, foldDone = true, onDismiss, rememberKey, group, labels, className }: {
41
67
  title: React.ReactNode;
42
68
  description?: React.ReactNode;
43
69
  steps: readonly SetupStep[];
70
+ /** Steps worth doing that the count leaves out (the admin's "Optional" notes): under a divider, never progress. */
71
+ optional?: readonly SetupStep[];
44
72
  /** The step to show open at first; defaults to the first one not done. */
45
73
  open?: string;
46
74
  /** false: the head is a plain heading, the guide cannot be folded away. */
47
75
  collapsible?: boolean;
76
+ /** Fold the steps done when the guide first renders behind one "✓ N completed" row (default). false lists them. */
77
+ foldDone?: boolean;
78
+ /** Put the guide away with a × instead of folding it (the surface remembers and stops rendering it). A guide that
79
+ * can be dismissed does not also fold: two ways to put one card away is one too many. */
80
+ onDismiss?: () => void;
48
81
  /** Remember a folded guide in localStorage under this key, so someone not setting up today is not asked again on every
49
82
  * visit. Give each place its own key (folding it on one page is not folding it everywhere). */
50
83
  rememberKey?: string;
51
84
  /** The steps' shared `name`. Defaults to a useId(), unique within one React root; pass one when two guides render in
52
85
  * separate roots or separate static renders. */
53
86
  group?: string;
54
- labels?: { progress?: string; notMeasured?: (n: number) => string };
87
+ labels?: SetupGuideLabels;
55
88
  className?: string;
56
89
  }) {
57
90
  const auto = useId();
58
91
  const name = group ?? `gb-setup${auto}`;
59
- // Decided once (see the header): later renders keep whatever the person opened.
92
+ // Decided once (see the header): later renders keep whatever the person opened, and what was folded.
60
93
  const [first] = useState(() => open ?? steps.find((s) => s.state !== "done" && s.body)?.id);
94
+ const [folded] = useState(() => new Set(foldDone ? steps.filter((s) => s.state === "done").map((s) => s.id) : []));
61
95
  const guide = useRef<HTMLDetailsElement>(null);
96
+ const folds = collapsible && !onDismiss;
62
97
  useEffect(() => {
63
98
  const el = guide.current;
64
99
  if (!el || !rememberKey) return undefined;
@@ -66,13 +101,32 @@ export function SetupGuide({ title, description, steps, open, collapsible = true
66
101
  const onToggle = () => { try { localStorage.setItem(rememberKey, el.open ? "open" : "folded"); } catch { /* ignore */ } };
67
102
  el.addEventListener("toggle", onToggle);
68
103
  return () => el.removeEventListener("toggle", onToggle);
69
- }, [rememberKey]);
104
+ }, [rememberKey, folds]);
70
105
 
71
106
  if (!steps.length) return null; // nothing to set up is no guide, not a "0/0" card
72
107
  const total = steps.length;
73
108
  const done = steps.filter((s) => s.state === "done").length;
74
109
  const unknown = steps.filter((s) => s.state === "unknown").length;
75
110
  const all = done === total;
111
+ // Folded = done when the guide first rendered AND still done (a step that came undone is back in the list).
112
+ const inFold = steps.filter((s) => folded.has(s.id) && s.state === "done");
113
+ const listed = steps.filter((s) => !inFold.includes(s));
114
+
115
+ const step = (s: SetupStep) => {
116
+ const state = s.state ?? "todo";
117
+ const row = (as: "summary" | "div") => React.createElement(as, { className: cx("ui-setup__row", as === "div" && "ui-setup__row--static") },
118
+ <span className="ui-setup__mark" aria-hidden="true">{MARK[state]}</span>,
119
+ <span className="ui-setup__label">{s.title}</span>,
120
+ as === "div" && s.action ? <span className="ui-setup__action">{s.action}</span> : null);
121
+ if (!s.body) return <div key={s.id} className={cx("ui-setup__step", s.className)} data-state={state}>{row("div")}</div>;
122
+ return (
123
+ <details key={s.id} className={cx("ui-setup__step", s.className)} name={name} open={s.id === first} data-state={state}>
124
+ {row("summary")}
125
+ <div className="ui-setup__body">{s.body}{s.action ? <div className="ui-setup__actions">{s.action}</div> : null}</div>
126
+ </details>
127
+ );
128
+ };
129
+
76
130
  const head = (
77
131
  <>
78
132
  <h2 className="ui-setup__title">{title}</h2>
@@ -85,27 +139,43 @@ export function SetupGuide({ title, description, steps, open, collapsible = true
85
139
  const inner = (
86
140
  <div className="ui-setup__inner">
87
141
  {description ? <p className="ui-setup__desc">{description}</p> : null}
88
- <progress className={cx("ui-setup__bar", all && "ui-setup__bar--done")} value={done} max={total}
142
+ <progress className="ui-setup__bar" value={done} max={total}
89
143
  aria-label={labels?.progress ?? "Setup progress"}>{`${done}/${total}`}</progress>
90
144
  <div className="ui-setup__steps">
91
- {steps.map((s) => {
92
- const state = s.state ?? "todo";
93
- const row = (as: "summary" | "div") => React.createElement(as, { className: cx("ui-setup__row", as === "div" && "ui-setup__row--static") },
94
- <span className="ui-setup__mark" aria-hidden="true">{MARK[state]}</span>,
95
- <span className="ui-setup__label">{s.title}</span>);
96
- if (!s.body) return <div key={s.id} className="ui-setup__step" data-state={state}>{row("div")}</div>;
97
- return (
98
- <details key={s.id} className="ui-setup__step" name={name} open={s.id === first} data-state={state}>
99
- {row("summary")}
100
- <div className="ui-setup__body">{s.body}</div>
101
- </details>
102
- );
103
- })}
145
+ {inFold.length ? (
146
+ // Not in the steps' `name` group: opening the fold must not shut the step that is open below it.
147
+ <details className="ui-setup__done">
148
+ <summary className="ui-setup__row">
149
+ <span className="ui-setup__mark" aria-hidden="true">{MARK.done}</span>
150
+ <span className="ui-setup__label">{labels?.completed?.(inFold.length) ?? `${inFold.length} completed`}</span>
151
+ </summary>
152
+ <div className="ui-setup__steps">{inFold.map(step)}</div>
153
+ </details>
154
+ ) : null}
155
+ {listed.map(step)}
104
156
  </div>
157
+ {optional?.length ? (
158
+ <div className="ui-setup__optional">
159
+ <h3 className="ui-setup__subtitle">{labels?.optional ?? "Optional"}</h3>
160
+ <div className="ui-setup__steps">{optional.map(step)}</div>
161
+ </div>
162
+ ) : null}
105
163
  </div>
106
164
  );
107
- if (!collapsible) {
108
- return <section className={cx("ui-card ui-setup", className)}><div className="ui-setup__head">{head}</div>{inner}</section>;
165
+ if (!folds) {
166
+ return (
167
+ <section className={cx("ui-card ui-setup", className)}>
168
+ <div className="ui-setup__head">
169
+ {head}
170
+ {onDismiss ? (
171
+ <button type="button" className="ui-setup__x" aria-label={labels?.dismiss ?? "Dismiss"} onClick={onDismiss}>
172
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" /></svg>
173
+ </button>
174
+ ) : null}
175
+ </div>
176
+ {inner}
177
+ </section>
178
+ );
109
179
  }
110
180
  return (
111
181
  <details className={cx("ui-card ui-setup", className)} open ref={guide}>