@shadow-js/ui 0.0.0-stage → 1.0.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.
Files changed (190) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +21 -0
  3. package/README.md +598 -2
  4. package/dist/cli.mjs +2293 -0
  5. package/dist/dev/cli/schema.d.ts +73 -0
  6. package/dist/dev/index.d.ts +6 -0
  7. package/dist/dev/index.js +5 -0
  8. package/dist/dev/locales/ar.d.ts +141 -0
  9. package/dist/dev/locales/ar.js +128 -0
  10. package/dist/dev/locales/ar.js.map +1 -0
  11. package/dist/dev/locales/en.d.ts +141 -0
  12. package/dist/dev/locales/en.js +128 -0
  13. package/dist/dev/locales/en.js.map +1 -0
  14. package/dist/dev/registry/utils/utils.d.ts +29 -0
  15. package/dist/dev/registry/utils/utils.js +27 -0
  16. package/dist/dev/registry/utils/utils.js.map +1 -0
  17. package/dist/dev/theme/theme-logic.d.ts +187 -0
  18. package/dist/dev/theme/theme-logic.js +184 -0
  19. package/dist/dev/theme/theme-logic.js.map +1 -0
  20. package/dist/dev/theme/theme-provider.d.ts +90 -0
  21. package/dist/dev/theme/theme-provider.js +150 -0
  22. package/dist/dev/theme/theme-provider.js.map +1 -0
  23. package/dist/prod/index.js +1 -0
  24. package/dist/prod/locales/ar.js +2 -0
  25. package/dist/prod/locales/ar.js.map +1 -0
  26. package/dist/prod/locales/en.js +2 -0
  27. package/dist/prod/locales/en.js.map +1 -0
  28. package/dist/prod/registry/utils/utils.js +2 -0
  29. package/dist/prod/registry/utils/utils.js.map +1 -0
  30. package/dist/prod/theme/theme-logic.js +2 -0
  31. package/dist/prod/theme/theme-logic.js.map +1 -0
  32. package/dist/prod/theme/theme-provider.js +2 -0
  33. package/dist/prod/theme/theme-provider.js.map +1 -0
  34. package/package.json +102 -4
  35. package/registry/accordion/accordion.tsx +210 -0
  36. package/registry/accordion/item.json +4 -0
  37. package/registry/alert/alert.tsx +200 -0
  38. package/registry/alert/item.json +4 -0
  39. package/registry/alert-dialog/alert-dialog.tsx +530 -0
  40. package/registry/alert-dialog/item.json +4 -0
  41. package/registry/aspect-ratio/aspect-ratio.tsx +71 -0
  42. package/registry/aspect-ratio/item.json +4 -0
  43. package/registry/attachment/attachment.tsx +459 -0
  44. package/registry/attachment/item.json +4 -0
  45. package/registry/avatar/avatar.tsx +385 -0
  46. package/registry/avatar/item.json +4 -0
  47. package/registry/badge/badge.tsx +108 -0
  48. package/registry/badge/item.json +4 -0
  49. package/registry/breadcrumb/breadcrumb.tsx +354 -0
  50. package/registry/breadcrumb/item.json +4 -0
  51. package/registry/bubble/bubble.tsx +260 -0
  52. package/registry/bubble/item.json +4 -0
  53. package/registry/button/button.tsx +125 -0
  54. package/registry/button/item.json +4 -0
  55. package/registry/button-group/button-group.tsx +189 -0
  56. package/registry/button-group/item.json +4 -0
  57. package/registry/calendar/calendar.tsx +348 -0
  58. package/registry/calendar/item.json +4 -0
  59. package/registry/card/card.tsx +311 -0
  60. package/registry/card/item.json +4 -0
  61. package/registry/carousel/carousel.tsx +851 -0
  62. package/registry/carousel/item.json +4 -0
  63. package/registry/chart/chart.tsx +1956 -0
  64. package/registry/chart/item.json +4 -0
  65. package/registry/chart-scale/chart-scale.ts +670 -0
  66. package/registry/chart-scale/item.json +5 -0
  67. package/registry/checkbox/checkbox.tsx +79 -0
  68. package/registry/checkbox/item.json +4 -0
  69. package/registry/code-block/code-block.tsx +470 -0
  70. package/registry/code-block/item.json +4 -0
  71. package/registry/collapsible/collapsible.tsx +117 -0
  72. package/registry/collapsible/item.json +4 -0
  73. package/registry/combobox/combobox.tsx +977 -0
  74. package/registry/combobox/item.json +4 -0
  75. package/registry/command/command.tsx +757 -0
  76. package/registry/command/item.json +4 -0
  77. package/registry/context-menu/context-menu.tsx +697 -0
  78. package/registry/context-menu/item.json +4 -0
  79. package/registry/data-table/data-table.tsx +437 -0
  80. package/registry/data-table/item.json +4 -0
  81. package/registry/date-picker/date-picker.tsx +349 -0
  82. package/registry/date-picker/item.json +4 -0
  83. package/registry/dialog/dialog.tsx +412 -0
  84. package/registry/dialog/item.json +4 -0
  85. package/registry/direction/direction.tsx +88 -0
  86. package/registry/direction/item.json +4 -0
  87. package/registry/drawer/drawer.tsx +787 -0
  88. package/registry/drawer/item.json +4 -0
  89. package/registry/dropdown-menu/dropdown-menu.tsx +692 -0
  90. package/registry/dropdown-menu/item.json +4 -0
  91. package/registry/empty/empty.tsx +240 -0
  92. package/registry/empty/item.json +4 -0
  93. package/registry/field/field.tsx +624 -0
  94. package/registry/field/item.json +4 -0
  95. package/registry/form/form.tsx +1112 -0
  96. package/registry/form/item.json +4 -0
  97. package/registry/hover-card/hover-card.tsx +144 -0
  98. package/registry/hover-card/item.json +4 -0
  99. package/registry/icons/icons.tsx +1691 -0
  100. package/registry/icons/item.json +4 -0
  101. package/registry/input/input.tsx +43 -0
  102. package/registry/input/item.json +4 -0
  103. package/registry/input-group/input-group.tsx +315 -0
  104. package/registry/input-group/item.json +4 -0
  105. package/registry/input-otp/input-otp.tsx +329 -0
  106. package/registry/input-otp/item.json +4 -0
  107. package/registry/item/item.json +4 -0
  108. package/registry/item/item.tsx +439 -0
  109. package/registry/kbd/item.json +4 -0
  110. package/registry/kbd/kbd.tsx +65 -0
  111. package/registry/label/item.json +4 -0
  112. package/registry/label/label.tsx +60 -0
  113. package/registry/marker/item.json +4 -0
  114. package/registry/marker/marker.tsx +175 -0
  115. package/registry/menubar/item.json +4 -0
  116. package/registry/menubar/menubar.tsx +694 -0
  117. package/registry/message/item.json +4 -0
  118. package/registry/message/message.tsx +280 -0
  119. package/registry/message-scroller/item.json +4 -0
  120. package/registry/message-scroller/message-scroller.tsx +262 -0
  121. package/registry/native-select/item.json +4 -0
  122. package/registry/native-select/native-select.tsx +113 -0
  123. package/registry/navigation-menu/item.json +4 -0
  124. package/registry/navigation-menu/navigation-menu.tsx +362 -0
  125. package/registry/pagination/item.json +4 -0
  126. package/registry/pagination/pagination.tsx +460 -0
  127. package/registry/password-input/item.json +5 -0
  128. package/registry/password-input/password-input.tsx +494 -0
  129. package/registry/popover/item.json +4 -0
  130. package/registry/popover/popover.tsx +284 -0
  131. package/registry/progress/item.json +4 -0
  132. package/registry/progress/progress.tsx +190 -0
  133. package/registry/questionnaire/item.json +4 -0
  134. package/registry/questionnaire/questionnaire.tsx +569 -0
  135. package/registry/radio-group/item.json +4 -0
  136. package/registry/radio-group/radio-group.tsx +101 -0
  137. package/registry/resizable/item.json +4 -0
  138. package/registry/resizable/resizable.tsx +455 -0
  139. package/registry/scroll-area/item.json +4 -0
  140. package/registry/scroll-area/scroll-area.tsx +149 -0
  141. package/registry/select/item.json +4 -0
  142. package/registry/select/select.tsx +571 -0
  143. package/registry/separator/item.json +4 -0
  144. package/registry/separator/separator.tsx +52 -0
  145. package/registry/sheet/item.json +4 -0
  146. package/registry/sheet/sheet.tsx +476 -0
  147. package/registry/sidebar/item.json +4 -0
  148. package/registry/sidebar/sidebar.tsx +1870 -0
  149. package/registry/skeleton/item.json +4 -0
  150. package/registry/skeleton/skeleton.tsx +47 -0
  151. package/registry/slider/item.json +4 -0
  152. package/registry/slider/slider.tsx +149 -0
  153. package/registry/spinner/item.json +4 -0
  154. package/registry/spinner/spinner.tsx +51 -0
  155. package/registry/switch/item.json +4 -0
  156. package/registry/switch/switch.tsx +93 -0
  157. package/registry/table/item.json +4 -0
  158. package/registry/table/table.tsx +373 -0
  159. package/registry/tabs/item.json +4 -0
  160. package/registry/tabs/tabs.tsx +209 -0
  161. package/registry/textarea/item.json +4 -0
  162. package/registry/textarea/textarea.tsx +51 -0
  163. package/registry/toast/item.json +4 -0
  164. package/registry/toast/toast.tsx +949 -0
  165. package/registry/toggle/item.json +4 -0
  166. package/registry/toggle/toggle.tsx +110 -0
  167. package/registry/toggle-group/item.json +4 -0
  168. package/registry/toggle-group/toggle-group.tsx +189 -0
  169. package/registry/tooltip/item.json +4 -0
  170. package/registry/tooltip/tooltip.tsx +169 -0
  171. package/registry/trace/item.json +4 -0
  172. package/registry/trace/trace.tsx +356 -0
  173. package/registry/ui-strings/item.json +5 -0
  174. package/registry/ui-strings/ui-strings.tsx +454 -0
  175. package/registry/use-highlighted-code/item.json +5 -0
  176. package/registry/use-highlighted-code/use-highlighted-code.tsx +145 -0
  177. package/registry/use-mobile/item.json +5 -0
  178. package/registry/use-mobile/use-mobile.ts +46 -0
  179. package/registry/utils/item.json +5 -0
  180. package/registry/utils/utils.ts +38 -0
  181. package/registry.json +3274 -0
  182. package/styles/animations.css +321 -0
  183. package/styles/theme-core.css +55 -0
  184. package/styles/theme.css +12 -0
  185. package/styles/themes/gray.css +69 -0
  186. package/styles/themes/neutral.css +69 -0
  187. package/styles/themes/slate.css +69 -0
  188. package/styles/themes/stone.css +69 -0
  189. package/styles/themes/zinc.css +69 -0
  190. package/styles/typeset.css +251 -0
@@ -0,0 +1,454 @@
1
+ import { type Accessor, useMemo } from "@shadow-js/core";
2
+ import { createPartContext } from "@shadow-js/primitives";
3
+ import type { JSX } from "@shadow-js/web";
4
+
5
+ /**
6
+ * The values a message takes: strings and numbers, by argument name.
7
+ * @see {@link formatUiString}
8
+ * @since 1.0.0
9
+ */
10
+ export type UiStringValues = Readonly<Record<string, string | number>>;
11
+
12
+ /**
13
+ * A catalog of messages: strings, or objects of strings, as many levels deep as you like. Both
14
+ * `{ dialog: { close: "Close" } }` and `{ "dialog.close": "Close" }` name the key `dialog.close`. The
15
+ * packs of `@shadow-js/ui/locales/*` are catalogs.
16
+ * @see {@link UiStringsProvider}
17
+ * @since 1.0.0
18
+ */
19
+ export interface UiStringsCatalog {
20
+ readonly [key: string]: string | UiStringsCatalog;
21
+ }
22
+
23
+ /**
24
+ * Looks up a string by key. The kit's components call it with the English text as `fallback`, which is
25
+ * what they show without a provider or without an entry for the key.
26
+ * @see {@link useUiStrings}
27
+ * @since 1.0.0
28
+ */
29
+ export type UiTranslate = (key: string, fallback: string, values?: UiStringValues) => string;
30
+
31
+ /** One piece of a parsed message. @internal */
32
+ type Piece =
33
+ | string
34
+ | { readonly kind: "argument"; readonly name: string }
35
+ | { readonly kind: "number"; readonly name: string; readonly style: string | undefined }
36
+ | { readonly kind: "pound" }
37
+ | {
38
+ readonly kind: "plural";
39
+ readonly name: string;
40
+ readonly offset: number;
41
+ readonly cases: ReadonlyMap<string, readonly Piece[]>;
42
+ };
43
+
44
+ const NAME = /[^\s,{}]+/y;
45
+ const SPACE = /\s*/y;
46
+ const PLURAL_CATEGORIES = new Set(["zero", "one", "two", "few", "many", "other"]);
47
+
48
+ /** Parses a message of the ICU subset the kit uses. @internal */
49
+ function parseMessage(message: string): readonly Piece[] {
50
+ let at = 0;
51
+ const fail = (reason: string): never => {
52
+ throw new SyntaxError(`${reason} at position ${at} of "${message}"`);
53
+ };
54
+ const skip = (): void => {
55
+ SPACE.lastIndex = at;
56
+ SPACE.test(message);
57
+ at = SPACE.lastIndex;
58
+ };
59
+ const word = (): string => {
60
+ NAME.lastIndex = at;
61
+ const found = NAME.exec(message);
62
+ if (found === null) return fail("expected a name");
63
+ at = NAME.lastIndex;
64
+ return found[0];
65
+ };
66
+ const consume = (char: string): void => {
67
+ skip();
68
+ if (message[at] !== char) fail(`expected "${char}"`);
69
+ at += 1;
70
+ };
71
+ const sequence = (closing: boolean, plural: boolean): Piece[] => {
72
+ const pieces: Piece[] = [];
73
+ let text = "";
74
+ const flush = (): void => {
75
+ if (text !== "") pieces.push(text);
76
+ text = "";
77
+ };
78
+ while (at < message.length) {
79
+ const char = message[at] as string;
80
+ if (char === "'") {
81
+ const next = message[at + 1];
82
+ if (next === "'") {
83
+ text += "'";
84
+ at += 2;
85
+ } else if (next === "{" || next === "}" || (plural && next === "#")) {
86
+ at += 1;
87
+ for (;;) {
88
+ if (at >= message.length) return fail("unterminated quote");
89
+ const inner = message[at] as string;
90
+ if (inner === "'" && message[at + 1] === "'") {
91
+ text += "'";
92
+ at += 2;
93
+ } else if (inner === "'") {
94
+ at += 1;
95
+ break;
96
+ } else {
97
+ text += inner;
98
+ at += 1;
99
+ }
100
+ }
101
+ } else {
102
+ text += "'";
103
+ at += 1;
104
+ }
105
+ } else if (char === "{") {
106
+ flush();
107
+ at += 1;
108
+ pieces.push(argument());
109
+ } else if (char === "}") {
110
+ if (!closing) return fail("unexpected }");
111
+ flush();
112
+ return pieces;
113
+ } else if (char === "#" && plural) {
114
+ flush();
115
+ pieces.push({ kind: "pound" });
116
+ at += 1;
117
+ } else {
118
+ text += char;
119
+ at += 1;
120
+ }
121
+ }
122
+ if (closing) return fail("missing }");
123
+ flush();
124
+ return pieces;
125
+ };
126
+ const argument = (): Piece => {
127
+ skip();
128
+ const name = word();
129
+ skip();
130
+ if (message[at] === "}") {
131
+ at += 1;
132
+ return { kind: "argument", name };
133
+ }
134
+ consume(",");
135
+ skip();
136
+ const type = word();
137
+ skip();
138
+ if (type === "number") {
139
+ let style: string | undefined;
140
+ if (message[at] === ",") {
141
+ at += 1;
142
+ skip();
143
+ style = word();
144
+ skip();
145
+ }
146
+ consume("}");
147
+ return { kind: "number", name, style };
148
+ }
149
+ if (type !== "plural") return fail(`unsupported argument type "${type}"`);
150
+ consume(",");
151
+ skip();
152
+ let offset = 0;
153
+ if (message.startsWith("offset:", at)) {
154
+ at += "offset:".length;
155
+ offset = Number(word());
156
+ if (!Number.isFinite(offset)) fail("bad offset");
157
+ }
158
+ const cases = new Map<string, readonly Piece[]>();
159
+ for (;;) {
160
+ skip();
161
+ if (message[at] === "}") break;
162
+ const selector = word();
163
+ if (!PLURAL_CATEGORIES.has(selector) && !/^=\d+$/.test(selector)) {
164
+ fail(`unknown plural selector "${selector}"`);
165
+ }
166
+ consume("{");
167
+ cases.set(selector, sequence(true, true));
168
+ consume("}");
169
+ }
170
+ at += 1;
171
+ if (!cases.has("other")) fail('a plural needs an "other" case');
172
+ return { kind: "plural", name, offset, cases };
173
+ };
174
+ return sequence(false, false);
175
+ }
176
+
177
+ const RTL_LANGUAGES = new Set(["ar", "he", "fa", "ur", "ps", "sd", "ug", "yi", "dv", "ckb", "ks"]);
178
+ const DIRECTIONS = new Map<string, boolean>();
179
+
180
+ /** Whether a locale is written right to left, from its tag. @internal */
181
+ function isRightToLeft(locale: string): boolean {
182
+ let known = DIRECTIONS.get(locale);
183
+ if (known === undefined) {
184
+ try {
185
+ const parsed = new Intl.Locale(locale) as Intl.Locale & {
186
+ getTextInfo?: () => { direction?: string };
187
+ textInfo?: { direction?: string };
188
+ };
189
+ const direction = (parsed.getTextInfo?.() ?? parsed.textInfo)?.direction;
190
+ known = direction === undefined ? RTL_LANGUAGES.has(parsed.language) : direction === "rtl";
191
+ } catch (error) {
192
+ if (!(error instanceof RangeError)) throw error;
193
+ known = false;
194
+ }
195
+ DIRECTIONS.set(locale, known);
196
+ }
197
+ return known;
198
+ }
199
+
200
+ const PARSED = new Map<string, readonly Piece[]>();
201
+ const NUMBER_FORMATS = new Map<string, Intl.NumberFormat>();
202
+ const PLURAL_RULES = new Map<string, Intl.PluralRules>();
203
+
204
+ /** A cached `Intl.NumberFormat`. @internal */
205
+ function numberFormat(locale: string, style: string | undefined): Intl.NumberFormat {
206
+ const key = `${locale}|${style ?? ""}`;
207
+ let format = NUMBER_FORMATS.get(key);
208
+ if (format === undefined) {
209
+ format = new Intl.NumberFormat(
210
+ locale,
211
+ style === "percent"
212
+ ? { style: "percent" }
213
+ : style === "integer"
214
+ ? { maximumFractionDigits: 0 }
215
+ : undefined,
216
+ );
217
+ NUMBER_FORMATS.set(key, format);
218
+ }
219
+ return format;
220
+ }
221
+
222
+ /** Writes the pieces of a message with the given values. @internal */
223
+ function write(
224
+ pieces: readonly Piece[],
225
+ values: UiStringValues | undefined,
226
+ locale: string,
227
+ pound: number | undefined,
228
+ ): string {
229
+ let out = "";
230
+ for (const piece of pieces) {
231
+ if (typeof piece === "string") out += piece;
232
+ else if (piece.kind === "pound") {
233
+ out += numberFormat(locale, undefined).format(pound ?? 0);
234
+ } else if (piece.kind === "argument") {
235
+ const value = values?.[piece.name];
236
+ // A string put into right-to-left text is fenced in isolates, so its own direction cannot reorder
237
+ // the words and punctuation around it (the same rule as @shadow-js/i18n).
238
+ out +=
239
+ typeof value === "string" && value !== "" && isRightToLeft(locale)
240
+ ? `\u2068${value}\u2069`
241
+ : String(value ?? "");
242
+ } else if (piece.kind === "number") {
243
+ out += numberFormat(locale, piece.style).format(Number(values?.[piece.name] ?? 0));
244
+ } else {
245
+ const count = Number(values?.[piece.name] ?? 0);
246
+ let rules = PLURAL_RULES.get(locale);
247
+ if (rules === undefined) {
248
+ rules = new Intl.PluralRules(locale);
249
+ PLURAL_RULES.set(locale, rules);
250
+ }
251
+ const shifted = count - piece.offset;
252
+ const chosen =
253
+ piece.cases.get(`=${count}`) ??
254
+ piece.cases.get(rules.select(shifted)) ??
255
+ piece.cases.get("other") ??
256
+ [];
257
+ out += write(chosen, values, locale, shifted);
258
+ }
259
+ }
260
+ return out;
261
+ }
262
+
263
+ /**
264
+ * Fills a message with values. The message is a subset of ICU MessageFormat, the format of
265
+ * `@shadow-js/i18n`: `{name}` inserts a value, `{n, number}` (also `percent` and `integer`) formats a
266
+ * number for the locale, and `{n, plural, =0 {none} one {# item} other {# items}}` picks a case by the
267
+ * plural rules of the locale (`#` is the number; the categories are `zero`, `one`, `two`, `few`, `many`
268
+ * and `other`, and Arabic uses all six). `''` is an apostrophe, and `'{'` quotes a brace. In a
269
+ * right-to-left locale a string value is fenced in bidirectional isolates (U+2068 and U+2069), so a Latin
270
+ * name inside Arabic text does not reorder its neighbors. The same messages work in `@shadow-js/i18n`,
271
+ * which supports more and prints them the same way.
272
+ *
273
+ * @param message The message.
274
+ * @param values The values of its arguments.
275
+ * @param locale The locale for numbers and plural rules, such as `"en"` or `"ar"`.
276
+ * @returns The text.
277
+ * @throws SyntaxError when the message is not valid, or uses a feature outside the subset (such as
278
+ * `select`).
279
+ * @example
280
+ * import { formatUiString } from "@/lib/ui-strings";
281
+ *
282
+ * export const label = formatUiString("Page {page} of {pages}", { page: 2, pages: 9 }, "en");
283
+ * @see {@link UiStringsProvider}
284
+ * @since 1.0.0
285
+ */
286
+ export function formatUiString(
287
+ message: string,
288
+ values: UiStringValues | undefined,
289
+ locale: string,
290
+ ): string {
291
+ let pieces = PARSED.get(message);
292
+ if (pieces === undefined) {
293
+ pieces = parseMessage(message);
294
+ if (PARSED.size >= 500) PARSED.clear();
295
+ PARSED.set(message, pieces);
296
+ }
297
+ return write(pieces, values, locale, undefined);
298
+ }
299
+
300
+ /** What the provider shares: the translate function and the locale. @internal */
301
+ interface UiStringsContextValue {
302
+ readonly translate: (key: string, values: UiStringValues | undefined) => string | undefined;
303
+ readonly locale: Accessor<string | undefined>;
304
+ }
305
+
306
+ const stringsContext = createPartContext<UiStringsContextValue>("UiStringsProvider");
307
+
308
+ /** Adds the entries of a catalog to a flat map of dotted keys. @internal */
309
+ function flatten(catalog: UiStringsCatalog, prefix: string, into: Map<string, string>): void {
310
+ for (const [name, value] of Object.entries(catalog)) {
311
+ const key = prefix === "" ? name : `${prefix}.${name}`;
312
+ if (typeof value === "string") into.set(key, value);
313
+ else flatten(value, key, into);
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Props of {@link UiStringsProvider}.
319
+ * @see {@link UiStringsProvider}
320
+ * @since 1.0.0
321
+ */
322
+ export interface UiStringsProviderProps {
323
+ /**
324
+ * The messages, such as the pack `@shadow-js/ui/locales/ar`. A key that is missing keeps its English
325
+ * text, or the text of an outer provider.
326
+ * @default none
327
+ */
328
+ readonly strings?: UiStringsCatalog | undefined;
329
+ /**
330
+ * The locale of the messages, for plural rules and number formats.
331
+ * @default the locale of the outer provider, else `"en"`
332
+ */
333
+ readonly locale?: string | undefined;
334
+ /**
335
+ * Looks a key up in your own i18n library. It is asked first; return `undefined` to decline. It may
336
+ * read signals, so a change of language updates the text.
337
+ * @default none
338
+ */
339
+ readonly translate?:
340
+ | ((key: string, values: UiStringValues | undefined) => string | undefined)
341
+ | undefined;
342
+ /**
343
+ * The app, or the part of it, that speaks this language.
344
+ * @default none
345
+ */
346
+ readonly children?: JSX.Element | undefined;
347
+ }
348
+
349
+ /**
350
+ * Sets the words the kit's components show to the user, for the part of the app below it: the labels of
351
+ * icon buttons (Close, Next), the names of landmarks, "Page 2 of 9". Pass a pack such as
352
+ * `@shadow-js/ui/locales/ar` as `strings`, or connect your i18n library with `translate`. A prop on a
353
+ * component (a `labels` object, a `toggleLabel`) still wins over the provider.
354
+ *
355
+ * Without a provider every component shows its English text. A provider inside another one answers
356
+ * first and leaves what it lacks to the outer one. The provider renders no element and handles no keys.
357
+ *
358
+ * @param props See {@link UiStringsProviderProps}.
359
+ * @returns The children.
360
+ * @example
361
+ * import { UiStringsProvider } from "@/lib/ui-strings";
362
+ *
363
+ * export function Page() {
364
+ * return (
365
+ * <UiStringsProvider strings={{ dialog: { close: "Fermer" } }} locale="fr">
366
+ * <p>Content</p>
367
+ * </UiStringsProvider>
368
+ * );
369
+ * }
370
+ * @see {@link useUiStrings}
371
+ * @since 1.0.0
372
+ */
373
+ function UiStringsProvider(props: UiStringsProviderProps): JSX.Element {
374
+ const parent = stringsContext.useOptional();
375
+ const flat = useMemo(() => {
376
+ const into = new Map<string, string>();
377
+ if (props.strings !== undefined) flatten(props.strings, "", into);
378
+ return into;
379
+ });
380
+ const locale = (): string | undefined => props.locale ?? parent?.locale();
381
+ const translate = (key: string, values: UiStringValues | undefined): string | undefined => {
382
+ const own = props.translate?.(key, values);
383
+ if (typeof own === "string") return own;
384
+ const message = flat().get(key);
385
+ if (message !== undefined) {
386
+ try {
387
+ return formatUiString(message, values, locale() ?? "en");
388
+ } catch (error) {
389
+ // A broken translation must not break the page: the caller shows the English text instead.
390
+ globalThis.reportError?.(error);
391
+ return undefined;
392
+ }
393
+ }
394
+ return parent?.translate(key, values);
395
+ };
396
+ return (
397
+ <stringsContext.Provider value={{ translate, locale }}>
398
+ {props.children}
399
+ </stringsContext.Provider>
400
+ );
401
+ }
402
+
403
+ /**
404
+ * The translate function of the nearest {@link UiStringsProvider}: `t(key, fallback, values)` returns the
405
+ * provider's message for the key, or the English `fallback` (filled in) without a provider or entry. The
406
+ * kit's components use it for their built-in words; use it for your own parts of a copied component.
407
+ *
408
+ * @returns The translate function. Call it where the text is used (in JSX), not in setup, so a change of
409
+ * language updates the text.
410
+ * @example
411
+ * import { useUiStrings } from "@/lib/ui-strings";
412
+ *
413
+ * export function CloseLabel() {
414
+ * const t = useUiStrings();
415
+ * return <span class="sr-only">{t("dialog.close", "Close")}</span>;
416
+ * }
417
+ * @see {@link UiStringsProvider}
418
+ * @since 1.0.0
419
+ */
420
+ function useUiStrings(): UiTranslate {
421
+ const context = stringsContext.useOptional();
422
+ return (key, fallback, values) => {
423
+ const found = context?.translate(key, values);
424
+ if (found !== undefined) return found;
425
+ try {
426
+ return formatUiString(fallback, values, "en");
427
+ } catch (error) {
428
+ globalThis.reportError?.(error);
429
+ return fallback;
430
+ }
431
+ };
432
+ }
433
+
434
+ /**
435
+ * The locale of the nearest {@link UiStringsProvider} that has one, or `undefined`. Components that
436
+ * format numbers (the chart) use it as their default locale.
437
+ *
438
+ * @returns An accessor of the locale.
439
+ * @example
440
+ * import { useUiLocale } from "@/lib/ui-strings";
441
+ *
442
+ * export function Digits(props: { value: number }) {
443
+ * const locale = useUiLocale();
444
+ * return <span>{new Intl.NumberFormat(locale()).format(props.value)}</span>;
445
+ * }
446
+ * @see {@link UiStringsProvider}
447
+ * @since 1.0.0
448
+ */
449
+ function useUiLocale(): Accessor<string | undefined> {
450
+ const context = stringsContext.useOptional();
451
+ return () => context?.locale();
452
+ }
453
+
454
+ export { UiStringsProvider, useUiLocale, useUiStrings };
@@ -0,0 +1,5 @@
1
+ {
2
+ "title": "useHighlightedCode",
3
+ "description": "Syntax highlighting for code blocks with Shiki: a hook for the browser and an async function for the server, one element per line.",
4
+ "type": "hook"
5
+ }
@@ -0,0 +1,145 @@
1
+ import { type Accessor, onCleanup, useEffect, useMemo, useSignal } from "@shadow-js/core";
2
+ import type { JSX } from "@shadow-js/web";
3
+ import type { BundledLanguage, SpecialLanguage } from "shiki";
4
+
5
+ /**
6
+ * One highlighted piece of a line: its text and the CSS custom properties that color it. The kit's code block
7
+ * colors a piece with `--shiki-light` in the light theme and `--shiki-dark` in the dark one.
8
+ * @since 1.0.0
9
+ * @see {@link highlightCode}
10
+ */
11
+ export interface HighlightedToken {
12
+ /** The text of the piece. */
13
+ readonly content: string;
14
+ /** The custom properties that color it, or `undefined` when the piece has no color of its own. */
15
+ readonly style: Readonly<Record<string, string>> | undefined;
16
+ }
17
+
18
+ /** A highlighted line: its pieces, in order. @since 1.0.0 */
19
+ export type HighlightedLine = readonly HighlightedToken[];
20
+
21
+ /** The two themes the code block colors with (Shiki's own names). */
22
+ const THEMES = { light: "github-light-high-contrast", dark: "github-dark" } as const;
23
+
24
+ /** What the part of Shiki's result that this file reads looks like. */
25
+ interface ShikiToken {
26
+ readonly content: string;
27
+ readonly htmlStyle?: Readonly<Record<string, string>> | undefined;
28
+ }
29
+
30
+ /** Whether Shiki can highlight this language id or alias: one of its grammars, or plain text and terminal output. */
31
+ function isLanguage(
32
+ languages: Readonly<Record<string, unknown>>,
33
+ language: string,
34
+ ): language is BundledLanguage | SpecialLanguage {
35
+ return language === "text" || language === "ansi" || Object.hasOwn(languages, language);
36
+ }
37
+
38
+ /**
39
+ * Highlights code with Shiki and returns the lines as data, so the same result can be rendered on the server (in a loader, before
40
+ * the page is sent) and in the browser. Shiki loads when this is first called and each grammar when its language is first used, so a page
41
+ * that never highlights code never downloads it. The languages Shiki knows work, among them `bash`, `css`, `diff`, `html`,
42
+ * `javascript`, `json`, `jsx`, `markdown`, `python`, `sql`, `tsx`, `typescript` and `yaml`, with aliases such as `ts`, `js`, `sh` and `yml`,
43
+ * and `ansi` for terminal output with color codes.
44
+ *
45
+ * @param code - The source text.
46
+ * @param language - The language id or alias.
47
+ * @returns One entry per line, or `undefined` when the language is not one Shiki knows (show the lines plain then).
48
+ * @example
49
+ * ```tsx
50
+ * import { highlightCode } from "@/hooks/use-highlighted-code";
51
+ *
52
+ * export async function loadLines() {
53
+ * return (await highlightCode("const answer = 42;", "ts")) ?? [];
54
+ * }
55
+ * ```
56
+ * @since 1.0.0
57
+ * @see {@link useHighlightedCode}
58
+ */
59
+ export async function highlightCode(
60
+ code: string,
61
+ language: string,
62
+ ): Promise<readonly HighlightedLine[] | undefined> {
63
+ const shiki = await import("shiki");
64
+ if (!isLanguage(shiki.bundledLanguages, language)) return undefined;
65
+ const result = await shiki.codeToTokens(code, {
66
+ lang: language,
67
+ themes: THEMES,
68
+ defaultColor: false,
69
+ });
70
+ return result.tokens.map((line) =>
71
+ line.map((token: ShikiToken) => ({ content: token.content, style: token.htmlStyle })),
72
+ );
73
+ }
74
+
75
+ /** One line of code as an element: the pieces in spans, colored by the custom properties. */
76
+ function lineOf(line: HighlightedLine): JSX.Element {
77
+ return (
78
+ <span data-slot="code-block-line" class="block min-h-[1lh]">
79
+ {line.map((token) => (
80
+ <span data-token="" style={token.style}>
81
+ {token.content}
82
+ </span>
83
+ ))}
84
+ </span>
85
+ );
86
+ }
87
+
88
+ /**
89
+ * Highlights code as it changes and returns one element per line. Until the first pass has finished, and for a language Shiki does not know, the
90
+ * lines are plain, so the text is on the page at once and nothing jumps when the colors arrive. The answer to the latest text wins: while
91
+ * a streaming reply grows, an older, slower pass is dropped instead of drawn over the newer text. On the server it returns the plain lines; to color
92
+ * server-rendered code call {@link highlightCode} in a loader. Give the lines to the content of a code block.
93
+ *
94
+ * @param code - The source text, or an accessor of it.
95
+ * @param language - The language id or alias, or an accessor of it.
96
+ * @returns An accessor of the lines, as elements.
97
+ * @example
98
+ * ```tsx
99
+ * import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
100
+ * import { useHighlightedCode } from "@/hooks/use-highlighted-code";
101
+ *
102
+ * export function Snippet() {
103
+ * const lines = useHighlightedCode("const answer = 42;", "ts");
104
+ * return (
105
+ * <CodeBlock>
106
+ * <CodeBlockContent>{lines()}</CodeBlockContent>
107
+ * </CodeBlock>
108
+ * );
109
+ * }
110
+ * ```
111
+ * @since 1.0.0
112
+ * @see {@link highlightCode}
113
+ */
114
+ export function useHighlightedCode(
115
+ code: string | Accessor<string>,
116
+ language: string | Accessor<string>,
117
+ ): Accessor<readonly JSX.Element[]> {
118
+ const read = <T,>(value: T | Accessor<T>): T =>
119
+ typeof value === "function" ? (value as Accessor<T>)() : value;
120
+ const plain = (text: string): readonly HighlightedLine[] =>
121
+ text.split("\n").map((line) => [{ content: line, style: undefined }]);
122
+ const [data, setData] = useSignal<readonly HighlightedLine[]>(plain(read(code)));
123
+ useEffect(() => {
124
+ const text = read(code);
125
+ const lang = read(language);
126
+ // Show the new text at once, plain; the colors follow when the pass for exactly this text is done.
127
+ setData(plain(text));
128
+ let current = true;
129
+ onCleanup(() => {
130
+ current = false;
131
+ });
132
+ highlightCode(text, lang).then(
133
+ (highlighted) => {
134
+ if (current && highlighted !== undefined) setData(highlighted);
135
+ },
136
+ (error: unknown) => {
137
+ // A failed load leaves the plain lines in place; the error still goes to the page's reporter.
138
+ globalThis.reportError?.(error);
139
+ },
140
+ );
141
+ });
142
+ // The elements are made here, under this hook's owner, not in the promise callback that has none.
143
+ const lines = useMemo(() => data().map(lineOf));
144
+ return lines;
145
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "title": "useIsMobile",
3
+ "description": "A hook that tells whether the viewport is narrower than 768px. Used by the sidebar.",
4
+ "type": "hook"
5
+ }
@@ -0,0 +1,46 @@
1
+ import { type Accessor, onMount, useSignal } from "@shadow-js/core";
2
+
3
+ /** Viewports narrower than this many px count as mobile (Tailwind's `md` breakpoint). */
4
+ const MOBILE_BREAKPOINT = 768;
5
+
6
+ /**
7
+ * Whether the viewport is narrower than the `md` breakpoint (`max-width: 767px`). The value is
8
+ * `false` on the server and until the component has mounted, so the server markup and the first
9
+ * client render agree; it then follows the media query. The listener is removed with the owner.
10
+ *
11
+ * @returns An accessor with the current answer.
12
+ * @example
13
+ * ```tsx
14
+ * import { useIsMobile } from "@/hooks/use-mobile";
15
+ * import { Show } from "@shadow-js/web";
16
+ *
17
+ * function DesktopNav() {
18
+ * return <nav>Desktop</nav>;
19
+ * }
20
+ *
21
+ * function MobileNav() {
22
+ * return <nav>Mobile</nav>;
23
+ * }
24
+ *
25
+ * export function Navigation() {
26
+ * const isMobile = useIsMobile();
27
+ * return <Show when={isMobile()} fallback={<DesktopNav />}><MobileNav /></Show>;
28
+ * }
29
+ * ```
30
+ * @since 1.0.0
31
+ */
32
+ function useIsMobile(): Accessor<boolean> {
33
+ const [isMobile, setIsMobile] = useSignal(false);
34
+ onMount(() => {
35
+ const query = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
36
+ const sync = (): void => {
37
+ setIsMobile(query.matches);
38
+ };
39
+ query.addEventListener("change", sync);
40
+ sync();
41
+ return () => query.removeEventListener("change", sync);
42
+ });
43
+ return isMobile;
44
+ }
45
+
46
+ export { useIsMobile };
@@ -0,0 +1,5 @@
1
+ {
2
+ "title": "Utils",
3
+ "description": "The cn() helper: clsx plus tailwind-merge. Every component imports it.",
4
+ "type": "lib"
5
+ }