@ryuzaki13/react-foundation-ui
v1.1.5
Published
Reusable React foundation UI primitives and Storybook stories.
Maintainers
Readme
@ryuzaki13/react-foundation-ui
React UI primitives, выделенные из бывшего src/shared/ui.
Пакет публикуется в npm как обычная публичная библиотека, но его основная цель практическая: разделить повторно используемый UI-слой между собственными проектами автора. Это не попытка сделать универсальную дизайн-систему для всех сценариев. API, стили и Storybook в первую очередь оптимизируются под семейство проектов, где рядом используются @ryuzaki13/react-foundation-lib и @ryuzaki13/react-foundation-api.
Импорты
Корневой импорт намеренно не открыт. Используйте точечные entrypoints:
import { Button } from "@ryuzaki13/react-foundation-ui/button";
import { ContextMenu } from "@ryuzaki13/react-foundation-ui/context-menu";
import type { UiControlProps } from "@ryuzaki13/react-foundation-ui/types";Стили подключаются отдельно и один раз на уровне host-приложения.
Подключение стилей
Готовый CSS
Самый простой вариант подходит для песочниц или приложений, которым достаточно дефолтных токенов пакета:
import "@ryuzaki13/react-foundation-ui/styles.css";В этом режиме значения CSS-переменных уже скомпилированы в dist/styles.css. Host-проект может переопределять их обычным CSS поверх, но это runtime override, а не build-time настройка.
Sass с настройкой host-проекта
Для рабочих проектов предпочтительнее Sass API. Он позволяет задать значения переменных на этапе сборки:
@use "@ryuzaki13/react-foundation-ui/styles/config" with (
$root-token-overrides: (
"--font-family": (
"Inter",
sans-serif
),
"--font-family-mono": (
"JetBrains Mono",
monospace
),
"--radius-md": 8px
),
$light-theme-overrides: (
tokens: (
"--surface-0": #ffffff,
"--surface-1": #f6f7f9,
"--surface-2": #eceff3,
"--content-0": #111827,
"--content-1": #374151,
"--content-2": #6b7280
)
)
);
@use "@ryuzaki13/react-foundation-ui/styles.scss";Важно: styles/config должен быть настроен до первого подключения любых foundation styles в Sass module graph. Иначе Sass уже загрузит конфиг с дефолтами и повторная настройка через with (...) не сработает.
Host со своими темами
Если проект сам управляет темами, например через data-theme="light:default", data-scheme="light" и data-theme-mode="default", лучше подключать базовый слой отдельно от selectors:
@use "@ryuzaki13/react-foundation-ui/styles/config" with (
$root-token-overrides: (
"--font-family": (
"Inter",
sans-serif
)
),
$light-theme-overrides: (
tokens: (
"--surface-0": #ffffff,
"--surface-1": #f6f7f9,
"--surface-2": #eceff3
)
),
$dark-theme-overrides: (
tokens: (
"--surface-0": #0b0b0b,
"--surface-1": #151515,
"--surface-2": #202020
)
)
);
@use "@ryuzaki13/react-foundation-ui/styles/foundation";
@use "@ryuzaki13/react-foundation-ui/styles/themes" as foundationThemes;
:root[data-theme="light:default"] {
@include foundationThemes.light-theme;
}
:root[data-theme="dark:default"] {
@include foundationThemes.dark-theme;
}styles/foundation подключает normalize, root tokens, base styles и utility classes, но не навязывает дефолтные selectors data-theme="light" / data-theme="dark". Selectors остаются ответственностью host-приложения.
Обычно в host-проекте это оформляется отдельным entrypoint, например src/app/styles/shared.scss, который сначала настраивает foundation config, затем подключает foundation styles, темы и доменные CSS-переменные приложения.
Просмотр изображений
ImageViewer из точечного entrypoint @ryuzaki13/react-foundation-ui/image предоставляет controlled lightbox для одного изображения или галереи. Реализация использует Yet Another React Lightbox как внутренний runtime, но наружу публикует только foundation-контракты ImageViewerImage, ImageViewerFeatures, ImageViewerLabels и ImageViewerStyle.
Поскольку lightbox подключён как optional peer dependency, host устанавливает совместимую версию явно:
npm install yet-another-react-lightbox@^3.32.1Отдельно импортировать CSS Yet Another React Lightbox не нужно: необходимые core/plugin styles уже входят в общий @ryuzaki13/react-foundation-ui/styles.css. JavaScript runtime YARL и его плагины загружаются отдельным динамическим chunk только при первом open и после загрузки остаются смонтированными для корректного завершения close lifecycle.
import { useState } from "react";
import { ImageViewer, type ImageViewerImage } from "@ryuzaki13/react-foundation-ui/image";
const images: readonly ImageViewerImage[] = [
{
src: "/images/42/primary-webp",
alt: "Учебный корпус",
intrinsicWidth: 1920,
intrinsicHeight: 1080,
candidates: [
{ src: "/images/42/640-webp", width: 640 },
{ src: "/images/42/1280-webp", width: 1280 }
],
thumbnail: "/images/42/320-webp"
}
];
export function MaterialGallery() {
const [open, setOpen] = useState(false);
const [index, setIndex] = useState(0);
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Открыть галерею
</button>
<ImageViewer
open={open}
images={images}
index={index}
onIndexChange={setIndex}
onClose={() => setOpen(false)}
/>
</>
);
}По умолчанию включены Zoom и Fullscreen; Captions включаются при наличии title, description или alt, а Thumbnails и Counter — для галереи из нескольких изображений. Download отключён до явного features={{ download: true }}. Поля thumbnail и download принимают уже разрешённые URL и не знают о правилах storage host-приложения.
Внешний вид настраивается стабильными CSS-переменными --image-viewer-*. Они могут быть глобальными либо scoped через переданные className/style; это важно, поскольку viewer рендерится в portal за пределами DOM-ветки trigger:
.projectImageViewer {
--image-viewer-z-index: 320;
--image-viewer-backdrop: rgb(5 10 20 / 96%);
--image-viewer-control-color: rgb(255 255 255 / 82%);
--image-viewer-control-active-color: #7dd3fc;
--image-viewer-control-disabled-color: rgb(255 255 255 / 38%);
--image-viewer-control-background: transparent;
--image-viewer-control-border: 0;
--image-viewer-control-radius: 0.3125rem;
--image-viewer-control-filter: drop-shadow(0 0.125rem 0.25rem rgb(0 0 0 / 70%));
--image-viewer-icon-size: 1.75rem;
--image-viewer-icon-stroke-width: 2;
--image-viewer-toolbar-padding: 0.625rem;
--image-viewer-focus: 0.125rem solid #7dd3fc;
--image-viewer-focus-offset: 0.125rem;
--image-viewer-image-radius: 0.3125rem;
--image-viewer-caption-background: rgb(5 10 20 / 72%);
--image-viewer-caption-color: #fff;
--image-viewer-counter-color: rgb(255 255 255 / 82%);
--image-viewer-thumbnail-background: rgb(255 255 255 / 8%);
--image-viewer-thumbnail-border-color: rgb(255 255 255 / 60%);
--image-viewer-thumbnail-active-border-color: #7dd3fc;
--image-viewer-loading-color: #fff;
--image-viewer-error-color: #ef4444;
}Публичный CSS-контракт не включает переменные --yarl__*: они остаются implementation detail и могут меняться вместе с внутренней библиотекой.
Storybook
Stories и MDX не экспортируются как public API пакета. Вместо этого пакет собирает готовую статическую документацию:
npm run build-storybookРезультат лежит в storybook-static и включается в npm package через files. После установки пакета host-проект может отдать эту директорию:
npx http-server node_modules/@ryuzaki13/react-foundation-ui/storybook-static -p 6007Такой запуск покажет готовые stories пакета, но не подмешает theme tokens конкретного host-проекта. Если нужно видеть компоненты в проектной теме, host должен запускать небольшую обертку:
- отдать файлы из
node_modules/@ryuzaki13/react-foundation-ui/storybook-static; - скомпилировать свой SCSS entrypoint, например
src/app/styles/shared.scss; - внедрить ссылку на host CSS в
iframe.html; - выставить нужные атрибуты на
document.documentElement:data-theme,data-scheme,data-theme-modeи связанные accessibility presets; - отдавать host public assets, например шрифты, не перекрывая
storybook-static/assets.
Именно host отвечает за конкретные значения CSS-переменных. Это позволяет одному и тому же Storybook пакета отображаться по-разному в разных проектах без копирования stories и MDX.
Пример обертки для запуска storybook в host проекте:
#!/usr/bin/env node
// tools/serveFoundationUiStorybook.mjs
import { existsSync, readFileSync, statSync } from "node:fs";
import { readFile, stat } from "node:fs/promises";
import { createServer } from "node:http";
import { createRequire } from "node:module";
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const projectRoot = process.cwd();
const projectRequire = createRequire(join(projectRoot, "package.json"));
const options = parseArgs(process.argv.slice(2));
const styleEntry = resolve(projectRoot, options.styles);
const publicRoot = resolve(projectRoot, options.public);
const storybookRoot = resolveFoundationStorybookRoot();
await assertFile(styleEntry, "Style entry");
await assertDirectory(storybookRoot, "Foundation UI Storybook");
await compileHostStyles();
const server = createServer(async (request, response) => {
try {
const requestUrl = new URL(request.url ?? "/", `http://${options.host}:${options.port}`);
if (requestUrl.pathname === "/__foundation-host-styles.css") {
const css = await compileHostStyles();
send(response, 200, css, "text/css; charset=utf-8");
return;
}
if (requestUrl.pathname.startsWith("/assets/")) {
const storybookAssetPath = resolve(storybookRoot, `.${requestUrl.pathname}`);
if (isInside(storybookRoot, storybookAssetPath) && isFile(storybookAssetPath)) {
await serveFile(response, storybookRoot, requestUrl.pathname);
return;
}
await serveFile(response, publicRoot, requestUrl.pathname);
return;
}
const pathname = requestUrl.pathname === "/" ? "/index.html" : requestUrl.pathname;
if (pathname === "/iframe.html") {
const html = await readFile(join(storybookRoot, "iframe.html"), "utf8");
send(response, 200, injectHostPreview(html), "text/html; charset=utf-8");
return;
}
await serveFile(response, storybookRoot, pathname);
} catch (error) {
const status = error?.code === "ENOENT" ? 404 : 500;
send(response, status, status === 404 ? "Not found" : String(error?.stack ?? error), "text/plain; charset=utf-8");
}
});
server.listen(options.port, options.host, () => {
console.log(`Foundation UI Storybook: http://${options.host}:${options.port}`);
});
function parseArgs(args) {
const result = {
host: "127.0.0.1",
port: 6007,
public: "public",
styles: "src/app/styles/shared.scss",
theme: "light:default",
scheme: undefined,
themeMode: undefined,
attrs: []
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--host") result.host = readArgValue(args, ++index, arg);
else if (arg === "--port") result.port = Number(readArgValue(args, ++index, arg));
else if (arg === "--public") result.public = readArgValue(args, ++index, arg);
else if (arg === "--styles") result.styles = readArgValue(args, ++index, arg);
else if (arg === "--theme") result.theme = readArgValue(args, ++index, arg);
else if (arg === "--scheme") result.scheme = readArgValue(args, ++index, arg);
else if (arg === "--theme-mode") result.themeMode = readArgValue(args, ++index, arg);
else if (arg === "--attr") result.attrs.push(readArgValue(args, ++index, arg));
else if (arg === "--help") printHelpAndExit();
else throw new Error(`Unknown argument: ${arg}`);
}
if (!Number.isInteger(result.port) || result.port <= 0) {
throw new Error(`Invalid --port value: ${result.port}`);
}
return result;
}
function readArgValue(args, index, name) {
const value = args[index];
if (!value || value.startsWith("--")) {
throw new Error(`Missing value for ${name}`);
}
return value;
}
function printHelpAndExit() {
console.log(
[
"Usage: node tools/serveFoundationUiStorybook.mjs [options]",
"",
"Options:",
" --styles <path> Host SCSS/CSS entry. Default: src/app/styles/shared.scss",
" --public <path> Host public directory. Default: public",
" --theme <value> data-theme value. Default: light:default",
" --scheme <value> data-scheme value. Default: first part of --theme",
" --theme-mode <value> data-theme-mode value. Default: second part of --theme",
" --attr <name=value> Additional html attribute. Can be repeated.",
" --host <host> Server host. Default: 127.0.0.1",
" --port <port> Server port. Default: 6007"
].join("\n")
);
process.exit(0);
}
function resolveFoundationStorybookRoot() {
const packageJsonPath = projectRequire.resolve("@ryuzaki13/react-foundation-ui/package.json");
return join(dirname(packageJsonPath), "storybook-static");
}
async function assertFile(filePath, label) {
const fileStat = await stat(filePath);
if (!fileStat.isFile()) {
throw new Error(`${label} is not a file: ${filePath}`);
}
}
async function assertDirectory(directoryPath, label) {
const directoryStat = await stat(directoryPath);
if (!directoryStat.isDirectory()) {
throw new Error(`${label} is not a directory: ${directoryPath}`);
}
}
async function compileHostStyles() {
const extension = extname(styleEntry);
if (extension === ".css") {
return readFile(styleEntry, "utf8");
}
const sass = await loadSass();
const result = sass.compile(styleEntry, {
style: "compressed",
loadPaths: [projectRoot, join(projectRoot, "src"), join(projectRoot, "node_modules")],
importers: [createSourceAliasImporter()]
});
return result.css;
}
async function loadSass() {
for (const packageName of ["sass-embedded", "sass"]) {
try {
const module = await import(projectRequire.resolve(packageName));
return module.default ?? module;
} catch {
// Try the next Sass implementation from the host project.
}
}
throw new Error("Install `sass` or `sass-embedded` in the host project to compile Storybook styles.");
}
function createSourceAliasImporter() {
return {
canonicalize(url, context) {
if (url.startsWith("@/")) {
const filePath = resolveSassFile(resolve(projectRoot, "src", url.slice(2)));
return filePath ? pathToFileURL(filePath) : null;
}
if (context.containingUrl?.protocol === "file:") {
const containingPath = fileURLToPath(context.containingUrl);
const filePath = resolveSassFile(resolve(dirname(containingPath), url));
if (filePath && isInside(projectRoot, filePath)) {
return pathToFileURL(filePath);
}
}
return null;
},
load(canonicalUrl) {
const filePath = fileURLToPath(canonicalUrl);
return {
contents: readFileSync(filePath, "utf8"),
syntax: resolveSassSyntax(filePath)
};
}
};
}
function resolveSassFile(filePath) {
const extension = extname(filePath);
const candidates = extension
? [filePath, join(dirname(filePath), `_${basename(filePath)}`)]
: [
filePath,
`${filePath}.scss`,
`${filePath}.sass`,
`${filePath}.css`,
join(dirname(filePath), `_${basename(filePath)}.scss`),
join(dirname(filePath), `_${basename(filePath)}.sass`),
join(filePath, "_index.scss"),
join(filePath, "_index.sass"),
join(filePath, "index.scss"),
join(filePath, "index.sass")
];
return candidates.find((candidate) => existsSync(candidate) && statSync(candidate).isFile());
}
function isFile(filePath) {
return existsSync(filePath) && statSync(filePath).isFile();
}
function resolveSassSyntax(filePath) {
if (filePath.endsWith(".sass")) return "indented";
if (filePath.endsWith(".css")) return "css";
return "scss";
}
function injectHostPreview(html) {
const attributes = resolveHtmlAttributes();
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>`;
const link = `<link rel="stylesheet" href="./__foundation-host-styles.css" data-foundation-host-styles>`;
return html.replace("</head>", `${script}\n${link}\n</head>`);
}
function resolveHtmlAttributes() {
const [schemeFromTheme, modeFromTheme] = options.theme.split(":");
const attributes = {
"data-theme": options.theme,
"data-scheme": options.scheme ?? schemeFromTheme,
"data-theme-mode": options.themeMode ?? modeFromTheme ?? "default",
"data-contrast": "auto",
"data-motion": "auto",
"data-line-height": "normal",
"data-letter-spacing": "normal",
"data-word-spacing": "normal",
"data-paragraph-spacing": "normal"
};
for (const attr of options.attrs) {
const separatorIndex = attr.indexOf("=");
if (separatorIndex < 1) {
throw new Error(`Invalid --attr value: ${attr}`);
}
attributes[attr.slice(0, separatorIndex)] = attr.slice(separatorIndex + 1);
}
return attributes;
}
async function serveFile(response, root, requestPathname) {
const filePath = resolve(root, `.${requestPathname}`);
if (!isInside(root, filePath)) {
send(response, 403, "Forbidden", "text/plain; charset=utf-8");
return;
}
const fileStat = await stat(filePath);
if (!fileStat.isFile()) {
send(response, 404, "Not found", "text/plain; charset=utf-8");
return;
}
send(response, 200, await readFile(filePath), contentType(filePath));
}
function isInside(root, filePath) {
const pathFromRoot = relative(root, filePath);
return pathFromRoot === "" || (!pathFromRoot.startsWith("..") && !pathFromRoot.includes(`..${sep}`));
}
function contentType(filePath) {
const extension = extname(filePath);
if (extension === ".html") return "text/html; charset=utf-8";
if (extension === ".js") return "text/javascript; charset=utf-8";
if (extension === ".css") return "text/css; charset=utf-8";
if (extension === ".json") return "application/json; charset=utf-8";
if (extension === ".svg") return "image/svg+xml";
if (extension === ".png") return "image/png";
if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg";
if (extension === ".woff") return "font/woff";
if (extension === ".woff2") return "font/woff2";
return "application/octet-stream";
}
function send(response, status, body, type) {
response.writeHead(status, {
"Cache-Control": "no-store",
"Content-Type": type
});
response.end(body);
}И небольшой скрип запуска:
{
"scripts": {
"serve-ui-storybook": "node tools/serveFoundationUiStorybook.mjs --styles src/app/styles/shared.scss --theme light:default --port 6007"
}
}Для разработки самого пакета:
npm run storybook
npm run build-storybook
npm run serve-storybookДля проверки npm-артефакта:
npm run pack:dry-runЗависимости
UI-пакет напрямую опирается на @ryuzaki13/react-foundation-lib.
Часть entrypoints с OData-компонентами импортирует @ryuzaki13/react-foundation-api/odata. Если host-проект использует такие компоненты, он должен установить совместимую версию @ryuzaki13/react-foundation-api рядом с UI-пакетом. В Storybook самого пакета эта зависимость используется для демонстрационных сценариев.
