@tuwaio/docs-ui 0.5.0 → 0.5.2

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.
package/README.md CHANGED
@@ -12,6 +12,7 @@
12
12
  - **Nextra layout parts:** `Navbar` and `Footer` wrap the navbar and footer of `nextra-theme-docs` with the site logo and a row of external links (`baseNavLinks`, `baseFooterLinks` are the defaults: the npm and GitHub organizations of TUWA). The footer also links the policies in force on tuwa.io and the list of all policies on the docs hub.
13
13
  - **Logo:** `RemoteLogo` is an async React Server Component that fetches an SVG (by default the TUWA logo) and renders it as React elements, so it takes `className` and `currentColor`.
14
14
  - **Theme switch:** `ThemeSwitcher` is a light/dark switch for any theme state (for example `next-themes`).
15
+ - **Language select:** `LocaleSwitcher` is an accessible glassmorphic select dropdown for switching languages (English, Українська, 简体中文, etc.), styled to match `ThemeSwitcher`. Features zero layout shift, Nova UI Kit animated directional chevron, collision detection with auto-flip (`placement="auto"`), multi-layout Latin typeahead navigation (independent of user OS keyboard language), and an invisible native `<select>` overlay on touch devices (`pointer: coarse`) for native mobile system pickers (iOS wheel, Android sheet). Full WCAG 2.1/2.2 AA/AAA compliance. It renders an invisible placeholder during SSR to avoid hydration shifts.
15
16
  - **Interface settings for 30 days:** `PreferenceExpiry` is an inline script for the `<head>` of a site's root layout. It forgets the Local Storage values of the given keys (by default `theme`) 30 days after they were written, as the [Cookie Policy](https://tuwa.io/cookie) states, and dates each later write, whatever library writes it (`next-themes`, the `Tabs` of Nextra).
16
17
  - **Decoration:** `Orb` (an animated sphere in the brand color of a TUWA project), `StarryBackground` (an animated star field on a canvas) and `NoSSR` (renders its children only on the client).
17
18
  - **Policy pages:** `policies` lists the TUWA policies (Terms, Privacy, Cookie, Refund, Security, Data Processing Addendum) with their paths and whether they are in force (`publishedPolicies`; footers use `footerPolicies`). `loadPolicy` loads the text of a policy in a language, and `PolicyDocument` renders it with the date of the version, a Download PDF button, a language switch (English, Українська) when the policy is translated, anchored sections (the same anchors in every language) and a list of sections. The texts and PDFs come from [`TuwaIO/workflows/docs`](https://github.com/TuwaIO/workflows/tree/main/docs). Code that needs only the list or `loadPolicy` (footers, sitemaps, route handlers) imports them from `@tuwaio/docs-ui/policies`, which has no React, Next.js or Nextra imports.
@@ -143,11 +144,11 @@ It writes the index to `public/_pagefind` (keep that directory in `.gitignore`)
143
144
 
144
145
  ## 🗄️ Browser Storage
145
146
 
146
- `ThemeSwitcher` stores nothing; it only calls `onToggle`, and the site keeps the theme. `PreferenceExpiry` stores `<key>:expires` (the time in epoch milliseconds) next to each key it is given, removes both once that time has passed, and wraps `Storage.prototype.setItem` on the page to date later writes of those keys.
147
+ `LocaleSwitcher` and `ThemeSwitcher` store nothing; they only call `onSelect` / `onToggle`, and the site keeps the choice. `PreferenceExpiry` stores `<key>:expires` (the time in epoch milliseconds) next to each key it is given, removes both once that time has passed, and wraps `Storage.prototype.setItem` on the page to date later writes of those keys.
147
148
 
148
149
  ## 🌐 External Services
149
150
 
150
- `RemoteLogo` requests its `url` on the server (by default `cdn.jsdelivr.net/gh/TuwaIO/workflows@main/preview/logo_v2.svg`), cached by Next.js for 24 hours. `loadPolicy` requests `raw.githubusercontent.com/TuwaIO/workflows/main/docs/<slug>.json` (a translation: `docs/<language>/<slug>.json`, and the English file when it does not exist) on the server, cached by Next.js for an hour by default. The Download PDF button of `PolicyDocument` links to the PDF on the same host; the browser requests it only when it is clicked. The other components send no requests.
151
+ `RemoteLogo` requests its `url` on the server (by default `tuwa.io/brand/logo_v2.svg`), cached by Next.js for 24 hours. `loadPolicy` requests `raw.githubusercontent.com/TuwaIO/workflows/main/docs/<slug>.json` (a translation: `docs/<language>/<slug>.json`, and the English file when it does not exist) on the server, cached by Next.js for an hour by default. The Download PDF button of `PolicyDocument` links to the PDF on the same host; the browser requests it only when it is clicked. The other components send no requests.
151
152
 
152
153
  ---
153
154
 
@@ -1,4 +1,4 @@
1
- import * as React from 'react';
1
+ import * as react from 'react';
2
2
  import { NavProps } from './NavBar.cjs';
3
3
 
4
4
  /**
@@ -10,6 +10,6 @@ import { NavProps } from './NavBar.cjs';
10
10
  * @param props - See {@link NavProps}.
11
11
  * @returns The footer.
12
12
  */
13
- declare function Footer({ links, logo }: NavProps): React.JSX.Element;
13
+ declare function Footer({ links, logo }: NavProps): react.JSX.Element;
14
14
 
15
15
  export { Footer };
@@ -1,4 +1,4 @@
1
- import * as React from 'react';
1
+ import * as react from 'react';
2
2
  import { NavProps } from './NavBar.js';
3
3
 
4
4
  /**
@@ -10,6 +10,6 @@ import { NavProps } from './NavBar.js';
10
10
  * @param props - See {@link NavProps}.
11
11
  * @returns The footer.
12
12
  */
13
- declare function Footer({ links, logo }: NavProps): React.JSX.Element;
13
+ declare function Footer({ links, logo }: NavProps): react.JSX.Element;
14
14
 
15
15
  export { Footer };
@@ -0,0 +1,474 @@
1
+ "use strict";
2
+ "use client";
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+ var __copyProps = (to, from, except, desc) => {
12
+ if (from && typeof from === "object" || typeof from === "function") {
13
+ for (let key of __getOwnPropNames(from))
14
+ if (!__hasOwnProp.call(to, key) && key !== except)
15
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
16
+ }
17
+ return to;
18
+ };
19
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
20
+ var LocaleSwitcher_exports = {};
21
+ __export(LocaleSwitcher_exports, {
22
+ DEFAULT_LOCALES: () => DEFAULT_LOCALES,
23
+ LocaleSwitcher: () => LocaleSwitcher,
24
+ getLocaleFlag: () => getLocaleFlag
25
+ });
26
+ module.exports = __toCommonJS(LocaleSwitcher_exports);
27
+ var import_jsx_runtime = require("react/jsx-runtime");
28
+ var import_utils = require("@tuwaio/nova-core/utils");
29
+ var import_react = require("react");
30
+ function FlagUK({ className }) {
31
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("svg", { viewBox: "0 0 640 480", className, "aria-hidden": "true", children: [
32
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { fill: "#012169", d: "M0 0h640v480H0z" }),
33
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { fill: "#FFF", d: "m75 0 245 180L565 0h75v60L435 240l205 180v60h-75L320 300 75 480H0v-60l205-180L0 60V0h75z" }),
34
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
35
+ "path",
36
+ {
37
+ fill: "#C8102E",
38
+ d: "m425 285 215 165v30h-35L380 300l45-15zM215 195 0 30v-30h35l225 180-45 15zm0 90L0 450v30h35l225-180-45-15zm210-90L640 30v-30h-35L380 180l45 15z"
39
+ }
40
+ ),
41
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { fill: "#FFF", d: "M250 0h140v480H250zM0 170h640v140H0z" }),
42
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { fill: "#C8102E", d: "M276 0h88v480h-88zM0 196h640v88H0z" })
43
+ ] });
44
+ }
45
+ function FlagUkraine({ className }) {
46
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("svg", { viewBox: "0 0 640 480", className, "aria-hidden": "true", children: [
47
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { fill: "#0057b7", d: "M0 0h640v240H0z" }),
48
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { fill: "#ffd700", d: "M0 240h640v240H0z" })
49
+ ] });
50
+ }
51
+ function FlagChina({ className }) {
52
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("svg", { viewBox: "0 0 640 480", className, "aria-hidden": "true", children: [
53
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("rect", { width: "640", height: "480", fill: "#ee1c25" }),
54
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
55
+ "path",
56
+ {
57
+ fill: "#ffde00",
58
+ d: "M120 72l14.1 43.4H180l-36.9 26.8 14.1 43.4-37.2-26.8-37.2 26.8 14.1-43.4L60 115.4h45.9z"
59
+ }
60
+ ),
61
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("circle", { cx: "200", cy: "40", r: "12", fill: "#ffde00" }),
62
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("circle", { cx: "240", cy: "80", r: "12", fill: "#ffde00" }),
63
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("circle", { cx: "240", cy: "140", r: "12", fill: "#ffde00" }),
64
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("circle", { cx: "200", cy: "180", r: "12", fill: "#ffde00" })
65
+ ] });
66
+ }
67
+ function ChevronArrowWithAnim({ isOpen, className }) {
68
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
69
+ "span",
70
+ {
71
+ className: (0, import_utils.cn)(
72
+ "tuwadocs:relative tuwadocs:inline-flex tuwadocs:h-3.5 tuwadocs:w-3.5 tuwadocs:items-center tuwadocs:justify-center tuwadocs:overflow-hidden tuwadocs:shrink-0",
73
+ className
74
+ ),
75
+ "aria-hidden": "true",
76
+ children: [
77
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
78
+ "svg",
79
+ {
80
+ viewBox: "0 0 24 24",
81
+ fill: "none",
82
+ strokeWidth: 2,
83
+ stroke: "currentColor",
84
+ className: (0, import_utils.cn)(
85
+ "tuwadocs:absolute tuwadocs:h-3.5 tuwadocs:w-3.5 tuwadocs:transition-all tuwadocs:duration-400 tuwadocs:ease-out tuwadocs:motion-reduce:transition-none tuwadocs:motion-reduce:transform-none",
86
+ isOpen ? "tuwadocs:opacity-0 -tuwadocs:translate-y-[3px] tuwadocs:scale-y-[0.8] tuwadocs:pointer-events-none" : "tuwadocs:opacity-100 tuwadocs:translate-y-0 tuwadocs:scale-y-100"
87
+ ),
88
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "m19.5 8.25-7.5 7.5-7.5-7.5" })
89
+ }
90
+ ),
91
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
92
+ "svg",
93
+ {
94
+ viewBox: "0 0 24 24",
95
+ fill: "none",
96
+ strokeWidth: 2,
97
+ stroke: "currentColor",
98
+ className: (0, import_utils.cn)(
99
+ "tuwadocs:absolute tuwadocs:h-3.5 tuwadocs:w-3.5 tuwadocs:transition-all tuwadocs:duration-400 tuwadocs:ease-out tuwadocs:motion-reduce:transition-none tuwadocs:motion-reduce:transform-none",
100
+ isOpen ? "tuwadocs:opacity-100 tuwadocs:translate-y-0 tuwadocs:scale-y-100" : "tuwadocs:opacity-0 tuwadocs:translate-y-[3px] tuwadocs:scale-y-[0.8] tuwadocs:pointer-events-none"
101
+ ),
102
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "m4.5 15.75 7.5-7.5 7.5 7.5" })
103
+ }
104
+ )
105
+ ]
106
+ }
107
+ );
108
+ }
109
+ function getLocaleFlag(code) {
110
+ const normalized = code.toLowerCase();
111
+ const flagClass = "tuwadocs:h-3 tuwadocs:w-4 tuwadocs:rounded-[2px] tuwadocs:object-cover tuwadocs:shadow-xs tuwadocs:border tuwadocs:border-black/10 tuwadocs:dark:border-white/20 tuwadocs:shrink-0";
112
+ if (normalized === "en" || normalized === "en-us" || normalized === "en-gb") {
113
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FlagUK, { className: flagClass });
114
+ }
115
+ if (normalized === "uk" || normalized === "ua") {
116
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FlagUkraine, { className: flagClass });
117
+ }
118
+ if (normalized === "zh" || normalized === "zh-cn" || normalized === "zh-hans") {
119
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FlagChina, { className: flagClass });
120
+ }
121
+ return void 0;
122
+ }
123
+ const DEFAULT_LOCALES = [
124
+ { code: "en", label: "English", short: "EN" },
125
+ { code: "uk", label: "\u0423\u043A\u0440\u0430\u0457\u043D\u0441\u044C\u043A\u0430", short: "UK" },
126
+ { code: "zh-CN", label: "\u7B80\u4F53\u4E2D\u6587", short: "ZH" }
127
+ ];
128
+ function resolveLatinChar(event) {
129
+ if (event.ctrlKey || event.metaKey || event.altKey) {
130
+ return null;
131
+ }
132
+ if (/^[a-zA-Z]$/.test(event.key)) {
133
+ return event.key.toLowerCase();
134
+ }
135
+ if (event.code && event.code.startsWith("Key")) {
136
+ return event.code.slice(3).toLowerCase();
137
+ }
138
+ return null;
139
+ }
140
+ function matchesLocaleOption(item, char) {
141
+ const code = item.code.toLowerCase();
142
+ const short = (item.short ?? "").toLowerCase();
143
+ const label = item.label.toLowerCase();
144
+ if (code.startsWith(char) || short.startsWith(char) || label.startsWith(char)) {
145
+ return true;
146
+ }
147
+ if ((code.startsWith("zh") || short === "zh") && (char === "z" || char === "c")) {
148
+ return true;
149
+ }
150
+ if ((code.startsWith("uk") || code.startsWith("ua") || short === "uk" || short === "ua") && char === "u") {
151
+ return true;
152
+ }
153
+ if ((code.startsWith("es") || short === "es") && char === "s") {
154
+ return true;
155
+ }
156
+ if ((code.startsWith("de") || short === "de") && char === "g") {
157
+ return true;
158
+ }
159
+ return false;
160
+ }
161
+ function LocaleSwitcher({
162
+ locale,
163
+ onSelect,
164
+ locales = DEFAULT_LOCALES,
165
+ placement: placementProp = "auto",
166
+ className,
167
+ ariaLabel = "Select language",
168
+ nativeTouch = true
169
+ }) {
170
+ const [mounted, setMounted] = (0, import_react.useState)(false);
171
+ const [isOpen, setIsOpen] = (0, import_react.useState)(false);
172
+ const [activeIndex, setActiveIndex] = (0, import_react.useState)(0);
173
+ const [resolvedPlacement, setResolvedPlacement] = (0, import_react.useState)("bottom");
174
+ const [isTouch, setIsTouch] = (0, import_react.useState)(false);
175
+ const listboxId = (0, import_react.useId)();
176
+ const containerRef = (0, import_react.useRef)(null);
177
+ const buttonRef = (0, import_react.useRef)(null);
178
+ const listRef = (0, import_react.useRef)(null);
179
+ (0, import_react.useEffect)(() => {
180
+ setMounted(true);
181
+ if (typeof window === "undefined") return;
182
+ const query = window.matchMedia("(pointer: coarse)");
183
+ const updateTouch = () => {
184
+ setIsTouch(query.matches && !window.matchMedia("(pointer: fine)").matches);
185
+ };
186
+ updateTouch();
187
+ query.addEventListener?.("change", updateTouch);
188
+ return () => {
189
+ query.removeEventListener?.("change", updateTouch);
190
+ };
191
+ }, []);
192
+ const updatePlacement = () => {
193
+ if (placementProp !== "auto") {
194
+ setResolvedPlacement(placementProp);
195
+ return;
196
+ }
197
+ if (buttonRef.current) {
198
+ const rect = buttonRef.current.getBoundingClientRect();
199
+ const spaceBelow = window.innerHeight - rect.bottom;
200
+ const spaceAbove = rect.top;
201
+ const estimatedHeight = Math.min(locales.length * 40, 260) + 16;
202
+ setResolvedPlacement(spaceBelow < estimatedHeight && spaceAbove > spaceBelow ? "top" : "bottom");
203
+ }
204
+ };
205
+ const openMenu = () => {
206
+ if (isTouch) return;
207
+ updatePlacement();
208
+ const currentIndex = locales.findIndex((item) => item.code === locale);
209
+ setActiveIndex(currentIndex >= 0 ? currentIndex : 0);
210
+ setIsOpen(true);
211
+ };
212
+ const closeMenu = () => {
213
+ setIsOpen(false);
214
+ buttonRef.current?.focus();
215
+ };
216
+ const pickLocale = (code) => {
217
+ closeMenu();
218
+ if (code !== locale) {
219
+ onSelect(code);
220
+ }
221
+ };
222
+ (0, import_react.useEffect)(() => {
223
+ if (!isOpen) return;
224
+ listRef.current?.focus();
225
+ const onPointerDown = (event) => {
226
+ if (!containerRef.current?.contains(event.target)) {
227
+ setIsOpen(false);
228
+ }
229
+ };
230
+ document.addEventListener("pointerdown", onPointerDown);
231
+ return () => {
232
+ document.removeEventListener("pointerdown", onPointerDown);
233
+ };
234
+ }, [isOpen]);
235
+ (0, import_react.useEffect)(() => {
236
+ if (!isOpen) return;
237
+ const activeEl = document.getElementById(`${listboxId}-option-${locales[activeIndex]?.code}`);
238
+ activeEl?.scrollIntoView({ block: "nearest" });
239
+ }, [activeIndex, isOpen, listboxId, locales]);
240
+ const handleKeyDown = (event) => {
241
+ const last = locales.length - 1;
242
+ if (event.key === "ArrowDown") {
243
+ event.preventDefault();
244
+ if (!isOpen) {
245
+ openMenu();
246
+ } else {
247
+ setActiveIndex((prev) => prev < last ? prev + 1 : 0);
248
+ }
249
+ return;
250
+ }
251
+ if (event.key === "ArrowUp") {
252
+ event.preventDefault();
253
+ if (!isOpen) {
254
+ openMenu();
255
+ } else {
256
+ setActiveIndex((prev) => prev > 0 ? prev - 1 : last);
257
+ }
258
+ return;
259
+ }
260
+ if (event.key === "Home" && isOpen) {
261
+ event.preventDefault();
262
+ setActiveIndex(0);
263
+ return;
264
+ }
265
+ if (event.key === "End" && isOpen) {
266
+ event.preventDefault();
267
+ setActiveIndex(last);
268
+ return;
269
+ }
270
+ if (event.key === "Enter" || event.key === " ") {
271
+ event.preventDefault();
272
+ if (!isOpen) {
273
+ openMenu();
274
+ } else {
275
+ const targetOption = locales[activeIndex];
276
+ if (targetOption) {
277
+ pickLocale(targetOption.code);
278
+ }
279
+ }
280
+ return;
281
+ }
282
+ if (event.key === "Escape") {
283
+ if (isOpen) {
284
+ event.preventDefault();
285
+ event.stopPropagation();
286
+ closeMenu();
287
+ }
288
+ return;
289
+ }
290
+ if (event.key === "Tab") {
291
+ if (isOpen) {
292
+ closeMenu();
293
+ }
294
+ return;
295
+ }
296
+ const char = resolveLatinChar(event);
297
+ if (char) {
298
+ const matchAfter = locales.findIndex((item, idx) => idx > activeIndex && matchesLocaleOption(item, char));
299
+ const matchFromStart = locales.findIndex((item) => matchesLocaleOption(item, char));
300
+ const targetIndex = matchAfter >= 0 ? matchAfter : matchFromStart;
301
+ if (targetIndex >= 0) {
302
+ event.preventDefault();
303
+ if (!isOpen) {
304
+ updatePlacement();
305
+ setActiveIndex(targetIndex);
306
+ setIsOpen(true);
307
+ } else {
308
+ setActiveIndex(targetIndex);
309
+ }
310
+ }
311
+ }
312
+ };
313
+ const defaultStyles = "tuwadocs:bg-black/5 tuwadocs:border-black/5 tuwadocs:hover:bg-black/10 tuwadocs:dark:bg-white/10 tuwadocs:dark:border-white/10 tuwadocs:dark:hover:bg-white/20";
314
+ const staticSize = "tuwadocs:h-10 tuwadocs:w-[5.25rem] tuwadocs:md:h-8 tuwadocs:md:w-[4.75rem]";
315
+ if (!mounted) {
316
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
317
+ "div",
318
+ {
319
+ className: (0, import_utils.cn)(
320
+ "tuwadocs:relative tuwadocs:flex tuwadocs:rounded-[var(--tuwa-rounded-corners)] tuwadocs:border tuwadocs:opacity-0",
321
+ staticSize,
322
+ defaultStyles,
323
+ className
324
+ )
325
+ }
326
+ );
327
+ }
328
+ const activeOption = locales.find((item) => item.code === locale) ?? locales[0];
329
+ const shortText = activeOption?.short ?? activeOption?.code?.slice(0, 2).toUpperCase() ?? "EN";
330
+ const activeFlag = activeOption?.flag ?? getLocaleFlag(activeOption?.code ?? "en");
331
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { ref: containerRef, className: (0, import_utils.cn)("tuwadocs:relative tuwadocs:inline-block", className), children: [
332
+ nativeTouch && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
333
+ "select",
334
+ {
335
+ value: locale,
336
+ onChange: (event) => pickLocale(event.target.value),
337
+ "aria-label": ariaLabel,
338
+ tabIndex: isTouch ? 0 : -1,
339
+ "aria-hidden": !isTouch,
340
+ className: "tuwadocs:absolute tuwadocs:inset-0 tuwadocs:z-10 tuwadocs:h-full tuwadocs:w-full tuwadocs:cursor-pointer tuwadocs:appearance-none tuwadocs:border-none tuwadocs:bg-transparent tuwadocs:opacity-0 tuwadocs:outline-none tuwadocs:hidden tuwadocs:pointer-coarse:block tuwadocs:pointer-fine:hidden",
341
+ children: locales.map((item) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("option", { value: item.code, lang: item.code, children: [
342
+ item.label,
343
+ " (",
344
+ item.short ?? item.code.toUpperCase(),
345
+ ")"
346
+ ] }, item.code))
347
+ }
348
+ ),
349
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
350
+ "button",
351
+ {
352
+ ref: buttonRef,
353
+ type: "button",
354
+ tabIndex: isTouch ? -1 : 0,
355
+ "aria-hidden": isTouch ? true : void 0,
356
+ onClick: () => isOpen ? closeMenu() : openMenu(),
357
+ onKeyDown: handleKeyDown,
358
+ className: (0, import_utils.cn)(
359
+ "tuwadocs:group tuwadocs:relative tuwadocs:flex tuwadocs:cursor-pointer tuwadocs:items-center tuwadocs:justify-between tuwadocs:rounded-[var(--tuwa-rounded-corners)] tuwadocs:px-2 tuwadocs:transition-all tuwadocs:duration-300 tuwadocs:ease-in-out tuwadocs:backdrop-blur-sm tuwadocs:border tuwadocs:motion-reduce:transition-none",
360
+ "tuwadocs:focus-visible:ring-2 tuwadocs:focus-visible:ring-[var(--tuwa-text-accent)] tuwadocs:focus-visible:outline-none tuwadocs:focus-visible:ring-offset-1 tuwadocs:focus-visible:ring-offset-[var(--tuwa-bg-primary)]",
361
+ staticSize,
362
+ defaultStyles
363
+ ),
364
+ "aria-haspopup": "listbox",
365
+ "aria-expanded": isOpen,
366
+ "aria-controls": isOpen ? listboxId : void 0,
367
+ children: [
368
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
369
+ "span",
370
+ {
371
+ "aria-hidden": "true",
372
+ className: "tuwadocs:flex tuwadocs:items-center tuwadocs:justify-center tuwadocs:shrink-0",
373
+ children: activeFlag
374
+ }
375
+ ),
376
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "tuwadocs:w-5 tuwadocs:text-center tuwadocs:font-mono tuwadocs:text-xs tuwadocs:font-bold tuwadocs:tracking-tight tuwadocs:text-[var(--tuwa-text-primary)] tuwadocs:shrink-0", children: shortText }),
377
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "tuwadocs:sr-only", children: [
378
+ ", ",
379
+ activeOption.label,
380
+ " \u2014 ",
381
+ ariaLabel
382
+ ] }),
383
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
384
+ ChevronArrowWithAnim,
385
+ {
386
+ isOpen,
387
+ className: "tuwadocs:text-[var(--tuwa-text-secondary)] tuwadocs:group-hover:text-[var(--tuwa-text-primary)]"
388
+ }
389
+ )
390
+ ]
391
+ }
392
+ ),
393
+ isOpen && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
394
+ "div",
395
+ {
396
+ ref: listRef,
397
+ id: listboxId,
398
+ role: "listbox",
399
+ tabIndex: -1,
400
+ "aria-label": ariaLabel,
401
+ "aria-activedescendant": `${listboxId}-option-${locales[activeIndex]?.code}`,
402
+ onKeyDown: handleKeyDown,
403
+ className: (0, import_utils.cn)(
404
+ "tuwadocs:absolute tuwadocs:right-0 tuwadocs:z-50 tuwadocs:min-w-[12rem] tuwadocs:max-h-[min(20rem,60vh)] tuwadocs:overflow-y-auto tuwadocs:overflow-x-hidden tuwadocs:rounded-[var(--tuwa-rounded-corners)] tuwadocs:border tuwadocs:border-[var(--tuwa-border-primary)] tuwadocs:bg-[var(--tuwa-bg-primary)]/95 tuwadocs:p-0 tuwadocs:shadow-xl tuwadocs:backdrop-blur-md tuwadocs:outline-none",
405
+ resolvedPlacement === "top" ? "tuwadocs:bottom-full tuwadocs:mb-1.5" : "tuwadocs:top-full tuwadocs:mt-1.5"
406
+ ),
407
+ children: locales.map((item, i) => {
408
+ const isSelected = item.code === locale;
409
+ const isFocused = i === activeIndex;
410
+ const flag = item.flag ?? getLocaleFlag(item.code);
411
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
412
+ "button",
413
+ {
414
+ id: `${listboxId}-option-${item.code}`,
415
+ type: "button",
416
+ role: "option",
417
+ lang: item.code,
418
+ tabIndex: -1,
419
+ "aria-selected": isSelected,
420
+ onClick: () => pickLocale(item.code),
421
+ onPointerMove: () => {
422
+ if (activeIndex !== i) setActiveIndex(i);
423
+ },
424
+ className: (0, import_utils.cn)(
425
+ "tuwadocs:relative tuwadocs:flex tuwadocs:w-full tuwadocs:cursor-pointer tuwadocs:items-center tuwadocs:justify-between tuwadocs:gap-2.5 tuwadocs:px-3.5 tuwadocs:py-2.5 tuwadocs:text-xs tuwadocs:transition-colors tuwadocs:outline-none",
426
+ "tuwadocs:focus-visible:ring-1 tuwadocs:focus-visible:ring-inset tuwadocs:focus-visible:ring-[var(--tuwa-text-accent)]",
427
+ isSelected ? isFocused ? "tuwadocs:bg-gradient-to-r tuwadocs:from-[var(--tuwa-text-accent)]/25 tuwadocs:via-[var(--tuwa-text-accent)]/10 tuwadocs:to-transparent tuwadocs:text-[var(--tuwa-text-primary)] tuwadocs:font-bold" : "tuwadocs:bg-gradient-to-r tuwadocs:from-[var(--tuwa-text-accent)]/15 tuwadocs:via-[var(--tuwa-text-accent)]/5 tuwadocs:to-transparent tuwadocs:text-[var(--tuwa-text-primary)] tuwadocs:font-bold" : isFocused ? "tuwadocs:bg-[var(--tuwa-bg-muted)]/70 tuwadocs:text-[var(--tuwa-text-primary)]" : "tuwadocs:text-[var(--tuwa-text-secondary)] tuwadocs:hover:bg-[var(--tuwa-bg-muted)]/50 tuwadocs:hover:text-[var(--tuwa-text-primary)]"
428
+ ),
429
+ children: [
430
+ isSelected && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
431
+ "span",
432
+ {
433
+ className: "tuwadocs:absolute tuwadocs:left-0 tuwadocs:top-0 tuwadocs:bottom-0 tuwadocs:w-[3px] tuwadocs:bg-[var(--tuwa-text-accent)] tuwadocs:shadow-[0_0_8px_var(--tuwa-text-accent)]",
434
+ "aria-hidden": "true"
435
+ }
436
+ ),
437
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "tuwadocs:flex tuwadocs:items-center tuwadocs:gap-2 tuwadocs:truncate", children: [
438
+ flag && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { "aria-hidden": "true", className: "tuwadocs:shrink-0", children: flag }),
439
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "tuwadocs:truncate", children: item.label })
440
+ ] }),
441
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "tuwadocs:flex tuwadocs:items-center tuwadocs:gap-1.5 tuwadocs:shrink-0", children: [
442
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
443
+ "span",
444
+ {
445
+ className: (0, import_utils.cn)(
446
+ "tuwadocs:font-mono tuwadocs:text-[10px] tuwadocs:uppercase tuwadocs:px-1.5 tuwadocs:py-0.5 tuwadocs:rounded",
447
+ isSelected ? "tuwadocs:bg-[var(--tuwa-text-accent)]/15 tuwadocs:text-[var(--tuwa-text-accent)] tuwadocs:font-bold" : "tuwadocs:text-[var(--tuwa-text-tertiary)]"
448
+ ),
449
+ children: item.short ?? item.code
450
+ }
451
+ ),
452
+ isSelected && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
453
+ "span",
454
+ {
455
+ className: "tuwadocs:h-1.5 tuwadocs:w-1.5 tuwadocs:rounded-full tuwadocs:bg-[var(--tuwa-text-accent)] tuwadocs:shadow-[0_0_8px_var(--tuwa-text-accent)]",
456
+ "aria-hidden": "true"
457
+ }
458
+ )
459
+ ] })
460
+ ]
461
+ },
462
+ item.code
463
+ );
464
+ })
465
+ }
466
+ )
467
+ ] });
468
+ }
469
+ // Annotate the CommonJS export names for ESM import in node:
470
+ 0 && (module.exports = {
471
+ DEFAULT_LOCALES,
472
+ LocaleSwitcher,
473
+ getLocaleFlag
474
+ });
@@ -0,0 +1,89 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+
4
+ /**
5
+ * Returns a locally embedded SVG flag node for a given locale code.
6
+ *
7
+ * Supported built-in codes:
8
+ * - English: `'en'`, `'en-US'`, `'en-GB'`
9
+ * - Ukrainian: `'uk'`, `'ua'`
10
+ * - Chinese: `'zh'`, `'zh-CN'`, `'zh-Hans'`
11
+ *
12
+ * @param code - The BCP-47 or ISO language tag (e.g. `'en'`, `'uk'`, `'zh-CN'`).
13
+ * @returns The SVG icon node or `undefined` if no built-in flag is available.
14
+ */
15
+ declare function getLocaleFlag(code: string): ReactNode;
16
+ /**
17
+ * A supported locale option for {@link LocaleSwitcher}.
18
+ */
19
+ interface LocaleOption {
20
+ /** The language tag or locale code, for example `'en'`, `'uk'`, or `'zh-CN'`. */
21
+ code: string;
22
+ /** Full native name of the language, for example `'English'`, `'Українська'`, or `'简体中文'`. */
23
+ label: string;
24
+ /** Short label displayed on the compact trigger button, for example `'EN'`, `'UK'`, or `'ZH'`. */
25
+ short?: string;
26
+ /** Optional custom flag element. If omitted, built-in vector flags are used. */
27
+ flag?: ReactNode;
28
+ }
29
+ /**
30
+ * Default list of supported locales across the TUWA documentation ecosystem.
31
+ */
32
+ declare const DEFAULT_LOCALES: readonly LocaleOption[];
33
+ /**
34
+ * Props of {@link LocaleSwitcher}.
35
+ */
36
+ interface LocaleSwitcherProps {
37
+ /** The currently active locale code (e.g. `'en'`, `'uk'`). */
38
+ locale: string;
39
+ /** Callback fired when a new locale is selected by the user. */
40
+ onSelect: (code: string) => void;
41
+ /** The list of available locales. Defaults to {@link DEFAULT_LOCALES}. */
42
+ locales?: readonly LocaleOption[] | LocaleOption[];
43
+ /** Preferred placement of the dropdown menu (`'auto'`, `'bottom'`, or `'top'`). Defaults to `'auto'`. */
44
+ placement?: 'auto' | 'bottom' | 'top';
45
+ /** Optional additional CSS classes for the root container. */
46
+ className?: string;
47
+ /** Accessible name for the button and listbox. Defaults to `'Select language'`. */
48
+ ariaLabel?: string;
49
+ /**
50
+ * Whether to render an invisible native `<select>` overlay on mobile touch devices (`pointer: coarse`).
51
+ * When `true`, tapping on iOS or Android summons the native system wheel/sheet picker.
52
+ * Defaults to `true`.
53
+ */
54
+ nativeTouch?: boolean;
55
+ }
56
+ /**
57
+ * A glassmorphic language select dropdown matching the visual footprint, theme variables, and interaction
58
+ * standards of `ThemeSwitcher`.
59
+ *
60
+ * ### Architectural Features:
61
+ * - **Zero Layout Shift:** Rigid static dimensions (`w-[5.25rem] md:w-[4.75rem]`) and fixed-width label slots
62
+ * prevent any layout reflow or navigation jumping when switching between language codes of varying character widths.
63
+ * - **Nova Motion Language:** Chevron arrow utilizes directional vertical translation (±3px), vertical scale
64
+ * compression (`scaleY(0.8)`), and opacity curves matching Nova UI Kit's `ChevronArrowWithAnim`.
65
+ * - **Flush Edge Indicator:** Replaced traditional checkboxes with a flush, zero-margin left-edge blue accent bar
66
+ * and glowing status indicator, cleanly abutting the dropdown boundary.
67
+ * - **Collision Detection & Dynamic Placement:** When configured with `'auto'` placement, dynamically calculates
68
+ * available viewport clearance below the trigger button. If rendered inside a mobile sheet, drawer, or footer
69
+ * with insufficient bottom clearance, it automatically opens upwards (`bottom-full`).
70
+ * - **Layout-Independent Latin Typeahead (WCAG 2.1.1):** Normalizes keystrokes through physical `event.code`
71
+ * to ensure keys like `E`, `U`, and `Z` reliably navigate to English, Ukrainian, and Chinese regardless of the user's
72
+ * active OS keyboard layout.
73
+ * - **Native Touch Device Support (`pointer: coarse`):** On mobile devices, an invisible native `<select>` overlay
74
+ * activates, triggering the operating system's native wheel picker (iOS Safari) or bottom sheet dialog (Android Chrome)
75
+ * with full system haptics and touch fluidness while retaining the custom visual trigger design.
76
+ * - **WCAG 2.1 & 2.2 AA/AAA Compliance:**
77
+ * - WCAG 2.5.3 (Label in Name): The accessible name of the button begins with the visible label text.
78
+ * - WCAG 3.1.2 (Language of Parts): Every option specifies its native `lang` attribute for screen readers.
79
+ * - WCAG 2.4.7 (Focus Visible): High-contrast focus rings with distinct offset colors.
80
+ * - WCAG 2.3.3 (Animation from Interactions): `motion-reduce:transition-none` disables transitions for sensitive users.
81
+ * - WCAG 4.1.2 (Name, Role, Value): WAI-ARIA `listbox`, `option`, `aria-activedescendant`, and `aria-selected`.
82
+ *
83
+ * @param props - Component properties (see {@link LocaleSwitcherProps}).
84
+ * @returns The rendered locale switcher trigger button, touch overlay, and dropdown menu.
85
+ */
86
+ declare function LocaleSwitcher({ locale, onSelect, locales, placement: placementProp, className, ariaLabel, nativeTouch, }: LocaleSwitcherProps): react.JSX.Element;
87
+
88
+ export { DEFAULT_LOCALES, LocaleSwitcher, getLocaleFlag };
89
+ export type { LocaleOption, LocaleSwitcherProps };