@gemboss/ui 0.45.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
+ }
@@ -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}>