@ryuzaki13/react-foundation-ui 1.0.11 → 1.0.13

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
@@ -1,26 +1,58 @@
1
1
  # @ryuzaki13/react-foundation-ui
2
2
 
3
- Библиотечный пакет React UI primitives, выделенный из бывшего `src/shared/ui`.
3
+ React UI primitives, выделенные из бывшего `src/shared/ui`.
4
4
 
5
- Пакет не открывает корневой импорт. Используйте точечные entrypoints:
5
+ Пакет публикуется в npm как обычная публичная библиотека, но его основная цель практическая: разделить повторно используемый UI-слой между собственными проектами автора. Это не попытка сделать универсальную дизайн-систему для всех сценариев. API, стили и Storybook в первую очередь оптимизируются под семейство проектов, где рядом используются `@ryuzaki13/react-foundation-lib` и `@ryuzaki13/react-foundation-api`.
6
+
7
+ ## Импорты
8
+
9
+ Корневой импорт намеренно не открыт. Используйте точечные entrypoints:
6
10
 
7
11
  ```tsx
8
- import "@ryuzaki13/react-foundation-ui/styles.css";
9
12
  import { Button } from "@ryuzaki13/react-foundation-ui/button";
13
+ import { ContextMenu } from "@ryuzaki13/react-foundation-ui/context-menu";
14
+ import type { UiControlProps } from "@ryuzaki13/react-foundation-ui/types";
10
15
  ```
11
16
 
12
- Если host-проект задает собственные значения CSS-переменных, подключайте Sass API вместо готового CSS:
17
+ Стили подключаются отдельно и один раз на уровне host-приложения.
18
+
19
+ ## Подключение стилей
20
+
21
+ ### Готовый CSS
22
+
23
+ Самый простой вариант подходит для песочниц или приложений, которым достаточно дефолтных токенов пакета:
24
+
25
+ ```ts
26
+ import "@ryuzaki13/react-foundation-ui/styles.css";
27
+ ```
28
+
29
+ В этом режиме значения CSS-переменных уже скомпилированы в `dist/styles.css`. Host-проект может переопределять их обычным CSS поверх, но это runtime override, а не build-time настройка.
30
+
31
+ ### Sass с настройкой host-проекта
32
+
33
+ Для рабочих проектов предпочтительнее Sass API. Он позволяет задать значения переменных на этапе сборки:
13
34
 
14
35
  ```scss
15
36
  @use "@ryuzaki13/react-foundation-ui/styles/config" with (
16
37
  $root-token-overrides: (
17
- "--font-family": ("Inter", sans-serif),
38
+ "--font-family": (
39
+ "Inter",
40
+ sans-serif
41
+ ),
42
+ "--font-family-mono": (
43
+ "JetBrains Mono",
44
+ monospace
45
+ ),
18
46
  "--radius-md": 8px
19
47
  ),
20
48
  $light-theme-overrides: (
21
49
  tokens: (
22
50
  "--surface-0": #ffffff,
23
- "--surface-1": #f5f7fb
51
+ "--surface-1": #f6f7f9,
52
+ "--surface-2": #eceff3,
53
+ "--content-0": #111827,
54
+ "--content-1": #374151,
55
+ "--content-2": #6b7280
24
56
  )
25
57
  )
26
58
  );
@@ -28,10 +60,425 @@ import { Button } from "@ryuzaki13/react-foundation-ui/button";
28
60
  @use "@ryuzaki13/react-foundation-ui/styles.scss";
29
61
  ```
30
62
 
31
- В одном приложении нужно выбрать один способ: либо `styles.css` с дефолтными токенами, либо Sass entrypoint с настройкой через `styles/config`.
63
+ Важно: `styles/config` должен быть настроен до первого подключения любых foundation styles в Sass module graph. Иначе Sass уже загрузит конфиг с дефолтами и повторная настройка через `with (...)` не сработает.
64
+
65
+ ### Host со своими темами
66
+
67
+ Если проект сам управляет темами, например через `data-theme="light:default"`, `data-scheme="light"` и `data-theme-mode="default"`, лучше подключать базовый слой отдельно от selectors:
68
+
69
+ ```scss
70
+ @use "@ryuzaki13/react-foundation-ui/styles/config" with (
71
+ $root-token-overrides: (
72
+ "--font-family": (
73
+ "Inter",
74
+ sans-serif
75
+ )
76
+ ),
77
+ $light-theme-overrides: (
78
+ tokens: (
79
+ "--surface-0": #ffffff,
80
+ "--surface-1": #f6f7f9,
81
+ "--surface-2": #eceff3
82
+ )
83
+ ),
84
+ $dark-theme-overrides: (
85
+ tokens: (
86
+ "--surface-0": #0b0b0b,
87
+ "--surface-1": #151515,
88
+ "--surface-2": #202020
89
+ )
90
+ )
91
+ );
92
+
93
+ @use "@ryuzaki13/react-foundation-ui/styles/foundation";
94
+ @use "@ryuzaki13/react-foundation-ui/styles/themes" as foundationThemes;
95
+
96
+ :root[data-theme="light:default"] {
97
+ @include foundationThemes.light-theme;
98
+ }
99
+
100
+ :root[data-theme="dark:default"] {
101
+ @include foundationThemes.dark-theme;
102
+ }
103
+ ```
104
+
105
+ `styles/foundation` подключает normalize, root tokens, base styles и utility classes, но не навязывает дефолтные selectors `data-theme="light"` / `data-theme="dark"`. Selectors остаются ответственностью host-приложения.
106
+
107
+ Обычно в host-проекте это оформляется отдельным entrypoint, например `src/app/styles/shared.scss`, который сначала настраивает foundation config, затем подключает foundation styles, темы и доменные CSS-переменные приложения.
108
+
109
+ ## Storybook
110
+
111
+ Stories и MDX не экспортируются как public API пакета. Вместо этого пакет собирает готовую статическую документацию:
112
+
113
+ ```bash
114
+ npm run build-storybook
115
+ ```
116
+
117
+ Результат лежит в `storybook-static` и включается в npm package через `files`. После установки пакета host-проект может отдать эту директорию:
118
+
119
+ ```bash
120
+ npx http-server node_modules/@ryuzaki13/react-foundation-ui/storybook-static -p 6007
121
+ ```
122
+
123
+ Такой запуск покажет готовые stories пакета, но не подмешает theme tokens конкретного host-проекта. Если нужно видеть компоненты в проектной теме, host должен запускать небольшую обертку:
124
+
125
+ 1. отдать файлы из `node_modules/@ryuzaki13/react-foundation-ui/storybook-static`;
126
+ 2. скомпилировать свой SCSS entrypoint, например `src/app/styles/shared.scss`;
127
+ 3. внедрить ссылку на host CSS в `iframe.html`;
128
+ 4. выставить нужные атрибуты на `document.documentElement`: `data-theme`, `data-scheme`, `data-theme-mode` и связанные accessibility presets;
129
+ 5. отдавать host public assets, например шрифты, не перекрывая `storybook-static/assets`.
130
+
131
+ Именно host отвечает за конкретные значения CSS-переменных. Это позволяет одному и тому же Storybook пакета отображаться по-разному в разных проектах без копирования stories и MDX.
132
+
133
+ Пример обертки для запуска storybook в host проекте:
134
+
135
+ ```mjs
136
+ #!/usr/bin/env node
137
+
138
+ // tools/serveFoundationUiStorybook.mjs
139
+
140
+ import { existsSync, readFileSync, statSync } from "node:fs";
141
+ import { readFile, stat } from "node:fs/promises";
142
+ import { createServer } from "node:http";
143
+ import { createRequire } from "node:module";
144
+ import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
145
+ import { fileURLToPath, pathToFileURL } from "node:url";
146
+
147
+ const projectRoot = process.cwd();
148
+ const projectRequire = createRequire(join(projectRoot, "package.json"));
149
+ const options = parseArgs(process.argv.slice(2));
150
+ const styleEntry = resolve(projectRoot, options.styles);
151
+ const publicRoot = resolve(projectRoot, options.public);
152
+ const storybookRoot = resolveFoundationStorybookRoot();
153
+
154
+ await assertFile(styleEntry, "Style entry");
155
+ await assertDirectory(storybookRoot, "Foundation UI Storybook");
156
+ await compileHostStyles();
157
+
158
+ const server = createServer(async (request, response) => {
159
+ try {
160
+ const requestUrl = new URL(request.url ?? "/", `http://${options.host}:${options.port}`);
161
+
162
+ if (requestUrl.pathname === "/__foundation-host-styles.css") {
163
+ const css = await compileHostStyles();
164
+ send(response, 200, css, "text/css; charset=utf-8");
165
+ return;
166
+ }
167
+
168
+ if (requestUrl.pathname.startsWith("/assets/")) {
169
+ const storybookAssetPath = resolve(storybookRoot, `.${requestUrl.pathname}`);
170
+
171
+ if (isInside(storybookRoot, storybookAssetPath) && isFile(storybookAssetPath)) {
172
+ await serveFile(response, storybookRoot, requestUrl.pathname);
173
+ return;
174
+ }
175
+
176
+ await serveFile(response, publicRoot, requestUrl.pathname);
177
+ return;
178
+ }
179
+
180
+ const pathname = requestUrl.pathname === "/" ? "/index.html" : requestUrl.pathname;
181
+
182
+ if (pathname === "/iframe.html") {
183
+ const html = await readFile(join(storybookRoot, "iframe.html"), "utf8");
184
+ send(response, 200, injectHostPreview(html), "text/html; charset=utf-8");
185
+ return;
186
+ }
187
+
188
+ await serveFile(response, storybookRoot, pathname);
189
+ } catch (error) {
190
+ const status = error?.code === "ENOENT" ? 404 : 500;
191
+ send(response, status, status === 404 ? "Not found" : String(error?.stack ?? error), "text/plain; charset=utf-8");
192
+ }
193
+ });
194
+
195
+ server.listen(options.port, options.host, () => {
196
+ console.log(`Foundation UI Storybook: http://${options.host}:${options.port}`);
197
+ });
198
+
199
+ function parseArgs(args) {
200
+ const result = {
201
+ host: "127.0.0.1",
202
+ port: 6007,
203
+ public: "public",
204
+ styles: "src/app/styles/shared.scss",
205
+ theme: "light:default",
206
+ scheme: undefined,
207
+ themeMode: undefined,
208
+ attrs: []
209
+ };
210
+
211
+ for (let index = 0; index < args.length; index += 1) {
212
+ const arg = args[index];
213
+
214
+ if (arg === "--host") result.host = readArgValue(args, ++index, arg);
215
+ else if (arg === "--port") result.port = Number(readArgValue(args, ++index, arg));
216
+ else if (arg === "--public") result.public = readArgValue(args, ++index, arg);
217
+ else if (arg === "--styles") result.styles = readArgValue(args, ++index, arg);
218
+ else if (arg === "--theme") result.theme = readArgValue(args, ++index, arg);
219
+ else if (arg === "--scheme") result.scheme = readArgValue(args, ++index, arg);
220
+ else if (arg === "--theme-mode") result.themeMode = readArgValue(args, ++index, arg);
221
+ else if (arg === "--attr") result.attrs.push(readArgValue(args, ++index, arg));
222
+ else if (arg === "--help") printHelpAndExit();
223
+ else throw new Error(`Unknown argument: ${arg}`);
224
+ }
225
+
226
+ if (!Number.isInteger(result.port) || result.port <= 0) {
227
+ throw new Error(`Invalid --port value: ${result.port}`);
228
+ }
229
+
230
+ return result;
231
+ }
232
+
233
+ function readArgValue(args, index, name) {
234
+ const value = args[index];
235
+
236
+ if (!value || value.startsWith("--")) {
237
+ throw new Error(`Missing value for ${name}`);
238
+ }
239
+
240
+ return value;
241
+ }
242
+
243
+ function printHelpAndExit() {
244
+ console.log(
245
+ [
246
+ "Usage: node tools/serveFoundationUiStorybook.mjs [options]",
247
+ "",
248
+ "Options:",
249
+ " --styles <path> Host SCSS/CSS entry. Default: src/app/styles/shared.scss",
250
+ " --public <path> Host public directory. Default: public",
251
+ " --theme <value> data-theme value. Default: light:default",
252
+ " --scheme <value> data-scheme value. Default: first part of --theme",
253
+ " --theme-mode <value> data-theme-mode value. Default: second part of --theme",
254
+ " --attr <name=value> Additional html attribute. Can be repeated.",
255
+ " --host <host> Server host. Default: 127.0.0.1",
256
+ " --port <port> Server port. Default: 6007"
257
+ ].join("\n")
258
+ );
259
+ process.exit(0);
260
+ }
261
+
262
+ function resolveFoundationStorybookRoot() {
263
+ const packageJsonPath = projectRequire.resolve("@ryuzaki13/react-foundation-ui/package.json");
264
+ return join(dirname(packageJsonPath), "storybook-static");
265
+ }
266
+
267
+ async function assertFile(filePath, label) {
268
+ const fileStat = await stat(filePath);
269
+
270
+ if (!fileStat.isFile()) {
271
+ throw new Error(`${label} is not a file: ${filePath}`);
272
+ }
273
+ }
274
+
275
+ async function assertDirectory(directoryPath, label) {
276
+ const directoryStat = await stat(directoryPath);
277
+
278
+ if (!directoryStat.isDirectory()) {
279
+ throw new Error(`${label} is not a directory: ${directoryPath}`);
280
+ }
281
+ }
282
+
283
+ async function compileHostStyles() {
284
+ const extension = extname(styleEntry);
285
+
286
+ if (extension === ".css") {
287
+ return readFile(styleEntry, "utf8");
288
+ }
289
+
290
+ const sass = await loadSass();
291
+ const result = sass.compile(styleEntry, {
292
+ style: "compressed",
293
+ loadPaths: [projectRoot, join(projectRoot, "src"), join(projectRoot, "node_modules")],
294
+ importers: [createSourceAliasImporter()]
295
+ });
296
+
297
+ return result.css;
298
+ }
299
+
300
+ async function loadSass() {
301
+ for (const packageName of ["sass-embedded", "sass"]) {
302
+ try {
303
+ const module = await import(projectRequire.resolve(packageName));
304
+ return module.default ?? module;
305
+ } catch {
306
+ // Try the next Sass implementation from the host project.
307
+ }
308
+ }
309
+
310
+ throw new Error("Install `sass` or `sass-embedded` in the host project to compile Storybook styles.");
311
+ }
312
+
313
+ function createSourceAliasImporter() {
314
+ return {
315
+ canonicalize(url, context) {
316
+ if (url.startsWith("@/")) {
317
+ const filePath = resolveSassFile(resolve(projectRoot, "src", url.slice(2)));
318
+ return filePath ? pathToFileURL(filePath) : null;
319
+ }
320
+
321
+ if (context.containingUrl?.protocol === "file:") {
322
+ const containingPath = fileURLToPath(context.containingUrl);
323
+ const filePath = resolveSassFile(resolve(dirname(containingPath), url));
324
+
325
+ if (filePath && isInside(projectRoot, filePath)) {
326
+ return pathToFileURL(filePath);
327
+ }
328
+ }
329
+
330
+ return null;
331
+ },
332
+ load(canonicalUrl) {
333
+ const filePath = fileURLToPath(canonicalUrl);
334
+
335
+ return {
336
+ contents: readFileSync(filePath, "utf8"),
337
+ syntax: resolveSassSyntax(filePath)
338
+ };
339
+ }
340
+ };
341
+ }
342
+
343
+ function resolveSassFile(filePath) {
344
+ const extension = extname(filePath);
345
+ const candidates = extension
346
+ ? [filePath, join(dirname(filePath), `_${basename(filePath)}`)]
347
+ : [
348
+ filePath,
349
+ `${filePath}.scss`,
350
+ `${filePath}.sass`,
351
+ `${filePath}.css`,
352
+ join(dirname(filePath), `_${basename(filePath)}.scss`),
353
+ join(dirname(filePath), `_${basename(filePath)}.sass`),
354
+ join(filePath, "_index.scss"),
355
+ join(filePath, "_index.sass"),
356
+ join(filePath, "index.scss"),
357
+ join(filePath, "index.sass")
358
+ ];
359
+
360
+ return candidates.find((candidate) => existsSync(candidate) && statSync(candidate).isFile());
361
+ }
362
+
363
+ function isFile(filePath) {
364
+ return existsSync(filePath) && statSync(filePath).isFile();
365
+ }
366
+
367
+ function resolveSassSyntax(filePath) {
368
+ if (filePath.endsWith(".sass")) return "indented";
369
+ if (filePath.endsWith(".css")) return "css";
370
+ return "scss";
371
+ }
372
+
373
+ function injectHostPreview(html) {
374
+ const attributes = resolveHtmlAttributes();
375
+ const script = `<script>(()=>{const root=document.documentElement;const attrs=${JSON.stringify(attributes)};for(const [name,value] of Object.entries(attrs)){if(value)root.setAttribute(name,value);}})();</script>`;
376
+ const link = `<link rel="stylesheet" href="./__foundation-host-styles.css" data-foundation-host-styles>`;
377
+
378
+ return html.replace("</head>", `${script}\n${link}\n</head>`);
379
+ }
380
+
381
+ function resolveHtmlAttributes() {
382
+ const [schemeFromTheme, modeFromTheme] = options.theme.split(":");
383
+ const attributes = {
384
+ "data-theme": options.theme,
385
+ "data-scheme": options.scheme ?? schemeFromTheme,
386
+ "data-theme-mode": options.themeMode ?? modeFromTheme ?? "default",
387
+ "data-contrast": "auto",
388
+ "data-motion": "auto",
389
+ "data-line-height": "normal",
390
+ "data-letter-spacing": "normal",
391
+ "data-word-spacing": "normal",
392
+ "data-paragraph-spacing": "normal"
393
+ };
394
+
395
+ for (const attr of options.attrs) {
396
+ const separatorIndex = attr.indexOf("=");
397
+
398
+ if (separatorIndex < 1) {
399
+ throw new Error(`Invalid --attr value: ${attr}`);
400
+ }
401
+
402
+ attributes[attr.slice(0, separatorIndex)] = attr.slice(separatorIndex + 1);
403
+ }
404
+
405
+ return attributes;
406
+ }
407
+
408
+ async function serveFile(response, root, requestPathname) {
409
+ const filePath = resolve(root, `.${requestPathname}`);
410
+
411
+ if (!isInside(root, filePath)) {
412
+ send(response, 403, "Forbidden", "text/plain; charset=utf-8");
413
+ return;
414
+ }
415
+
416
+ const fileStat = await stat(filePath);
417
+
418
+ if (!fileStat.isFile()) {
419
+ send(response, 404, "Not found", "text/plain; charset=utf-8");
420
+ return;
421
+ }
422
+
423
+ send(response, 200, await readFile(filePath), contentType(filePath));
424
+ }
425
+
426
+ function isInside(root, filePath) {
427
+ const pathFromRoot = relative(root, filePath);
428
+ return pathFromRoot === "" || (!pathFromRoot.startsWith("..") && !pathFromRoot.includes(`..${sep}`));
429
+ }
430
+
431
+ function contentType(filePath) {
432
+ const extension = extname(filePath);
433
+
434
+ if (extension === ".html") return "text/html; charset=utf-8";
435
+ if (extension === ".js") return "text/javascript; charset=utf-8";
436
+ if (extension === ".css") return "text/css; charset=utf-8";
437
+ if (extension === ".json") return "application/json; charset=utf-8";
438
+ if (extension === ".svg") return "image/svg+xml";
439
+ if (extension === ".png") return "image/png";
440
+ if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg";
441
+ if (extension === ".woff") return "font/woff";
442
+ if (extension === ".woff2") return "font/woff2";
443
+
444
+ return "application/octet-stream";
445
+ }
446
+
447
+ function send(response, status, body, type) {
448
+ response.writeHead(status, {
449
+ "Cache-Control": "no-store",
450
+ "Content-Type": type
451
+ });
452
+ response.end(body);
453
+ }
454
+ ```
455
+
456
+ И небольшой скрип запуска:
457
+
458
+ ```json
459
+ {
460
+ "scripts": {
461
+ "serve-ui-storybook": "node tools/serveFoundationUiStorybook.mjs --styles src/app/styles/shared.scss --theme light:default --port 6007"
462
+ }
463
+ }
464
+ ```
465
+
466
+ Для разработки самого пакета:
467
+
468
+ ```bash
469
+ npm run storybook
470
+ npm run build-storybook
471
+ npm run serve-storybook
472
+ ```
473
+
474
+ Для проверки npm-артефакта:
475
+
476
+ ```bash
477
+ npm run pack:dry-run
478
+ ```
32
479
 
33
- Для host-проектов со своим реестром тем используйте `@ryuzaki13/react-foundation-ui/styles/foundation`: он подключает root tokens и utility-классы без дефолтных `data-theme` selectors из UI-пакета.
480
+ ## Зависимости
34
481
 
35
- Stories публикуются отдельными subpath exports рядом с компонентами, например `@ryuzaki13/react-foundation-ui/button/stories/Button.stories`.
482
+ UI-пакет напрямую опирается на `@ryuzaki13/react-foundation-lib`.
36
483
 
37
- UI зависит от `@ryuzaki13/react-foundation-lib` и не включает API-bound OData wrappers в публичную сборку.
484
+ Часть entrypoints с OData-компонентами импортирует `@ryuzaki13/react-foundation-api/odata`. Если host-проект использует такие компоненты, он должен установить совместимую версию `@ryuzaki13/react-foundation-api` рядом с UI-пакетом. В Storybook самого пакета эта зависимость используется для демонстрационных сценариев.