@cyberskill/design 1.2.0 → 1.3.8

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.
Files changed (215) hide show
  1. package/DESIGN.md +4 -3
  2. package/README.md +20 -19
  3. package/SKILL.md +5 -5
  4. package/THIRD-PARTY-NOTICES.md +35 -0
  5. package/VERSION +1 -1
  6. package/_ds_bundle.js +701 -1342
  7. package/_esm/cs.mjs +2 -2
  8. package/_esm/react.mjs +6 -1
  9. package/_vendor/NOTICE.md +16 -0
  10. package/_vendor/babel/README.md +7 -0
  11. package/_vendor/babel/babel.min.js +4 -0
  12. package/_vendor/react/README.md +3 -0
  13. package/_vendor/react/react-dom.production.min.js +1 -0
  14. package/_vendor/react/react.production.min.js +1 -0
  15. package/assets/aurora-hoa.webp +0 -0
  16. package/assets/aurora-kim.webp +0 -0
  17. package/assets/aurora-moc.webp +0 -0
  18. package/assets/aurora-tho.webp +0 -0
  19. package/assets/aurora-thuy.webp +0 -0
  20. package/base/ai.css +8 -3
  21. package/base/collections.css +10 -4
  22. package/base/components.css +5 -2
  23. package/base/controls.css +58 -21
  24. package/base/data.css +1 -1
  25. package/base/feedback.css +9 -2
  26. package/base/forms.css +1 -1
  27. package/base/glass.css +1 -1
  28. package/base/interaction.css +6 -2
  29. package/base/navigation.css +1 -1
  30. package/base/reset.css +7 -1
  31. package/base/responsive.css +13 -12
  32. package/base/shell.css +8 -2
  33. package/components/_i18n/i18n.js +28 -3
  34. package/components/_i18n/strings.js +42 -24
  35. package/components/ai/ChatMessage.d.ts +2 -0
  36. package/components/ai/ChatMessage.jsx +8 -4
  37. package/components/ai/ConfidenceMeter.d.ts +2 -0
  38. package/components/ai/HumanReviewGate.d.ts +2 -0
  39. package/components/ai/PromptInput.d.ts +2 -0
  40. package/components/ai/TypingIndicator.d.ts +2 -0
  41. package/components/ai/ai-conversation.card.html +3 -3
  42. package/components/ai/ai-lumi.card.html +3 -3
  43. package/components/ai/ai.card.html +3 -3
  44. package/components/brand/brand.card.html +3 -3
  45. package/components/button/buttons.card.html +3 -3
  46. package/components/data/Image.jsx +18 -2
  47. package/components/data/Sortable.d.ts +5 -1
  48. package/components/data/Sortable.jsx +42 -9
  49. package/components/data/Sortable.prompt.md +4 -2
  50. package/components/data/Terminal.jsx +3 -2
  51. package/components/data/Tooltip.d.ts +2 -0
  52. package/components/data/Tooltip.jsx +6 -3
  53. package/components/data/Tree.d.ts +2 -1
  54. package/components/data/Tree.jsx +137 -16
  55. package/components/data/Tree.prompt.md +1 -1
  56. package/components/data/collections.card.html +3 -3
  57. package/components/data/data.card.html +3 -3
  58. package/components/datatable/DataTable.d.ts +24 -1
  59. package/components/datatable/DataTable.jsx +78 -5
  60. package/components/datatable/DataTable.prompt.md +2 -1
  61. package/components/datatable/datatable.card.html +20 -16
  62. package/components/dialog/dialog.card.html +3 -3
  63. package/components/feedback/Alert.d.ts +2 -0
  64. package/components/feedback/Alert.jsx +8 -2
  65. package/components/feedback/EmptyState.d.ts +2 -0
  66. package/components/feedback/EmptyState.jsx +6 -3
  67. package/components/feedback/ProgressBar.d.ts +2 -0
  68. package/components/feedback/ProgressBar.jsx +6 -3
  69. package/components/feedback/Toast.jsx +15 -1
  70. package/components/feedback/Toast.prompt.md +2 -1
  71. package/components/feedback/feedback-2.card.html +3 -3
  72. package/components/feedback/feedback.card.html +3 -3
  73. package/components/forms/Cascader.jsx +7 -2
  74. package/components/forms/FileUpload.d.ts +2 -0
  75. package/components/forms/Form.d.ts +3 -1
  76. package/components/forms/Form.jsx +9 -1
  77. package/components/forms/Form.prompt.md +1 -1
  78. package/components/forms/Mentions.d.ts +1 -1
  79. package/components/forms/Mentions.jsx +59 -7
  80. package/components/forms/Mentions.prompt.md +3 -1
  81. package/components/forms/Rating.d.ts +3 -2
  82. package/components/forms/Rating.jsx +34 -3
  83. package/components/forms/Rating.prompt.md +1 -1
  84. package/components/forms/Select.d.ts +2 -0
  85. package/components/forms/Select.jsx +6 -2
  86. package/components/forms/Slider.d.ts +2 -0
  87. package/components/forms/Slider.jsx +5 -2
  88. package/components/forms/Switch.d.ts +2 -0
  89. package/components/forms/Switch.jsx +7 -9
  90. package/components/forms/Toggle.d.ts +2 -0
  91. package/components/forms/Toggle.jsx +14 -2
  92. package/components/forms/forms-2.card.html +3 -3
  93. package/components/forms/forms.card.html +3 -3
  94. package/components/icon/Icon.d.ts +1 -1
  95. package/components/icon/Icon.prompt.md +1 -1
  96. package/components/icon/icon.card.html +3 -3
  97. package/components/logo/logo.card.html +3 -3
  98. package/components/navigation/CommandPalette.d.ts +2 -0
  99. package/components/navigation/Menu.d.ts +2 -0
  100. package/components/navigation/Menu.jsx +60 -6
  101. package/components/navigation/Steps.d.ts +2 -0
  102. package/components/navigation/Steps.jsx +6 -3
  103. package/components/navigation/Tabs.d.ts +2 -0
  104. package/components/navigation/Tabs.jsx +6 -3
  105. package/components/navigation/Toolbar.d.ts +3 -2
  106. package/components/navigation/Toolbar.jsx +117 -11
  107. package/components/navigation/Toolbar.prompt.md +1 -1
  108. package/components/navigation/navigation.card.html +3 -3
  109. package/components/navigation/shell.card.html +3 -3
  110. package/components/overlays/ContextMenu.d.ts +2 -0
  111. package/components/overlays/ContextMenu.jsx +6 -3
  112. package/components/overlays/Drawer.d.ts +2 -0
  113. package/components/overlays/alertdialog.card.html +3 -3
  114. package/components/overlays/overlays.card.html +3 -3
  115. package/components/textfield/textfield.card.html +3 -3
  116. package/dist/styles.min.css +312 -64
  117. package/docs/ci-cd.md +18 -13
  118. package/docs/consumer-grant.md +3 -0
  119. package/docs/consuming.md +7 -7
  120. package/docs/contrast-report.md +79 -1
  121. package/docs/conventions.md +17 -4
  122. package/docs/decisions.md +40 -2
  123. package/docs/deploy.md +17 -6
  124. package/docs/design-styles.md +1 -1
  125. package/docs/quality-gates.md +19 -11
  126. package/docs/release-runbook.md +78 -0
  127. package/docs/sync.md +1 -1
  128. package/docs/vi/ci-cd.md +16 -13
  129. package/docs/vi/consumer-grant.md +3 -0
  130. package/docs/vi/consuming.md +7 -7
  131. package/docs/vi/contrast-report.md +79 -1
  132. package/docs/vi/conventions.md +18 -5
  133. package/docs/vi/decisions.md +39 -2
  134. package/docs/vi/deploy.md +17 -6
  135. package/docs/vi/quality-gates.md +18 -11
  136. package/docs/vi/release-runbook.md +78 -0
  137. package/docs/vi/sync.md +1 -1
  138. package/docs/viewer.html +2 -2
  139. package/fonts/OFL.txt +98 -0
  140. package/guidelines/atomic-view.html +2 -2
  141. package/guidelines/brand-imagery.html +1 -1
  142. package/guidelines/elements-variants.html +5 -5
  143. package/guidelines/spacing-breakpoints.html +2 -1
  144. package/guidelines/surfaces-glass.html +1 -1
  145. package/llms.txt +4 -4
  146. package/package.json +28 -8
  147. package/templates/app-shell/AppShell.dc.html +3 -3
  148. package/templates/app-shell/content-schema.json +7 -0
  149. package/templates/article/Article.dc.html +3 -3
  150. package/templates/article/content-schema.json +7 -0
  151. package/templates/auth/Auth.dc.html +26 -10
  152. package/templates/auth/content-schema.json +25 -0
  153. package/templates/dashboard/Dashboard.dc.html +4 -4
  154. package/templates/dashboard/content-schema.json +7 -0
  155. package/templates/delivery-change-note/DeliveryChangeNote.dc.html +2 -2
  156. package/templates/delivery-runbook/DeliveryRunbook.dc.html +2 -2
  157. package/templates/email-safe/email-announcement.html +2 -2
  158. package/templates/email-safe/email-dunning.html +2 -2
  159. package/templates/email-safe/email-investor.html +2 -2
  160. package/templates/email-safe/email-launch.html +2 -2
  161. package/templates/email-safe/email-newsletter.html +2 -2
  162. package/templates/email-safe/email-status.html +2 -2
  163. package/templates/email-safe/email-transactional.html +2 -2
  164. package/templates/finance-dunning-email/FinanceDunningEmail.dc.html +2 -2
  165. package/templates/finance-expense-report/FinanceExpenseReport.dc.html +2 -2
  166. package/templates/finance-invoice/FinanceInvoice.dc.html +2 -2
  167. package/templates/finance-quote/FinanceQuote.dc.html +4 -4
  168. package/templates/hr-interview-kit/HrInterviewKit.dc.html +1 -1
  169. package/templates/marketing-case-study/MarketingCaseStudy.dc.html +1 -1
  170. package/templates/marketing-page/MarketingPage.dc.html +3 -3
  171. package/templates/marketing-page/content-schema.json +1 -0
  172. package/templates/settings/Settings.dc.html +3 -3
  173. package/templates/settings/content-schema.json +7 -0
  174. package/tokens/element-seeds.json +2 -2
  175. package/tokens/elements.css +193 -19
  176. package/tokens/native/CSTokens.kt +3 -2
  177. package/tokens/native/CSTokens.swift +3 -2
  178. package/tokens/native/cs_tokens.dart +3 -2
  179. package/tokens/provenance.json +8 -8
  180. package/tokens/spacing.css +2 -1
  181. package/tokens/tokens.dtcg.json +19 -14
  182. package/tokens/tokens.js +16 -15
  183. package/tokens/tokens.json +15 -14
  184. package/ui_kits/deck/index.html +1 -1
  185. package/ui_kits/status-hub/StatusHub.jsx +1 -1
  186. package/ui_kits/status-hub/login.html +52 -10
  187. package/ui_kits/status-hub/project.html +1 -1
  188. package/ui_kits/status-hub/settings.html +1 -1
  189. package/ui_kits/website/Website.jsx +1 -1
  190. package/ui_kits/website/careers.html +2 -2
  191. package/ui_kits/website/chat.html +1 -1
  192. package/ui_kits/website/site.css +1 -1
  193. package/ui_kits/website/work.html +2 -2
  194. package/assets/aurora-hoa.png +0 -0
  195. package/assets/aurora-kim.png +0 -0
  196. package/assets/aurora-moc.png +0 -0
  197. package/assets/aurora-thuy.png +0 -0
  198. package/docs/decisions/container-query-migration.md +0 -18
  199. package/docs/decisions/overlay-manager-design.md +0 -101
  200. package/docs/plans/cyberskill-design-system-audit-and-12-month-evolution-plan.md +0 -1490
  201. package/docs/status/assets/favicon.svg +0 -5
  202. package/docs/status/assets/status-legacy.css +0 -399
  203. package/docs/status/assets/status-legacy.js +0 -505
  204. package/docs/status/assets/status.css +0 -644
  205. package/docs/status/assets/status.js +0 -1308
  206. package/docs/status/data/status-feed.json +0 -1
  207. package/docs/status/data/task/TASK-REL-001.js +0 -1
  208. package/docs/status/data/task/TASK-REL-002.js +0 -1
  209. package/docs/status/index.html +0 -46
  210. package/docs/status/roadmap.html +0 -4
  211. package/docs/status/status-legacy.html +0 -122
  212. package/docs/tasks/BACKLOG.md +0 -26
  213. package/docs/tasks/_state/commit-links.yaml +0 -14
  214. package/docs/tasks/release/TASK-REL-001-launch-1-1-0/spec.md +0 -55
  215. package/docs/tasks/release/TASK-REL-002-republish-1-1-1/spec.md +0 -54
@@ -9,6 +9,8 @@ export interface FileUploadProps {
9
9
  multiple?: boolean;
10
10
  onFiles?: (files: File[]) => void;
11
11
  icon?: ReactNode;
12
+ /** "en" | "vi" — else resolved from the nearest [lang] ancestor (vi default). */
13
+ lang?: string;
12
14
  className?: string;
13
15
  }
14
16
  export function FileUpload(props: FileUploadProps): React.ReactElement;
@@ -13,7 +13,9 @@ export interface FormProps {
13
13
  errors?: Record<string, string | undefined>;
14
14
  /** name → rule or rule[]; validated on submit. */
15
15
  rules?: Record<string, FormRule | FormRule[]>;
16
- /** name → async (value, values) => message | null; runs after sync rules pass. */
16
+ /** name → async (value, values) => message | null; runs after sync rules pass.
17
+ * While awaiting, Form sets `is-pending` / `aria-busy`, disables controls via CSS,
18
+ * and ignores duplicate submits. Context exposes `pending: boolean`. */
17
19
  asyncRules?: Record<string, (value: unknown, values: Record<string, unknown>) => Promise<string | null | undefined>>;
18
20
  /** Seed for controller-registered fields. */
19
21
  initialValues?: Record<string, unknown>;
@@ -55,6 +55,7 @@ export function Form({ onSubmit, errors = {}, rules, asyncRules, initialValues,
55
55
  const [values, setValues] = React.useState(() => initialValues || {});
56
56
  const [ruleErrors, setRuleErrors] = React.useState({});
57
57
  const [pending, setPending] = React.useState(false);
58
+ const pendingRef = React.useRef(false);
58
59
  const setValue = React.useCallback((name, v) => {
59
60
  setValues((s) => (String(name).includes(".") ? setPath(s, name, v) : { ...s, [name]: v }));
60
61
  setRuleErrors((e) => (e[name] ? { ...e, [name]: undefined } : e));
@@ -96,20 +97,27 @@ export function Form({ onSubmit, errors = {}, rules, asyncRules, initialValues,
96
97
  return (
97
98
  <Ctx.Provider value={ctx}>
98
99
  <form {...props} ref={ref} className={cx("cs-form", pending && "is-pending", className)} noValidate
100
+ aria-busy={pending || undefined}
99
101
  onSubmit={async (e) => {
100
102
  e.preventDefault();
103
+ // FIND-015 / FIND-044 — lock re-entry while async validation / submit runs.
104
+ if (pendingRef.current) return;
101
105
  const fd = new FormData(e.currentTarget);
102
106
  const v = {}; fd.forEach((val, k) => { v[k] = val; });
103
107
  Object.assign(v, values); // controller-registered fields are the source of truth
104
108
  const errs = runRules(v);
105
109
  if (Object.keys(errs).some((k) => errs[k])) { setRuleErrors(errs); return; }
110
+ pendingRef.current = true;
106
111
  setPending(true);
107
112
  try {
108
113
  const aerrs = await runAsyncRules(v);
109
114
  setRuleErrors(aerrs);
110
115
  if (Object.keys(aerrs).some((k) => aerrs[k])) return;
111
116
  onSubmit && onSubmit(v);
112
- } finally { setPending(false); }
117
+ } finally {
118
+ pendingRef.current = false;
119
+ setPending(false);
120
+ }
113
121
  }}>
114
122
  {keys.length ? (
115
123
  <div className="cs-form__summary" role="alert">
@@ -10,7 +10,7 @@
10
10
 
11
11
  Manual mode unchanged: omit `rules`/`name`, pass `errors={{email:"…"}}` yourself.
12
12
 
13
- **asyncRules:** optional map of field name → async `(value, values) => message|null`. Runs after sync `rules` pass; blocks submit while pending.
13
+ **asyncRules:** optional map of field name → async `(value, values) => message|null`. Runs after sync `rules` pass; Form emits `is-pending` + `aria-busy`, locks controls, and refuses re-entry until the async pass finishes (FIND-015 / FIND-044). Controllers can also read `pending` from Form context.
14
14
 
15
15
  **FormFieldArray** — manage an array of row objects under `name` on the surrounding Form. Children render-prop: `({ index, item, remove, path })`.
16
16
 
@@ -1,4 +1,4 @@
1
- /** Textarea with @user suggestions (typeahead on the trailing "@word"). */
1
+ /** Textarea with @user suggestions (APG editable combobox / listbox). */
2
2
  export interface MentionsProps {
3
3
  value?: string;
4
4
  defaultValue?: string;
@@ -2,28 +2,80 @@ import React from "react";
2
2
  import { makeT, useLang } from "../_i18n/i18n.js";
3
3
  import { cx } from "../_utils/cx.js";
4
4
 
5
- /** CyberSkill Mentions — textarea that suggests @users while typing. */
5
+ let mentionsUid = 0;
6
+
7
+ /** CyberSkill Mentions — textarea that suggests @users while typing (APG editable combobox). */
6
8
  export function Mentions({ value, defaultValue = "", onChange, users = [], placeholder, rows = 3, lang, className }) {
7
9
  const [inner, setInner] = React.useState(defaultValue);
8
10
  const val = value != null ? value : inner;
9
11
  const set = (v) => { if (value == null) setInner(v); onChange && onChange(v); };
10
12
  const [q, setQ] = React.useState(null);
13
+ const [activeIndex, setActiveIndex] = React.useState(0);
14
+ const [listId] = React.useState(() => "cs-mentions-" + (++mentionsUid));
11
15
  const [ref, L] = useLang(lang);
12
16
  const t = makeT("Mentions", L);
13
17
  const ph = placeholder != null ? placeholder : t("placeholder");
18
+ const hits = q == null ? [] : users.filter((u) => u.toLowerCase().includes(q)).slice(0, 6);
19
+ const open = hits.length > 0;
20
+
21
+ React.useEffect(() => {
22
+ setActiveIndex((i) => (hits.length ? Math.min(i, hits.length - 1) : 0));
23
+ }, [q, users]);
24
+
14
25
  const onInput = (v) => {
15
26
  set(v);
16
27
  const m = /(^|\s)@(\w*)$/.exec(v);
17
- setQ(m ? m[2].toLowerCase() : null);
28
+ const next = m ? m[2].toLowerCase() : null;
29
+ setQ(next);
30
+ if (next != null) setActiveIndex(0);
18
31
  };
19
- const hits = q == null ? [] : users.filter((u) => u.toLowerCase().includes(q)).slice(0, 6);
20
32
  const pick = (u) => { set(val.replace(/(^|\s)@\w*$/, "$1@" + u + " ")); setQ(null); };
33
+ const optionId = (i) => listId + "-opt-" + i;
34
+ const onKeyDown = (e) => {
35
+ if (e.nativeEvent.isComposing || e.keyCode === 229) return;
36
+ if (!open) return;
37
+ if (e.key === "ArrowDown") {
38
+ e.preventDefault();
39
+ setActiveIndex((i) => Math.min(hits.length - 1, i + 1));
40
+ } else if (e.key === "ArrowUp") {
41
+ e.preventDefault();
42
+ setActiveIndex((i) => Math.max(0, i - 1));
43
+ } else if (e.key === "Enter") {
44
+ if (hits[activeIndex]) { e.preventDefault(); pick(hits[activeIndex]); }
45
+ } else if (e.key === "Escape") {
46
+ e.preventDefault();
47
+ setQ(null);
48
+ }
49
+ };
21
50
  return (
22
51
  <span ref={ref} className={cx("cs-mentions", className)}>
23
- <textarea className="cs-field__control" rows={rows} value={val} placeholder={ph} onChange={(e) => onInput(e.target.value)} />
24
- {hits.length ? (
25
- <span className="cs-mentions__pop" role="listbox">
26
- {hits.map((u) => <button key={u} type="button" role="option" onMouseDown={(e) => { e.preventDefault(); pick(u); }}>@{u}</button>)}
52
+ <textarea
53
+ className="cs-field__control"
54
+ rows={rows}
55
+ value={val}
56
+ placeholder={ph}
57
+ role="combobox"
58
+ aria-autocomplete="list"
59
+ aria-expanded={open}
60
+ aria-controls={listId}
61
+ aria-activedescendant={open && hits[activeIndex] ? optionId(activeIndex) : undefined}
62
+ onChange={(e) => onInput(e.target.value)}
63
+ onKeyDown={onKeyDown}
64
+ />
65
+ {open ? (
66
+ <span className="cs-mentions__pop" role="listbox" id={listId}>
67
+ {hits.map((u, i) => (
68
+ <button
69
+ key={u}
70
+ type="button"
71
+ id={optionId(i)}
72
+ role="option"
73
+ aria-selected={i === activeIndex}
74
+ className={cx(i === activeIndex && "hl")}
75
+ onMouseEnter={() => setActiveIndex(i)}
76
+ onMouseDown={(e) => { e.preventDefault(); pick(u); }}
77
+ >@{u}</button>
78
+ ))}
27
79
  </span>
28
80
  ) : null}
29
81
  </span>
@@ -1,4 +1,6 @@
1
- **Mentions** — textarea suggesting @users as you type.
1
+ **Mentions** — textarea suggesting @users as you type (APG editable combobox).
2
+
3
+ Keyboard: ArrowUp/Down move the active suggestion (`aria-activedescendant`); Enter selects; Escape closes. Mouse click still picks.
2
4
 
3
5
  ```jsx
4
6
  <Mentions users={["anle","baovu","lumi"]} placeholder="Add a note — @ to mention"/>
@@ -1,5 +1,6 @@
1
- /** Whole-star rating (radiogroup). Controlled (value/onChange) or uncontrolled
2
- * (defaultValue). Clicking the current value clears to 0. readOnly for display. */
1
+ /** Whole-star rating (APG radiogroup: roving tabindex + arrow keys). Controlled
2
+ * (value/onChange) or uncontrolled (defaultValue). Clicking the current value
3
+ * clears to 0. readOnly for display. */
3
4
  export interface RatingProps {
4
5
  value?: number;
5
6
  defaultValue?: number;
@@ -3,17 +3,48 @@ import { makeT, useLang } from "../_i18n/i18n.js";
3
3
  import { cx } from "../_utils/cx.js";
4
4
  const STAR = "M12 2l2.9 6.2 6.6.8-4.9 4.6 1.3 6.5L12 16.9 6.1 20l1.3-6.5L2.5 9l6.6-.8z";
5
5
 
6
- /** CyberSkill Rating — whole-star rating. Controlled or uncontrolled; readOnly for display. Click the current value to clear. */
6
+ /** CyberSkill Rating — whole-star rating (APG radiogroup: roving tabindex + arrows). Controlled or uncontrolled; readOnly for display. Click the current value to clear. */
7
7
  export function Rating({ value, defaultValue = 0, onChange, max = 5, readOnly = false, label, lang, className }) {
8
8
  const [inner, setInner] = React.useState(defaultValue);
9
9
  const val = value != null ? value : inner;
10
- const set = (n) => { if (readOnly) return; const v = n === val ? 0 : n; if (value == null) setInner(v); onChange && onChange(v); };
10
+ const commit = (n) => { if (readOnly) return; if (value == null) setInner(n); onChange && onChange(n); };
11
+ const set = (n) => commit(n === val ? 0 : n);
11
12
  const [ref, L] = useLang(lang);
12
13
  const lbl = label != null ? label : makeT("Rating", L)("label");
14
+ const refs = React.useRef([]);
15
+ const focusIdx = val > 0 ? Math.min(max, val) - 1 : 0;
16
+
17
+ const move = (from, delta) => {
18
+ if (readOnly) return;
19
+ const next = Math.max(0, Math.min(max - 1, from + delta));
20
+ commit(next + 1);
21
+ const b = refs.current[next];
22
+ if (b) b.focus();
23
+ };
24
+
25
+ const onKeyDown = (e, i) => {
26
+ if (readOnly || e.nativeEvent.isComposing || e.keyCode === 229) return;
27
+ if (e.key === "ArrowRight" || e.key === "ArrowUp") { e.preventDefault(); move(i, 1); }
28
+ else if (e.key === "ArrowLeft" || e.key === "ArrowDown") { e.preventDefault(); move(i, -1); }
29
+ else if (e.key === "Home") { e.preventDefault(); move(i, -i); }
30
+ else if (e.key === "End") { e.preventDefault(); move(i, max - 1 - i); }
31
+ };
32
+
13
33
  return (
14
34
  <div ref={ref} className={cx("cs-rating", className)} role="radiogroup" aria-label={lbl + ": " + val + " / " + max} data-readonly={readOnly ? "true" : undefined}>
15
35
  {Array.from({ length: max }).map((_, i) => (
16
- <button key={i} type="button" role="radio" aria-checked={i < val} aria-label={(i + 1) + " / " + max} className={i < val ? "on" : undefined} onClick={() => set(i + 1)} tabIndex={readOnly ? -1 : 0}>
36
+ <button
37
+ key={i}
38
+ type="button"
39
+ role="radio"
40
+ aria-checked={i < val}
41
+ aria-label={(i + 1) + " / " + max}
42
+ className={i < val ? "on" : undefined}
43
+ onClick={() => set(i + 1)}
44
+ tabIndex={readOnly ? -1 : (i === focusIdx ? 0 : -1)}
45
+ ref={(el) => { refs.current[i] = el; }}
46
+ onKeyDown={(e) => onKeyDown(e, i)}
47
+ >
17
48
  <svg width="22" height="22" viewBox="0 0 24 24" fill={i < val ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" aria-hidden="true"><path d={STAR} /></svg>
18
49
  </button>
19
50
  ))}
@@ -1,4 +1,4 @@
1
- **Rating** — whole-star rating (ochre stars). Controlled or uncontrolled; `readOnly` for display; click current value to clear. Bilingual aria via the registry.
1
+ **Rating** — whole-star rating (ochre stars). APG radiogroup: one tab stop, ArrowLeft/Right (and Up/Down) change value. Controlled or uncontrolled; `readOnly` for display; click current value to clear. Bilingual aria via the registry.
2
2
 
3
3
  ```jsx
4
4
  <Rating defaultValue={4} onChange={setStars}/>
@@ -14,6 +14,8 @@ export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElemen
14
14
  /** Options as data; alternatively pass <option> children. */
15
15
  options?: SelectOption[];
16
16
  children?: ReactNode;
17
+ /** "en" | "vi" — else resolved from the nearest [lang] ancestor (vi default). */
18
+ lang?: string;
17
19
  }
18
20
 
19
21
  export function Select(props: SelectProps): React.ReactElement;
@@ -1,20 +1,24 @@
1
1
  import React from "react";
2
+ import { makeT, useLang } from "../_i18n/i18n.js";
2
3
  import { cx } from "../_utils/cx.js";
3
4
 
4
5
  /** CyberSkill Select — native <select> in the .cs-field frame with a chevron.
5
6
  * Pass `options` [{value,label}] or <option> children. */
6
- export function Select({ id, label, description, error, options, children, disabled = false, className, ...props }) {
7
+ export function Select({ id, label, description, error, options, children, disabled = false, lang, className, placeholder, ...props }) {
8
+ const [ref, L] = useLang(lang);
9
+ const t = makeT("Select", L);
7
10
  const gid = React.useId();
8
11
  const sid = id ?? gid;
9
12
  const descId = description ? sid + "-desc" : undefined;
10
13
  const errId = error ? sid + "-err" : undefined;
11
14
  const describedBy = [descId, errId].filter(Boolean).join(" ") || undefined;
12
15
  return (
13
- <label className={cx("cs-field", disabled && "is-disabled", error && "is-invalid", className)} htmlFor={sid}>
16
+ <label ref={ref} className={cx("cs-field", disabled && "is-disabled", error && "is-invalid", className)} htmlFor={sid}>
14
17
  {label ? <span className="cs-field__label">{label}</span> : null}
15
18
  {description ? <span id={descId} className="cs-field__description">{description}</span> : null}
16
19
  <span className="cs-select">
17
20
  <select {...props} id={sid} disabled={disabled} aria-invalid={error ? true : undefined} aria-describedby={describedBy} className="cs-field__control">
21
+ {placeholder != null || !children ? <option value="" disabled hidden>{placeholder ?? t("placeholder")}</option> : null}
18
22
  {options ? options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>) : children}
19
23
  </select>
20
24
  <span className="cs-select__chevron" aria-hidden="true">
@@ -4,3 +4,5 @@ import type * as React from "react";
4
4
  * range props (min, max, step, value, defaultValue, onChange). */
5
5
  export interface SliderProps extends React.InputHTMLAttributes<HTMLInputElement> {}
6
6
  export function Slider(props: SliderProps): React.ReactElement;
7
+ /** "en" | "vi" — else resolved from the nearest [lang] ancestor (vi default). */
8
+ lang?: string;
@@ -1,8 +1,11 @@
1
1
  import React from "react";
2
+ import { makeT, useLang } from "../_i18n/i18n.js";
2
3
  import { cx } from "../_utils/cx.js";
3
4
 
4
5
  /** CyberSkill Slider — brand-tinted native range input. Pass native props (min/max/step/value/onChange). */
5
6
  // `children` is destructured but never rendered on purpose: keeps stray children out of {...props} → void <input>.
6
- export function Slider({ className, children, ...props }) {
7
- return <input type="range" className={cx("cs-slider", className)} {...props} />;
7
+ export function Slider({ className, children, lang, "aria-label": ariaLabel, ...props }) {
8
+ const [ref, L] = useLang(lang);
9
+ const t = makeT("Slider", L);
10
+ return <input ref={ref} type="range" className={cx("cs-slider", className)} aria-label={ariaLabel ?? t("label")} {...props} />;
8
11
  }
@@ -7,6 +7,8 @@ import type { ReactNode } from "react";
7
7
  */
8
8
  export interface SwitchProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
9
9
  label?: ReactNode;
10
+ /** "en" | "vi" — else resolved from the nearest [lang] ancestor (vi default). */
11
+ lang?: string;
10
12
  }
11
13
 
12
14
  export function Switch(props: SwitchProps): React.ReactElement;
@@ -1,23 +1,21 @@
1
1
  import React from "react";
2
+ import { makeT, useLang } from "../_i18n/i18n.js";
2
3
  import { cx } from "../_utils/cx.js";
3
4
 
4
- let warnedNameless = false;
5
-
6
5
  /** CyberSkill Switch — accessible toggle (role="switch"); Umber on, Ochre knob-track in dark. */
7
6
  // `children` is destructured but never rendered on purpose: keeps stray children out of {...props} → void <input>.
8
- export function Switch({ label, disabled = false, className, children, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, ...props }) {
7
+ export function Switch({ label, disabled = false, className, children, lang, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, ...props }) {
8
+ const [ref, L] = useLang(lang);
9
+ const t = makeT("Switch", L);
9
10
  const named = !!(label || ariaLabel || ariaLabelledby);
10
- if (!named && !warnedNameless && typeof console !== "undefined" && console.warn) {
11
- warnedNameless = true;
12
- console.warn("CyberSkill Switch: render with label, aria-label, or aria-labelledby.");
13
- }
11
+ const fallback = props.checked || props.defaultChecked ? t("on") : t("off");
14
12
  return (
15
- <label className={cx("cs-switch", disabled && "is-disabled", className)}>
13
+ <label ref={ref} className={cx("cs-switch", disabled && "is-disabled", className)}>
16
14
  <input
17
15
  type="checkbox"
18
16
  role="switch"
19
17
  disabled={disabled}
20
- aria-label={ariaLabel}
18
+ aria-label={ariaLabel ?? (named ? undefined : fallback)}
21
19
  aria-labelledby={ariaLabelledby}
22
20
  {...props}
23
21
  />
@@ -11,6 +11,8 @@ export interface ToggleProps {
11
11
  icon?: ReactNode;
12
12
  children?: ReactNode;
13
13
  disabled?: boolean;
14
+ /** "en" | "vi" — else resolved from the nearest [lang] ancestor (vi default). */
15
+ lang?: string;
14
16
  className?: string;
15
17
  }
16
18
  export function Toggle(props: ToggleProps): React.ReactElement;
@@ -1,13 +1,25 @@
1
1
  import React from "react";
2
+ import { makeT, useLang } from "../_i18n/i18n.js";
2
3
  import { cx } from "../_utils/cx.js";
3
4
 
4
5
  /** CyberSkill Toggle — a single pressable on/off button (≠ Switch: no form semantics; ≠ SegmentedControl: standalone). */
5
- export function Toggle({ pressed, defaultPressed = false, onChange, icon, children, disabled = false, className, ...props }) {
6
+ export function Toggle({ pressed, defaultPressed = false, onChange, icon, children, disabled = false, lang, className, "aria-label": ariaLabel, ...props }) {
6
7
  const [inner, setInner] = React.useState(defaultPressed);
7
8
  const on = pressed != null ? pressed : inner;
9
+ const [ref, L] = useLang(lang);
10
+ const t = makeT("Toggle", L);
8
11
  const flip = () => { const v = !on; if (pressed == null) setInner(v); onChange && onChange(v); };
9
12
  return (
10
- <button type="button" className={cx("cs-toggle", className)} aria-pressed={on} disabled={disabled} onClick={flip} {...props}>
13
+ <button
14
+ ref={ref}
15
+ type="button"
16
+ className={cx("cs-toggle", className)}
17
+ aria-pressed={on}
18
+ aria-label={ariaLabel ?? (on ? t("pressed") : t("unpressed"))}
19
+ disabled={disabled}
20
+ onClick={flip}
21
+ {...props}
22
+ >
11
23
  {icon ? <span aria-hidden="true" style={{ display: "inline-flex" }}>{icon}</span> : null}
12
24
  {children}
13
25
  </button>
@@ -10,9 +10,9 @@
10
10
  .row { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
11
11
  .lbl { width: 100%; font: 600 11px/1 var(--cs-font-family-ui); text-transform: uppercase; letter-spacing: .09em; color: var(--cs-color-text-muted); }
12
12
  </style>
13
- <script src="https://unpkg.com/umd-react@19.2.8/dist/react.development.js" integrity="sha384-r22tQd8qRIHDPIkmdXZcVtvPi1Tj2x1QaW2tmFQHEvHscATUnHQ5siTyxWw5PMUs" crossorigin="anonymous"></script>
14
- <script src="https://unpkg.com/umd-react@19.2.8/dist/react-dom.development.js" integrity="sha384-gJZYmumEmwqVmX3JVsLZq+4QrxoPGh/BW47Rk7SqNPCPFpTGnRtvmeRPJ7//ZDPk" crossorigin="anonymous"></script>
15
- <script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
13
+ <script src="../../_vendor/react/react.production.min.js"></script>
14
+ <script src="../../_vendor/react/react-dom.production.min.js"></script>
15
+ <script src="../../_vendor/babel/babel.min.js"></script>
16
16
  <script src="../../_ds_bundle.js"></script>
17
17
  </head>
18
18
  <body>
@@ -9,9 +9,9 @@
9
9
  body { margin: 0; background: var(--cs-color-surface-page); padding: 22px 24px; }
10
10
  .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 22px 28px; max-width: 640px; align-items: start; }
11
11
  </style>
12
- <script src="https://unpkg.com/umd-react@19.2.8/dist/react.development.js" integrity="sha384-r22tQd8qRIHDPIkmdXZcVtvPi1Tj2x1QaW2tmFQHEvHscATUnHQ5siTyxWw5PMUs" crossorigin="anonymous"></script>
13
- <script src="https://unpkg.com/umd-react@19.2.8/dist/react-dom.development.js" integrity="sha384-gJZYmumEmwqVmX3JVsLZq+4QrxoPGh/BW47Rk7SqNPCPFpTGnRtvmeRPJ7//ZDPk" crossorigin="anonymous"></script>
14
- <script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
12
+ <script src="../../_vendor/react/react.production.min.js"></script>
13
+ <script src="../../_vendor/react/react-dom.production.min.js"></script>
14
+ <script src="../../_vendor/babel/babel.min.js"></script>
15
15
  <script src="../../_ds_bundle.js"></script>
16
16
  </head>
17
17
  <body>
@@ -6,7 +6,7 @@ export type IconName =
6
6
  | "search" | "sliders" | "upload" | "download" | "calendar"
7
7
  | "user" | "plus" | "trash" | "external" | "menu"
8
8
  | "chevron-down" | "chevron-up" | "chevron-left" | "chevron-right"
9
- | "edit" | "copy" | "info" | "alert-triangle";
9
+ | "edit" | "copy" | "info" | "alert-triangle" | "pin";
10
10
 
11
11
  /**
12
12
  * CyberSkill line-icon renderer. One component drives the whole in-repo icon set
@@ -5,4 +5,4 @@
5
5
  <Icon name="sparkle" size="lg" label="Wish" /> {/* meaningful */}
6
6
  ```
7
7
 
8
- Set: `close · sun · moon · arrow-right · check · sparkle · chat · sound-on · sound-off` + v2.0 extension `search · sliders · upload · download · calendar · user · plus · trash · external · menu` + v2.13 `chevron-down/up/left/right · edit · copy · info · alert-triangle` (same grammar). `sparkle` is the brand's "wish" glyph. Colour follows the surrounding text token — set `color` on a parent to recolour.
8
+ Set: `close · sun · moon · arrow-right · check · sparkle · chat · sound-on · sound-off` + v2.0 extension `search · sliders · upload · download · calendar · user · plus · trash · external · menu` + v2.13 `chevron-down/up/left/right · edit · copy · info · alert-triangle · pin` (same grammar). `sparkle` is the brand's "wish" glyph. Colour follows the surrounding text token — set `color` on a parent to recolour.
@@ -13,9 +13,9 @@
13
13
  .cell .g { color: var(--cs-color-brand-umber); }
14
14
  .cell:nth-child(6) .g { color: var(--cs-color-brand-ochre); }
15
15
  </style>
16
- <script src="https://unpkg.com/umd-react@19.2.8/dist/react.development.js" integrity="sha384-r22tQd8qRIHDPIkmdXZcVtvPi1Tj2x1QaW2tmFQHEvHscATUnHQ5siTyxWw5PMUs" crossorigin="anonymous"></script>
17
- <script src="https://unpkg.com/umd-react@19.2.8/dist/react-dom.development.js" integrity="sha384-gJZYmumEmwqVmX3JVsLZq+4QrxoPGh/BW47Rk7SqNPCPFpTGnRtvmeRPJ7//ZDPk" crossorigin="anonymous"></script>
18
- <script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
16
+ <script src="../../_vendor/react/react.production.min.js"></script>
17
+ <script src="../../_vendor/react/react-dom.production.min.js"></script>
18
+ <script src="../../_vendor/babel/babel.min.js"></script>
19
19
  <script src="../../_ds_bundle.js"></script>
20
20
  </head>
21
21
  <body>
@@ -15,9 +15,9 @@
15
15
  .lockup b { font: 800 22px/1 var(--cs-font-family-ui); color: var(--cs-color-brand-umber); letter-spacing: -.01em; }
16
16
  .cap { font: 500 12px/1.4 var(--cs-font-family-ui); color: var(--cs-color-text-muted); }
17
17
  </style>
18
- <script src="https://unpkg.com/umd-react@19.2.8/dist/react.development.js" integrity="sha384-r22tQd8qRIHDPIkmdXZcVtvPi1Tj2x1QaW2tmFQHEvHscATUnHQ5siTyxWw5PMUs" crossorigin="anonymous"></script>
19
- <script src="https://unpkg.com/umd-react@19.2.8/dist/react-dom.development.js" integrity="sha384-gJZYmumEmwqVmX3JVsLZq+4QrxoPGh/BW47Rk7SqNPCPFpTGnRtvmeRPJ7//ZDPk" crossorigin="anonymous"></script>
20
- <script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
18
+ <script src="../../_vendor/react/react.production.min.js"></script>
19
+ <script src="../../_vendor/react/react-dom.production.min.js"></script>
20
+ <script src="../../_vendor/babel/babel.min.js"></script>
21
21
  <script src="../../_ds_bundle.js"></script>
22
22
  </head>
23
23
  <body>
@@ -14,6 +14,8 @@ export interface CommandPaletteProps {
14
14
  onClose?: () => void;
15
15
  placeholder?: string;
16
16
  groups: CommandGroup[];
17
+ /** "en" | "vi" — else resolved from the nearest [lang] ancestor (vi default). */
18
+ lang?: string;
17
19
  className?: string;
18
20
  }
19
21
  export function CommandPalette(props: CommandPaletteProps): React.ReactElement | null;
@@ -12,6 +12,8 @@ export interface MenuProps {
12
12
  align?: "start" | "end";
13
13
  open?: boolean;
14
14
  onOpenChange?: (open: boolean) => void;
15
+ /** "en" | "vi" — else resolved from the nearest [lang] ancestor (vi default). */
16
+ lang?: string;
15
17
  className?: string;
16
18
  }
17
19
  export function Menu(props: MenuProps): React.ReactElement;
@@ -1,25 +1,72 @@
1
1
  import React from "react";
2
+ import { makeT, useLang } from "../_i18n/i18n.js";
2
3
  import { cx } from "../_utils/cx.js";
3
4
 
4
5
  /** CyberSkill Menu — dropdown. Provide a `trigger` element; compose MenuItem children. */
5
- export function Menu({ trigger, children, align = "start", open: controlledOpen, onOpenChange, className }) {
6
+ export function Menu({ trigger, children, align = "start", open: controlledOpen, onOpenChange, lang, className }) {
6
7
  const [uOpen, setUOpen] = React.useState(false);
7
8
  const open = controlledOpen != null ? controlledOpen : uOpen;
8
9
  const set = (v) => { onOpenChange ? onOpenChange(v) : setUOpen(v); };
9
- const ref = React.useRef(null);
10
+ const [ref, L] = useLang(lang);
11
+ const t = makeT("Menu", L);
12
+ const triggerRef = React.useRef(null);
13
+ const listRef = React.useRef(null);
14
+
10
15
  React.useEffect(() => {
11
16
  if (!open) return;
17
+ const items = () => listRef.current
18
+ ? [...listRef.current.querySelectorAll('[role="menuitem"]:not([disabled])')]
19
+ : [];
20
+ // FIND-040 — move focus into the menu on open.
21
+ const first = items()[0];
22
+ if (first && first.focus) first.focus();
23
+
12
24
  const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) set(false); };
13
- const onKey = (e) => { if (e.key === "Escape") set(false); };
25
+ const onKey = (e) => {
26
+ const opts = items();
27
+ if (e.key === "Escape") {
28
+ e.preventDefault();
29
+ set(false);
30
+ const el = triggerRef.current;
31
+ if (el && el.focus) el.focus();
32
+ return;
33
+ }
34
+ if (!opts.length) return;
35
+ const active = document.activeElement;
36
+ const idx = opts.indexOf(active);
37
+ if (e.key === "ArrowDown") {
38
+ e.preventDefault();
39
+ (opts[idx < 0 ? 0 : Math.min(opts.length - 1, idx + 1)] || opts[0]).focus();
40
+ } else if (e.key === "ArrowUp") {
41
+ e.preventDefault();
42
+ (opts[idx < 0 ? opts.length - 1 : Math.max(0, idx - 1)] || opts[0]).focus();
43
+ } else if (e.key === "Home") {
44
+ e.preventDefault();
45
+ opts[0].focus();
46
+ } else if (e.key === "End") {
47
+ e.preventDefault();
48
+ opts[opts.length - 1].focus();
49
+ }
50
+ };
14
51
  document.addEventListener("mousedown", onDoc);
15
52
  document.addEventListener("keydown", onKey);
16
- return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
53
+ return () => {
54
+ document.removeEventListener("mousedown", onDoc);
55
+ document.removeEventListener("keydown", onKey);
56
+ };
17
57
  }, [open]);
58
+
18
59
  const toggle = () => set(!open);
19
60
  // ARIA attrs must live on the interactive trigger (button), not a wrapping <span>
20
61
  // — axe aria-allowed-attr flags aria-haspopup/aria-expanded on generic elements.
21
62
  const triggerNode = React.isValidElement(trigger)
22
63
  ? React.cloneElement(trigger, {
64
+ ref: (node) => {
65
+ triggerRef.current = node;
66
+ const r = trigger.ref;
67
+ if (typeof r === "function") r(node);
68
+ else if (r && typeof r === "object") r.current = node;
69
+ },
23
70
  "aria-haspopup": "menu",
24
71
  "aria-expanded": open,
25
72
  onClick: (e) => {
@@ -27,12 +74,19 @@ export function Menu({ trigger, children, align = "start", open: controlledOpen,
27
74
  if (!e.defaultPrevented) toggle();
28
75
  },
29
76
  })
30
- : <button type="button" aria-haspopup="menu" aria-expanded={open} onClick={toggle}>{trigger}</button>;
77
+ : <button type="button" ref={triggerRef} aria-haspopup="menu" aria-expanded={open} onClick={toggle}>{trigger}</button>;
31
78
  return (
32
79
  <div className={cx("cs-menu", className)} ref={ref}>
33
80
  {triggerNode}
34
81
  {open ? (
35
- <div className={cx("cs-menu__list", align === "end" && "cs-menu__list--end")} role="menu">{children}</div>
82
+ <div
83
+ ref={listRef}
84
+ className={cx("cs-menu__list", align === "end" && "cs-menu__list--end")}
85
+ role="menu"
86
+ aria-label={t("menu")}
87
+ >
88
+ {children}
89
+ </div>
36
90
  ) : null}
37
91
  </div>
38
92
  );
@@ -8,6 +8,8 @@ export interface StepItem { title: ReactNode; body?: ReactNode; n?: ReactNode; }
8
8
  export interface StepsProps {
9
9
  steps: StepItem[];
10
10
  current?: number;
11
+ /** "en" | "vi" — else resolved from the nearest [lang] ancestor (vi default). */
12
+ lang?: string;
11
13
  className?: string;
12
14
  }
13
15
  export function Steps(props: StepsProps): React.ReactElement;
@@ -1,14 +1,17 @@
1
1
  import React from "react";
2
+ import { makeT, useLang } from "../_i18n/i18n.js";
2
3
  import { cx } from "../_utils/cx.js";
3
4
 
4
5
  /** CyberSkill Steps — horizontal progress stepper. Steps before `current` are done, `current` is active. */
5
- export function Steps({ steps = [], current = 0, className }) {
6
+ export function Steps({ steps = [], current = 0, lang, className }) {
7
+ const [ref, L] = useLang(lang);
8
+ const t = makeT("Steps", L);
6
9
  return (
7
- <div className={cx("cs-steps", className)}>
10
+ <div ref={ref} className={cx("cs-steps", className)} role="list" aria-label={t("label")}>
8
11
  {steps.map((s, i) => {
9
12
  const state = i < current ? "done" : i === current ? "active" : "todo";
10
13
  return (
11
- <div key={i} className={cx("cs-step", `cs-step--${state}`)}>
14
+ <div key={i} className={cx("cs-step", `cs-step--${state}`)} role="listitem">
12
15
  <span className="cs-step__marker">{state === "done" ? "✓" : s.n || i + 1}</span>
13
16
  <span className="cs-step__title">{s.title}</span>
14
17
  {s.body ? <span className="cs-step__body">{s.body}</span> : null}
@@ -11,6 +11,8 @@ export interface TabsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "o
11
11
  tabs: TabItem[];
12
12
  value?: string;
13
13
  onChange?: (value: string) => void;
14
+ /** "en" | "vi" — else resolved from the nearest [lang] ancestor (vi default). */
15
+ lang?: string;
14
16
  }
15
17
  export function Tabs(props: TabsProps): React.ReactElement;
16
18