ValyroUI English version

Начало работы: SvelteKit

SSR без вспышки: hooks.server.ts подставляет тему из cookie в <html> серверного HTML; стили импортируются в корневом layout

1. Создайте приложение

Проверено с SvelteKit 3.0 и Svelte 5.57. В SvelteKit 3 конфиг Kit (и адаптер) живёт в vite.config.ts, а $lib заменён subpath-импортами #lib.

npx sv create my-app --template minimal --types ts --no-add-ons --install npm
cd my-app

2. Установите пакет

Из пакета нужен только CSS: valyro-ui/styles.css (плюс файл темы, если тема не из двух встроенных). Шрифты — отдельные файлы: тема vercel (по умолчанию в ThemeProvider) использует Geist, поэтому рядом со стилями импортируйте valyro-ui/fonts/geist.css и valyro-ui/fonts/geist-mono.css, иначе текст будет системным шрифтом; glass использует системные шрифты, ей файлы не нужны. npm сам положит react и react-dom в node_modules как peer-зависимости, но в бандл они не попадут, пока вы их не импортируете. Пока пакет не опубликован, установите tarball: npm install ../valyro-ui-0.1.0.tgz.

npm install valyro-ui

3. Подключите стили

<script lang="ts">
	import 'valyro-ui/styles.css';
	import favicon from '#lib/assets/favicon.svg';
	import type { LayoutProps } from './$types';

	let { children }: LayoutProps = $props();
</script>

<svelte:head>
	<link rel="icon" href={favicon} />
</svelte:head>

{@render children()}

4. Тема без вспышки

data-theme (vercel, glass или подключённая отдельным файлом тема) и data-mode (light или dark) на <html> включают тему: от них зависят токены --ui-* и фон страницы. В шаблоне документа вместо значений — плейсхолдеры, их заменяет серверный хук.

<!doctype html>
<html lang="en" data-theme="%ui.theme%" data-mode="%ui.mode%">
	<head>
		<meta charset="utf-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1" />
		<meta name="text-scale" content="scale" />
		%sveltekit.head%
	</head>
	<body data-sveltekit-preload-data="hover">
		<div style="display: contents">%sveltekit.body%</div>
	</body>
</html>

5. Серверный хук

Хук читает cookie, пропускает только известные значения и пишет их в HTML до отправки. В SvelteKit 3 тип Handle импортируется из @sveltejs/kit/hooks.

import type { Handle } from '@sveltejs/kit/hooks';

const THEMES = ['glass', 'vercel'] as const;
const MODES = ['light', 'dark'] as const;

// Cookie values are user input: only known values reach the HTML.
function pick<T extends string>(value: string | undefined, allowed: readonly T[], fallback: T): T {
	return allowed.includes(value as T) ? (value as T) : fallback;
}

export const handle: Handle = async ({ event, resolve }) => {
	const theme = pick(event.cookies.get('ui-theme'), THEMES, 'glass');
	const mode = pick(event.cookies.get('ui-mode'), MODES, 'light');

	return resolve(event, {
		transformPageChunk: ({ html }) => html.replace('%ui.theme%', theme).replace('%ui.mode%', mode)
	});
};

6. Переключатель и разметка

Кнопки меняют атрибут на <html> и сохраняют выбор в cookie для следующего серверного рендера. Разметка — ровно то, что рендерят React-компоненты (Button, Badge, TextInput, Alert, Card). Без React работают только визуальные компоненты (список — в гайде HTML + CSS); разметку любого из них показывает пример «Использование» на странице компонента, когда выбран этот фреймворк.

<script lang="ts">
	// The server already wrote data-theme/data-mode on <html> (hooks.server.ts);
	// the buttons flip the attribute and persist the choice in a cookie for the next SSR.
	function toggle(key: 'theme' | 'mode', a: string, b: string) {
		const next = document.documentElement.dataset[key] === a ? b : a;
		document.documentElement.dataset[key] = next;
		document.cookie = `ui-${key}=${next}; path=/; max-age=31536000; samesite=lax`;
	}
</script>

<main style="display: grid; justify-items: start; gap: 16px; max-width: 560px; padding: 32px">
	<div style="display: flex; gap: 8px">
		<button class="ui-button" data-variant="secondary" data-size="md" type="button" onclick={() => toggle('theme', 'glass', 'vercel')}>
			<span class="ui-button__label">Toggle theme</span>
		</button>
		<button class="ui-button" data-variant="secondary" data-size="md" type="button" onclick={() => toggle('mode', 'light', 'dark')}>
			<span class="ui-button__label">Toggle mode</span>
		</button>
	</div>

  <button class="ui-button" data-variant="primary" data-size="md" type="button"><span class="ui-button__label">Deploy</span></button>
  <span class="ui-badge" data-color="green" data-variant="subtle" data-size="md"><span class="ui-badge__label">Ready</span></span>
  <div class="ui-field ui-text-input" data-size="md"><input aria-label="Email" placeholder="[email protected]" type="text" class="ui-field__input ui-text-input__input" /></div>
  <div role="status" class="ui-alert" data-variant="info">
    <span class="ui-alert__icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false" class="ui-icon"><circle cx="12" cy="12" r="10"></circle><path d="M12 16v-4"></path><path d="M12 8h.01"></path></svg></span>
    <div class="ui-alert__content"><div class="ui-alert__title">Heads up</div><div class="ui-alert__description">Static markup, no React at runtime.</div></div>
  </div>
  <div class="ui-card ui-panel" data-variant="default" data-padding="md">
    <div class="ui-card__header"><h3 class="ui-card__title">Project</h3><p class="ui-card__description">Production deployment</p></div>
    <div class="ui-card__content">Body</div>
    <div class="ui-card__footer"><button class="ui-button" data-variant="secondary" data-size="md" type="button"><span class="ui-button__label">Open</span></button></div>
  </div>
</main>

7. Соберите и запустите

С адаптером по умолчанию (adapter-auto) работает npm run preview. Для своего Node-сервера поставьте @sveltejs/adapter-node и замените импорт адаптера в vite.config.ts на import adapter from '@sveltejs/adapter-node';.

npm run check
npm run build
npm run preview

# own Node server
npm i -D @sveltejs/adapter-node
npm run build
node build

8. Интерактивные компоненты — React-островом

Select, Dialog и другие компоненты с состоянием работают только в React. Svelte-action монтирует их через createRoot только в браузере, поэтому при SSR React не рендерится. Бандл страницы с островом вырастет на React 19 (≈ +80 kB gzip), остальные страницы не меняются.

npm install react react-dom
npm install -D @types/react @types/react-dom

9. Action острова

ThemeProvider пишет тему на <html>, поэтому передавайте ему текущие значения. children у него обязательный проп — передавайте его в объекте пропсов, а не третьим аргументом createElement.

import { createElement, type ComponentType } from 'react';
import { createRoot } from 'react-dom/client';
import { ThemeProvider, type ThemeMode, type ThemeName } from 'valyro-ui';

export interface IslandParams<P extends object> {
	component: ComponentType<P>;
	props: P;
	theme: ThemeName;
	mode: ThemeMode;
}

// Svelte action: actions run only in the browser, so React never renders during SSR.
export function reactIsland<P extends object>(node: HTMLElement, params: IslandParams<P>) {
	const root = createRoot(node);
	const render = ({ component, props, theme, mode }: IslandParams<P>) =>
		root.render(
			createElement(ThemeProvider, { theme, mode, locale: 'en-US', children: createElement(component, props) })
		);
	render(params);
	return { update: render, destroy: () => root.unmount() };
}

10. Тема для острова

Острову нужны тема и режим ещё до монтирования. Сохраните их в locals: в handle перед return добавьте event.locals.theme = theme; и event.locals.mode = mode;, а тип опишите в app.d.ts.

// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
	namespace App {
		// interface Error {}
		interface Locals {
			theme: 'glass' | 'vercel';
			mode: 'light' | 'dark';
		}
		// interface PageData {}
		// interface PageState {}
		// interface Platform {}
	}
}

export {};

11. Страница с островом

import type { PageServerLoad } from './$types';

export const load: PageServerLoad = ({ locals }) => ({ theme: locals.theme, mode: locals.mode });

12. Остров в разметке

Импорт #lib/react-island.ts — с расширением: subpath-импорты не подставляют его, и без него svelte-check не найдёт модуль.

<script lang="ts">
	import { Select } from 'valyro-ui';
	import { reactIsland } from '#lib/react-island.ts';
	import type { PageProps } from './$types';

	let { data }: PageProps = $props();
	let theme = $derived(data.theme);
	let mode = $derived(data.mode);

	function save(key: 'theme' | 'mode', value: string) {
		document.documentElement.dataset[key] = value;
		document.cookie = `ui-${key}=${value}; path=/; max-age=31536000; samesite=lax`;
	}

	const options = [
		{ value: 'eu', label: 'Europe' },
		{ value: 'us', label: 'United States' }
	];
</script>

<main style="display: grid; gap: 16px; max-width: 640px; padding: 32px">
	<div style="display: flex; gap: 8px">
		<button class="ui-button" data-variant="secondary" data-size="md" type="button" onclick={() => save('theme', (theme = theme === 'glass' ? 'vercel' : 'glass'))}>
			<span class="ui-button__label">Toggle theme</span>
		</button>
		<button class="ui-button" data-variant="secondary" data-size="md" type="button" onclick={() => save('mode', (mode = mode === 'light' ? 'dark' : 'light'))}>
			<span class="ui-button__label">Toggle mode</span>
		</button>
	</div>

	<!-- React island: a ValyroUI Select rendered by React inside a Svelte page. -->
	<div use:reactIsland={{ component: Select, props: { 'aria-label': 'Region', defaultValue: 'eu', options }, theme, mode }}></div>
</main>

Другие фреймворки