refactor: заменить shiki на самописный highlighter и обновить архитектуру

- Удалён shiki (9.5→0 МБ), создан regex-токенизатор для html/css/xml
- CLI переведён с аргументов на конфиг-файл svg-sprites.config.ts
- Превью переработано: React-приложение вместо инлайн HTML
- Добавлен футер с названием пакета и ссылкой на репозиторий
- Исправлена загрузка dev-data.js для Vite 8
- Футер прижат к низу, содержимое центрировано
This commit is contained in:
2026-04-22 16:54:35 +03:00
parent aad1c97f50
commit e77e7dfcf1
154 changed files with 9083 additions and 516 deletions

108
preview/src/App.tsx Normal file
View File

@@ -0,0 +1,108 @@
import { useState, useMemo } from 'react'
import { useTheme } from './infrastructure/theme'
import { Banner } from './ui/banner'
import { SearchInput } from './ui/search-input'
import { IconGrid } from './ui/icon-grid'
import { IconModal } from './ui/icon-modal'
import type { SpritesData, IconData } from './shared/types'
import styles from './app/styles/app.module.css'
declare global {
interface Window {
__SPRITES_DATA__?: SpritesData
}
}
const MOCK_DATA: SpritesData = {
groups: [],
}
/**
* Корневой компонент превью SVG-спрайтов.
*
* Используется для:
* - отображения всех иконок из сгенерированных спрайтов
* - просмотра деталей иконки и кода использования
*/
export const App = () => {
const { toggle } = useTheme()
const data = window.__SPRITES_DATA__ ?? MOCK_DATA
const [searchQuery, setSearchQuery] = useState('')
const [selectedIconId, setSelectedIconId] = useState<string | null>(null)
const isFileProtocol = location.protocol === 'file:'
const totalIcons = useMemo(
() => data.groups.reduce((sum, g) => sum + g.icons.length, 0),
[data.groups],
)
const selectedIcon = useMemo((): IconData | null => {
if (!selectedIconId) {
return null
}
for (const group of data.groups) {
const found = group.icons.find((i) => i.id === selectedIconId)
if (found) {
return found
}
}
return null
}, [selectedIconId, data.groups])
const handleCloseModal = (): void => {
setSelectedIconId(null)
}
return (
<>
{isFileProtocol && (
<Banner variant="warn">
<strong>file://</strong> — Preview opened from local file. External SVG
references won&apos;t work in code snippets. Use a local server for full
functionality.
</Banner>
)}
<header className={styles.header}>
<h1 className={styles.title}>SVG Sprites</h1>
<span className={styles.count}>{totalIcons} icons</span>
<div className={styles.toolbar}>
<SearchInput
placeholder="Search icons..."
onValueChange={setSearchQuery}
/>
<button type="button" className={styles.themeButton} onClick={toggle}>
&#x25D1;
</button>
</div>
</header>
<IconGrid
groups={data.groups}
searchQuery={searchQuery}
onIconSelect={setSelectedIconId}
/>
<IconModal
icon={selectedIcon}
defaultSprite={data.groups[0]?.name}
onClose={handleCloseModal}
/>
<footer className={styles.footer}>
<span className={styles.footerText}>@gromlab/svg-sprites</span>
<a
className={styles.footerLink}
href="https://gromlab.ru/gromov/svg-sprites"
target="_blank"
rel="noreferrer"
>
Repository
</a>
</footer>
</>
)
}

View File

@@ -0,0 +1,64 @@
.header {
display: flex;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
margin-bottom: var(--space-6);
}
.title {
font-size: 1.5rem;
font-weight: 700;
}
.count {
font-size: 13px;
color: var(--color-muted);
font-weight: 400;
}
.toolbar {
display: flex;
gap: var(--space-3);
margin-left: auto;
align-items: center;
}
.themeButton {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-2);
background: var(--color-card-bg);
color: var(--color-fg);
cursor: pointer;
font-size: 14px;
}
.themeButton:hover {
background: var(--color-card-hover);
}
.footer {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
margin-top: auto;
padding-top: var(--space-4);
border-top: 1px solid var(--color-border);
color: var(--color-muted);
font-size: 13px;
}
.footerText {
font-weight: 500;
}
.footerLink {
color: var(--color-accent);
text-decoration: none;
}
.footerLink:hover {
text-decoration: underline;
}

View File

@@ -0,0 +1,26 @@
@import './variables.css';
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--color-bg);
color: var(--color-fg);
padding: var(--space-6);
max-width: 1400px;
margin: 0 auto;
min-height: 100dvh;
display: flex;
flex-direction: column;
}
#root {
flex: 1;
display: flex;
flex-direction: column;
}

View File

@@ -0,0 +1,60 @@
:root {
--color-bg: #f0f0f3;
--color-fg: #1a1a1a;
--color-card-bg: #ffffff;
--color-card-hover: #eaeaed;
--color-border: #d8d8d8;
--color-accent: #3b82f6;
--color-muted: #888888;
--color-code-bg: #f5f5f5;
--radius-1: 4px;
--radius-2: 8px;
--radius-3: 12px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--icon-size: 128px;
--checkerboard: conic-gradient(
var(--color-checker-a) 25%, var(--color-checker-b) 0 50%,
var(--color-checker-a) 0 75%, var(--color-checker-b) 0
);
--checkerboard-size: 8px 8px;
--color-checker-a: #e9e9e9;
--color-checker-b: #ffffff;
--font-mono: 'SF Mono', Monaco, Consolas, 'Liberation Mono', monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--color-bg: #1a1a1a;
--color-fg: #e5e5e5;
--color-card-bg: #2a2a2a;
--color-card-hover: #333333;
--color-border: #404040;
--color-code-bg: #2a2a2a;
--color-checker-a: #333333;
--color-checker-b: #2a2a2a;
}
}
:root[data-theme='dark'] {
--color-bg: #1a1a1a;
--color-fg: #e5e5e5;
--color-card-bg: #2a2a2a;
--color-card-hover: #333333;
--color-border: #404040;
--color-code-bg: #2a2a2a;
--color-checker-a: #333333;
--color-checker-b: #2a2a2a;
}

View File

@@ -0,0 +1,40 @@
import { useState, useCallback, useEffect } from 'react'
import type { Theme } from '../types/theme.type'
const getSystemTheme = (): Theme => {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
/**
* Управляет темой приложения: авто-определение по системе + ручное переключение.
*/
export const useTheme = () => {
const [theme, setTheme] = useState<Theme>(getSystemTheme)
const toggle = useCallback(() => {
setTheme((prev) => {
const next: Theme = prev === 'dark' ? 'light' : 'dark'
document.documentElement.dataset.theme = next
return next
})
}, [])
useEffect(() => {
// Устанавливаем data-theme при инициализации, чтобы CSS-селекторы
// (включая подсветку кода) работали сразу, а не только после ручного переключения.
document.documentElement.dataset.theme = getSystemTheme()
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = (): void => {
const next = getSystemTheme()
document.documentElement.dataset.theme = next
setTheme(next)
}
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}, [])
return { theme, toggle }
}

View File

@@ -0,0 +1,2 @@
export { useTheme } from './hooks/use-theme.hook'
export type { Theme } from './types/theme.type'

View File

@@ -0,0 +1,2 @@
.root {
}

View File

@@ -0,0 +1,4 @@
/**
* Theme infrastructure module — реэкспортирует хук.
* Компонент не требуется, тема управляется через data-theme на documentElement.
*/

View File

@@ -0,0 +1,11 @@
/**
* Тема приложения.
*/
export type Theme = 'light' | 'dark'
/**
* Параметры Theme.
*/
export type ThemeParams = {}
export type ThemeProps = ThemeParams

28
preview/src/main.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './app/styles/globals.css'
import { App } from './App'
function loadDevScript(): Promise<void> {
return new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = '/dev-data.js'
script.onload = () => resolve()
script.onerror = reject
document.head.appendChild(script)
})
}
async function loadDevData(): Promise<void> {
if (import.meta.env.DEV && !window.__SPRITES_DATA__) {
await loadDevScript()
}
}
loadDevData().then(() => {
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
})

View File

@@ -0,0 +1,16 @@
/**
* Конвертирует CSS-цвет (rgb или hex) в hex-формат.
*/
export const rgbToHex = (color: string): string => {
if (color.startsWith('#')) {
return color
}
const match = color.match(/\d+/g)
if (match && match.length >= 3) {
return '#' + match.slice(0, 3).map((c) => parseInt(c).toString(16).padStart(2, '0')).join('')
}
return '#000000'
}

View File

@@ -0,0 +1,6 @@
export type {
IconVar,
IconData,
SpriteGroup,
SpritesData,
} from './sprites-data.type'

View File

@@ -0,0 +1,63 @@
/**
* CSS-переменная иконки.
*/
export type IconVar = {
/** Имя CSS-переменной. */
varName: string
/** Fallback-значение. */
fallback: string
/** HEX-значение для color picker. */
hex: string
/** Является ли fallback значением currentColor. */
isCurrentColor: boolean
}
/**
* Размеры viewBox иконки.
*/
export type IconViewBox = {
x: number
y: number
width: number
height: number
}
/**
* Данные одной иконки.
*/
export type IconData = {
/** Идентификатор иконки. */
id: string
/** Имя группы (папки спрайта). */
group: string
/** Режим спрайта. */
mode: 'stack' | 'symbol'
/** Относительный путь к файлу спрайта. */
spriteFile: string
/** Размеры viewBox иконки. */
viewBox: IconViewBox | null
/** CSS-переменные иконки. */
vars: IconVar[]
}
/**
* Группа спрайтов.
*/
export type SpriteGroup = {
/** Имя группы. */
name: string
/** Режим спрайта. */
mode: 'stack' | 'symbol'
/** Относительный путь к файлу спрайта. */
spriteFile: string
/** Иконки в группе. */
icons: IconData[]
}
/**
* Данные для рендера превью.
*/
export type SpritesData = {
/** Группы спрайтов. */
groups: SpriteGroup[]
}

View File

@@ -0,0 +1,20 @@
import cl from 'clsx'
import type { BannerProps } from './types/banner.type'
import styles from './styles/banner.module.css'
/**
* Баннер-уведомление.
*
* Используется для:
* - предупреждений о file:// протоколе
* - информационных сообщений
*/
export const Banner = (props: BannerProps) => {
const { children, className, variant = 'warn', ...htmlAttr } = props
return (
<div {...htmlAttr} className={cl(styles.root, variant && styles[`_${variant}`], className)}>
{children}
</div>
)
}

View File

@@ -0,0 +1 @@
export { Banner } from './banner.ui'

View File

@@ -0,0 +1,29 @@
.root {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 10px var(--space-4);
border-radius: var(--radius-2);
font-size: 13px;
margin-bottom: var(--space-5);
}
._warn {
background: #fef3c7;
color: #92400e;
border: 1px solid #fde68a;
}
:root[data-theme='dark'] ._warn {
background: #451a03;
color: #fde68a;
border-color: #92400e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) ._warn {
background: #451a03;
color: #fde68a;
border-color: #92400e;
}
}

View File

@@ -0,0 +1,14 @@
import type { HTMLAttributes } from 'react'
/**
* Параметры Banner.
*/
export type BannerParams = {
/** Вариант отображения. */
variant?: 'warn' | 'info'
}
/** HTML-атрибуты корневого элемента. */
type RootAttrs = HTMLAttributes<HTMLDivElement>
export type BannerProps = RootAttrs & BannerParams

View File

@@ -0,0 +1,33 @@
import { useState, useMemo } from 'react'
import cl from 'clsx'
import { highlight } from './lib/highlight.lib'
import type { CodeBlockProps } from './types/code-block.type'
import styles from './styles/code-block.module.css'
export const CodeBlock = (props: CodeBlockProps) => {
const { code, language = 'html', copyable = true, className, ...htmlAttr } = props
const [isCopied, setIsCopied] = useState(false)
const highlightedHtml = useMemo(() => highlight(code, language), [code, language])
const handleCopy = (): void => {
navigator.clipboard.writeText(code).then(() => {
setIsCopied(true)
setTimeout(() => setIsCopied(false), 1500)
})
}
return (
<div {...htmlAttr} className={cl(styles.root, className)}>
<pre className={styles.pre}>
<code className={cl(styles.code, styles[language])} dangerouslySetInnerHTML={{ __html: highlightedHtml }} />
</pre>
{copyable && (
<button type="button" className={styles.copyButton} onClick={handleCopy}>
{isCopied ? 'Copied!' : 'Copy'}
</button>
)}
</div>
)
}

View File

@@ -0,0 +1 @@
export { CodeBlock } from './code-block.ui'

View File

@@ -0,0 +1,68 @@
const ESCAPE_RE = /[&<>"]/g
const ESCAPE_MAP: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }
const escape = (str: string): string => str.replace(ESCAPE_RE, (c) => ESCAPE_MAP[c])
type Token = { kind: string; match: string }
type Rule = [RegExp, string]
const HTML_RULES: Rule[] = [
[/<!--[\s\S]*?-->/g, 'comment'],
[/<\/?[\w-]+/g, 'tag'],
[/<\/?/g, 'punctuation'],
[/\/?>/g, 'punctuation'],
[/[\w-]+(?=\s*=)/g, 'attr'],
[/=/g, 'punctuation'],
[/"[^"]*"|'[^']*'/g, 'string'],
]
const CSS_RULES: Rule[] = [
[/\/\*[\s\S]*?\*\//g, 'comment'],
[/\.[\w-]+/g, 'selector'],
[/[\w-]+(?=\s*:)/g, 'property'],
[/[:;]/g, 'punctuation'],
[/\{|\}/g, 'punctuation'],
[/'[^']*'|"[^"]*"/g, 'string'],
[/#[\da-fA-F]{3,8}\b/g, 'color'],
[/\d+(?:\.\d+)?(?:px|em|rem|%|vh|vw|deg|s|ms)?/g, 'number'],
[/url\([^)]*\)/g, 'function'],
]
const RULES: Record<string, Rule[]> = { html: HTML_RULES, css: CSS_RULES, xml: HTML_RULES }
const tokenize = (code: string, lang: string): Token[] => {
const rules = RULES[lang] ?? []
const tokens: Token[] = []
let pos = 0
while (pos < code.length) {
let matched = false
for (const [re, kind] of rules) {
re.lastIndex = pos
const m = re.exec(code)
if (m && m.index === pos) {
tokens.push({ kind, match: m[0] })
pos += m[0].length
matched = true
break
}
}
if (!matched) {
const last = tokens[tokens.length - 1]
const ch = code[pos]
if (last && last.kind === 'plain') {
last.match += ch
} else {
tokens.push({ kind: 'plain', match: ch })
}
pos++
}
}
return tokens
}
export const highlight = (code: string, lang: string): string =>
tokenize(code, lang)
.map((t) => (t.kind === 'plain' ? escape(t.match) : `<span class="hl-${t.kind}">${escape(t.match)}</span>`))
.join('')

View File

@@ -0,0 +1,85 @@
.root {
position: relative;
border-radius: var(--radius-2);
overflow: hidden;
background: var(--color-code-bg);
}
.pre {
margin: 0;
padding: var(--space-4);
overflow-x: auto;
font-size: 12px;
font-family: var(--font-mono);
line-height: 1.6;
}
.code {
font-family: var(--font-mono);
}
:global(:root[data-theme='light']) .code {
--hl-tag: #116329;
--hl-attr: #0550ae;
--hl-string: #0a3069;
--hl-comment: #8b949e;
--hl-punctuation: #6639ba;
--hl-selector: #6639ba;
--hl-property: #0550ae;
--hl-color: #0a3069;
--hl-number: #0550ae;
--hl-function: #0550ae;
}
:global(:root[data-theme='dark']) .code {
--hl-tag: #7ee787;
--hl-attr: #79c0ff;
--hl-string: #a5d6ff;
--hl-comment: #8b949e;
--hl-punctuation: #d2a8ff;
--hl-selector: #d2a8ff;
--hl-property: #79c0ff;
--hl-color: #a5d6ff;
--hl-number: #79c0ff;
--hl-function: #d2a8ff;
}
.code :global(.hl-tag),
.code :global(.hl-attr),
.code :global(.hl-string),
.code :global(.hl-comment),
.code :global(.hl-punctuation),
.code :global(.hl-selector),
.code :global(.hl-property),
.code :global(.hl-color),
.code :global(.hl-number),
.code :global(.hl-function) {
color: var(--hl-tag);
}
.code :global(.hl-attr) { color: var(--hl-attr); }
.code :global(.hl-string) { color: var(--hl-string); }
.code :global(.hl-comment) { color: var(--hl-comment); }
.code :global(.hl-punctuation) { color: var(--hl-punctuation); }
.code :global(.hl-selector) { color: var(--hl-selector); }
.code :global(.hl-property) { color: var(--hl-property); }
.code :global(.hl-color) { color: var(--hl-color); }
.code :global(.hl-number) { color: var(--hl-number); }
.code :global(.hl-function) { color: var(--hl-function); }
.copyButton {
position: absolute;
top: var(--space-2);
right: var(--space-2);
padding: var(--space-1) var(--space-2);
font-size: 11px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-1);
color: var(--color-muted);
cursor: pointer;
}
.copyButton:hover {
color: var(--color-fg);
}

View File

@@ -0,0 +1,18 @@
import type { HTMLAttributes } from 'react'
/**
* Параметры CodeBlock.
*/
export type CodeBlockParams = {
/** Код для отображения. */
code: string
/** Язык подсветки синтаксиса. */
language?: 'html' | 'css' | 'xml'
/** Показывать кнопку копирования. */
copyable?: boolean
}
/** HTML-атрибуты корневого элемента. */
type RootAttrs = HTMLAttributes<HTMLDivElement>
export type CodeBlockProps = RootAttrs & CodeBlockParams

View File

@@ -0,0 +1,74 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { HexColorPicker } from 'react-colorful'
import cl from 'clsx'
import type { ColorPickerProps } from './types/color-picker.type'
import styles from './styles/color-picker.module.css'
/**
* Выбор цвета с попапом react-colorful и HEX-инпутом.
*
* Используется для:
* - настройки CSS-переменных цвета иконок
* - визуального подбора цвета в модалке
*/
export const ColorPicker = (props: ColorPickerProps) => {
const { value, onValueChange, label, className, ...htmlAttr } = props
const [isOpen, setIsOpen] = useState(false)
const popoverRef = useRef<HTMLDivElement>(null)
const swatchRef = useRef<HTMLButtonElement>(null)
const handleToggle = useCallback(() => {
setIsOpen((prev) => !prev)
}, [])
useEffect(() => {
if (!isOpen) return
const handleClickOutside = (e: MouseEvent): void => {
const target = e.target as Node
if (
popoverRef.current && !popoverRef.current.contains(target) &&
swatchRef.current && !swatchRef.current.contains(target)
) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isOpen])
const handleHexInput = (e: React.ChangeEvent<HTMLInputElement>): void => {
const hex = e.target.value
if (/^#[0-9a-fA-F]{6}$/.test(hex)) {
onValueChange(hex)
}
}
return (
<div {...htmlAttr} className={cl(styles.root, className)}>
<button
ref={swatchRef}
type="button"
className={styles.swatch}
style={{ backgroundColor: value }}
onClick={handleToggle}
/>
{label && <code className={styles.label}>{label}</code>}
{isOpen && (
<div ref={popoverRef} className={styles.popover}>
<HexColorPicker color={value} onChange={onValueChange} />
<input
type="text"
className={styles.hexInput}
value={value}
onChange={handleHexInput}
/>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1 @@
export { ColorPicker } from './color-picker.ui'

View File

@@ -0,0 +1,53 @@
.root {
position: relative;
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.swatch {
width: 24px;
height: 24px;
border-radius: var(--radius-1);
border: 1px solid var(--color-border);
cursor: pointer;
padding: 0;
background: none;
}
.label {
font-size: 12px;
color: var(--color-muted);
font-family: var(--font-mono);
}
.popover {
position: absolute;
top: calc(100% + var(--space-2));
left: 0;
z-index: 200;
padding: var(--space-3);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-2);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.hexInput {
display: block;
width: 100%;
margin-top: var(--space-2);
padding: var(--space-1) var(--space-2);
font-size: 12px;
font-family: var(--font-mono);
color: var(--color-fg);
background: var(--color-card-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-1);
outline: none;
text-align: center;
}
.hexInput:focus {
border-color: var(--color-accent);
}

View File

@@ -0,0 +1,18 @@
import type { HTMLAttributes } from 'react'
/**
* Параметры ColorPicker.
*/
export type ColorPickerParams = {
/** Текущий цвет в HEX. */
value: string
/** Обработчик изменения цвета. */
onValueChange: (color: string) => void
/** Подпись под picker-ом. */
label?: string
}
/** HTML-атрибуты корневого элемента. */
type RootAttrs = Omit<HTMLAttributes<HTMLDivElement>, 'onChange'>
export type ColorPickerProps = RootAttrs & ColorPickerParams

View File

@@ -0,0 +1,29 @@
import cl from 'clsx'
import type { IconCardProps } from './types/icon-card.type'
import styles from './styles/icon-card.module.css'
/**
* Карточка иконки в сетке превью.
*
* Используется для:
* - отображения иконки из спрайта с именем
* - открытия модалки деталей по клику
*/
export const IconCard = (props: IconCardProps) => {
const { iconId, onSelect, className, ...htmlAttr } = props
const handleClick = (): void => {
onSelect?.(iconId)
}
return (
<div {...htmlAttr} className={cl(styles.root, className)} onClick={handleClick}>
<div className={styles.iconWrap}>
<svg className={styles.icon}>
<use href={`#${iconId}`} />
</svg>
</div>
<span className={styles.name}>{iconId}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export { IconCard } from './icon-card.ui'

View File

@@ -0,0 +1,37 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding: var(--space-4) var(--space-2);
border-radius: var(--radius-2);
background: var(--color-card-bg);
cursor: pointer;
transition: background 0.15s;
}
.root:hover {
background: var(--color-card-hover);
}
.iconWrap {
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-1);
background: var(--checkerboard);
background-size: var(--checkerboard-size);
}
.icon {
width: var(--icon-size);
height: var(--icon-size);
color: var(--color-fg);
}
.name {
font-size: 13px;
color: var(--color-muted);
text-align: center;
word-break: break-all;
}

View File

@@ -0,0 +1,16 @@
import type { HTMLAttributes } from 'react'
/**
* Параметры IconCard.
*/
export type IconCardParams = {
/** Идентификатор иконки. */
iconId: string
/** Обработчик клика по карточке. */
onSelect?: (iconId: string) => void
}
/** HTML-атрибуты корневого элемента. */
type RootAttrs = Omit<HTMLAttributes<HTMLDivElement>, 'onSelect'>
export type IconCardProps = RootAttrs & IconCardParams

View File

@@ -0,0 +1,51 @@
import cl from 'clsx'
import { IconCard } from '../icon-card'
import type { IconGridProps } from './types/icon-grid.type'
import styles from './styles/icon-grid.module.css'
/**
* Сетка иконок, сгруппированная по спрайтам.
*
* Используется для:
* - отображения всех иконок из всех спрайтов
* - фильтрации иконок по поисковому запросу
*/
export const IconGrid = (props: IconGridProps) => {
const { groups, searchQuery = '', onIconSelect, className, ...htmlAttr } = props
const query = searchQuery.toLowerCase()
return (
<div {...htmlAttr} className={cl(styles.root, className)}>
{groups.map((group) => {
const filteredIcons = query
? group.icons.filter((icon) => icon.id.includes(query))
: group.icons
const isGroupHidden = filteredIcons.length === 0
return (
<section
key={group.name}
className={cl(styles.group, isGroupHidden && styles._hidden)}
>
<h2 className={styles.groupHeader}>
{group.name}
<span className={styles.badge}>{group.mode}</span>
<span className={styles.count}>{group.icons.length}</span>
</h2>
<div className={styles.grid}>
{filteredIcons.map((icon) => (
<IconCard
key={icon.id}
iconId={icon.id}
onSelect={onIconSelect}
/>
))}
</div>
</section>
)
})}
</div>
)
}

View File

@@ -0,0 +1 @@
export { IconGrid } from './icon-grid.ui'

View File

@@ -0,0 +1,42 @@
.root {
}
.group {
margin-bottom: var(--space-10);
}
.groupHeader {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: 1.1rem;
font-weight: 600;
margin-bottom: var(--space-4);
}
.badge {
font-size: 11px;
font-weight: 500;
padding: 2px var(--space-2);
border-radius: 10px;
background: var(--color-accent);
color: #ffffff;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.count {
font-size: 13px;
color: var(--color-muted);
font-weight: 400;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: var(--space-3);
}
._hidden {
display: none;
}

View File

@@ -0,0 +1,19 @@
import type { HTMLAttributes } from 'react'
import type { SpriteGroup } from '../../../shared/types'
/**
* Параметры IconGrid.
*/
export type IconGridParams = {
/** Группы спрайтов для отображения. */
groups: SpriteGroup[]
/** Строка поиска для фильтрации. */
searchQuery?: string
/** Обработчик выбора иконки. */
onIconSelect?: (iconId: string) => void
}
/** HTML-атрибуты корневого элемента. */
type RootAttrs = HTMLAttributes<HTMLDivElement>
export type IconGridProps = RootAttrs & IconGridParams

View File

@@ -0,0 +1,235 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import cl from 'clsx'
import { CodeBlock } from '../code-block'
import { ColorPicker } from '../color-picker'
import { rgbToHex } from '../../shared/lib/rgb-to-hex.util'
import type { IconModalProps } from './types/icon-modal.type'
import styles from './styles/icon-modal.module.css'
type TabId = 'react' | 'svg' | 'img' | 'css'
const TABS: { id: TabId; label: string }[] = [
{ id: 'react', label: 'React' },
{ id: 'svg', label: 'SVG' },
{ id: 'img', label: 'IMG' },
{ id: 'css', label: 'CSS' },
]
/**
* Модалка деталей иконки с превью, color pickers и табами кода.
*
* Превью иконки рендерится тем же способом, что выбран в табе:
* - React / SVG — через <svg><use href>
* - IMG — через <img src>
* - CSS — через mask-image на <div>
*/
export const IconModal = (props: IconModalProps) => {
const { icon, onClose, defaultSprite, className, ...htmlAttr } = props
const [activeTab, setActiveTab] = useState<TabId>('react')
const [colors, setColors] = useState<Record<string, string>>({})
const [cssColor, setCssColor] = useState('#000000')
const iconWrapRef = useRef<HTMLDivElement>(null)
const handleOverlayClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
onClose()
}
}, [onClose])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
onClose()
}
}
if (icon) {
document.addEventListener('keydown', handleKeyDown)
}
return () => document.removeEventListener('keydown', handleKeyDown)
}, [icon, onClose])
useEffect(() => {
if (!icon) return
const fg = getComputedStyle(document.documentElement).getPropertyValue('--color-fg').trim()
const fgHex = rgbToHex(fg)
const initialColors: Record<string, string> = {}
icon.vars.forEach((v) => {
initialColors[v.varName] = v.isCurrentColor ? fgHex : v.hex
})
setColors(initialColors)
setCssColor(fgHex)
setActiveTab('react')
if (iconWrapRef.current) {
iconWrapRef.current.removeAttribute('style')
}
}, [icon])
if (!icon) {
return null
}
const spriteRef = `${icon.spriteFile}#${icon.id}`
const isDefaultSprite = icon.group === defaultSprite
const codeReact = isDefaultSprite
? `<SvgSprite icon="${icon.id}" />`
: `<SvgSprite icon="${icon.id}" sprite="${icon.group}" />`
const codeSvg = `<svg width="24" height="24">\n <use href="${spriteRef}"/>\n</svg>`
const codeImg = `<img src="${spriteRef}" width="24" height="24" alt="${icon.id}">`
const codeCss = `.${icon.id} {\n width: 24px;\n height: 24px;\n mask: url('${spriteRef}') no-repeat center / contain;\n -webkit-mask: url('${spriteRef}') no-repeat center / contain;\n background-color: ${cssColor};\n}`
const codeByTab: Record<TabId, { code: string; language: 'html' | 'css' }> = {
react: { code: codeReact, language: 'html' },
svg: { code: codeSvg, language: 'html' },
img: { code: codeImg, language: 'html' },
css: { code: codeCss, language: 'css' },
}
const handleColorChange = (varName: string, value: string): void => {
setColors((prev) => ({ ...prev, [varName]: value }))
iconWrapRef.current?.style.setProperty(varName, value)
}
const renderPreview = () => {
switch (activeTab) {
case 'react':
case 'svg':
return (
<svg className={styles.icon}>
<use href={`#${icon.id}`} />
</svg>
)
case 'img':
return (
<img
className={styles.iconImg}
src={`${icon.spriteFile}#${icon.id}`}
width={96}
height={96}
alt={icon.id}
/>
)
case 'css':
return (
<div
className={styles.iconCss}
style={{
mask: `url('${icon.spriteFile}#${icon.id}') no-repeat center / contain`,
WebkitMask: `url('${icon.spriteFile}#${icon.id}') no-repeat center / contain`,
backgroundColor: cssColor,
}}
/>
)
}
}
const supportsColorChange = activeTab === 'react' || activeTab === 'svg'
const isMono = icon.vars.length > 0 && icon.vars.every((v) => v.isCurrentColor)
return (
<div className={styles.overlay} onClick={handleOverlayClick}>
<div {...htmlAttr} className={cl(styles.root, className)}>
<button type="button" className={styles.closeButton} onClick={onClose}>
&#x2715;
</button>
<div ref={iconWrapRef} className={styles.iconWrap}>
<div className={styles.iconViewBox}>
{renderPreview()}
</div>
</div>
<div className={styles.nameRow}>
<div className={styles.name}>{icon.id}</div>
{icon.viewBox && (
<span className={styles.viewBoxBadge}>
{icon.viewBox.width} &times; {icon.viewBox.height}
</span>
)}
</div>
<div className={styles.varsSection}>
{icon.vars.length > 0 && supportsColorChange ? (
<>
<div className={styles.colorHint}>
{isMono ? (
<span>
Иконка наследует цвет текста или задаётся точечно через CSS-переменную.
</span>
) : (
<span>
Многоцветная иконка каждый цвет задаётся отдельной CSS-переменной.
</span>
)}
</div>
<div className={styles.varsTitle}>CSS Variables</div>
{icon.vars.map((v) => (
<ColorPicker
key={v.varName}
value={colors[v.varName] || v.hex}
onValueChange={(color) => handleColorChange(v.varName, color)}
label={`${v.varName}: ${v.fallback}`}
className={styles.varRow}
/>
))}
</>
) : activeTab === 'img' ? (
<div className={styles.hint}>
Управление цветом в режиме IMG невозможно. &lt;img&gt; изолирует SVG CSS-переменные
и currentColor не проникают внутрь. Подходит для многоцветных изображений с фиксированными цветами.
</div>
) : activeTab === 'css' ? (
<>
<div className={styles.hint}>
В режиме CSS mask иконка монохромная цвет задаётся через background-color.
CSS-переменные спрайта не поддерживаются.
</div>
<ColorPicker
value={cssColor}
onValueChange={setCssColor}
label="background-color"
className={styles.cssColorRow}
/>
</>
) : (
<span className={styles.noVars}>No color variables</span>
)}
</div>
<div className={styles.tabs}>
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
className={cl(styles.tab, activeTab === tab.id && styles._active)}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
{TABS.map((tab) => (
<div
key={tab.id}
className={cl(styles.tabContent, activeTab === tab.id && styles._active)}
>
<CodeBlock
code={codeByTab[tab.id].code}
language={codeByTab[tab.id].language}
/>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1 @@
export { IconModal } from './icon-modal.ui'

View File

@@ -0,0 +1,211 @@
.overlay {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: flex-start;
justify-content: center;
padding: var(--space-10) 0;
overflow-y: auto;
background: rgba(0, 0, 0, 0.5);
}
.root {
position: relative;
width: 90%;
max-width: 560px;
padding: var(--space-6);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-3);
}
.closeButton {
position: absolute;
top: var(--space-3);
right: var(--space-3);
padding: var(--space-1) var(--space-2);
background: none;
border: none;
color: var(--color-fg);
font-size: 20px;
cursor: pointer;
line-height: 1;
}
.iconWrap {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-6);
margin-bottom: var(--space-4);
background: var(--color-card-bg);
border-radius: var(--radius-2);
}
.iconViewBox {
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-1);
background: var(--checkerboard);
background-size: var(--checkerboard-size);
}
.icon,
.iconImg,
.iconCss {
width: 256px;
height: 256px;
}
.icon {
color: var(--color-fg);
}
.iconImg {
object-fit: contain;
}
.iconCss {
background-color: var(--color-fg);
}
.nameRow {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.name {
font-size: 1rem;
font-weight: 600;
}
.viewBoxBadge {
font-size: 11px;
font-weight: 500;
padding: 2px var(--space-2);
border-radius: 10px;
background: var(--color-card-bg);
border: 1px solid var(--color-border);
color: var(--color-muted);
white-space: nowrap;
}
.varsSection {
margin-bottom: var(--space-5);
}
.varsTitle {
font-size: 12px;
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: var(--space-2);
}
.varRow {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.noVars {
font-size: 12px;
color: var(--color-muted);
font-style: italic;
}
.hint {
font-size: 12px;
line-height: 1.5;
color: var(--color-muted);
padding: var(--space-2) var(--space-3);
background: var(--color-card-bg);
border-radius: var(--radius-1);
border-left: 3px solid var(--color-accent);
}
.colorHint {
margin-bottom: var(--space-3);
padding: var(--space-2) var(--space-3);
font-size: 12px;
line-height: 1.5;
color: var(--color-muted);
background: var(--color-card-bg);
border-radius: var(--radius-1);
border-left: 3px solid var(--color-accent);
}
.colorHintTitle {
font-size: 12px;
font-weight: 600;
color: var(--color-muted);
margin-bottom: var(--space-1);
}
.colorHintRow {
display: flex;
align-items: baseline;
gap: var(--space-2);
margin-top: var(--space-1);
}
.colorHintCode {
font-family: var(--font-mono);
font-size: 11px;
padding: 2px var(--space-1);
background: var(--color-bg);
border-radius: 3px;
white-space: nowrap;
}
.colorHintDesc {
font-size: 11px;
color: var(--color-muted);
}
.cssColorRow {
display: flex;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-3);
}
.tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-3);
}
.tab {
padding: var(--space-2) var(--space-4);
font-size: 12px;
font-weight: 600;
cursor: pointer;
border: none;
border-bottom: 2px solid transparent;
color: var(--color-muted);
background: none;
}
.tab._active {
color: var(--color-accent);
border-bottom-color: var(--color-accent);
}
.tabContent {
display: none;
}
.tabContent._active {
display: block;
}

View File

@@ -0,0 +1,19 @@
import type { HTMLAttributes } from 'react'
import type { IconData } from '../../../shared/types'
/**
* Параметры IconModal.
*/
export type IconModalParams = {
/** Данные иконки для отображения. null — модалка закрыта. */
icon: IconData | null
/** Имя спрайта по умолчанию (первый из конфига). */
defaultSprite?: string
/** Обработчик закрытия модалки. */
onClose: () => void
}
/** HTML-атрибуты корневого элемента. */
type RootAttrs = HTMLAttributes<HTMLDivElement>
export type IconModalProps = RootAttrs & IconModalParams

View File

@@ -0,0 +1 @@
export { SearchInput } from './search-input.ui'

View File

@@ -0,0 +1,27 @@
import cl from 'clsx'
import type { SearchInputProps } from './types/search-input.type'
import styles from './styles/search-input.module.css'
/**
* Поле поиска.
*
* Используется для:
* - фильтрации иконок по имени
*/
export const SearchInput = (props: SearchInputProps) => {
const { className, onValueChange, ...htmlAttr } = props
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
onValueChange?.(e.target.value)
}
return (
<input
{...htmlAttr}
type="search"
autoComplete="off"
onChange={handleChange}
className={cl(styles.root, className)}
/>
)
}

View File

@@ -0,0 +1,14 @@
.root {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-2);
background: var(--color-card-bg);
color: var(--color-fg);
font-size: 14px;
width: 200px;
outline: none;
}
.root:focus {
border-color: var(--color-accent);
}

View File

@@ -0,0 +1,14 @@
import type { InputHTMLAttributes } from 'react'
/**
* Параметры SearchInput.
*/
export type SearchInputParams = {
/** Обработчик изменения значения поиска. */
onValueChange?: (value: string) => void
}
/** HTML-атрибуты корневого элемента. */
type RootAttrs = Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'>
export type SearchInputProps = RootAttrs & SearchInputParams

6
preview/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.module.css' {
const classes: { readonly [key: string]: string }
export default classes
}