feat: Новый кросс платформенный вьювер

This commit is contained in:
2026-07-14 09:54:36 +03:00
parent c596f9f1c3
commit 1280eb6fcd
46 changed files with 1440 additions and 785 deletions

View File

@@ -89,7 +89,7 @@ mode A -X-> mode B
mode A -X-> shared output codegen
```
`src/react/` с `SpriteViewer` является отдельным runtime пакета, а не mode adapter.
`src/viewer/` с Web Component является framework-neutral browser runtime пакета, а не mode adapter. `src/react/` содержит только React bridge к этому runtime.
## Изменение mode

View File

@@ -210,7 +210,7 @@ Create themes, states, and hover effects without editing the SVG or making addit
## SpriteViewer: every sprite on one debug page
`SpriteViewer` renders all project sprites in one place and shows which icons are included in each set and how they look.
`SpriteViewer` renders all standalone, React, and Next.js project sprites in one place. One Web Component owns the visuals, while React uses a thin bridge to it.
For each icon, you can see the generated CSS variables and their fallback colors. Change the values directly in the Viewer and see the result immediately.
@@ -225,6 +225,8 @@ It also provides ready-to-use integration examples for:
The Viewer is added only to an internal debug page and does not become part of the generated icon components.
Bare standalone loads the Viewer as a browser script and HTML element; bundler modes use the npm entry, while React and Next.js import `SpriteViewer` from `@gromlab/svg-sprites/react`.
## Standalone, React, and Next.js
The package generates low-level standalone sprites for static HTML, Vite, and Webpack 5, plus typed React components for React and Next.js.

View File

@@ -210,7 +210,7 @@ JavaScript отвечает за интерфейс и поведение, а г
## SpriteViewer: все спрайты на одной debug-странице
`SpriteViewer` рендерит все спрайты проекта в одном месте и показывает, какие иконки вошли в каждый набор и как они выглядят.
`SpriteViewer` рендерит все standalone, React и Next.js спрайты проекта в одном месте. Один Web Component отвечает за визуал, а React использует тонкий bridge к нему.
Для каждой иконки видны созданные CSS-переменные и их fallback-цвета. Значения можно менять прямо в Viewer и сразу наблюдать результат.
@@ -225,6 +225,8 @@ JavaScript отвечает за интерфейс и поведение, а г
Viewer подключается только к внутренней debug-странице и не становится частью generated-компонентов иконок.
Bare standalone подключает Viewer через browser script и HTML element; bundler modes используют npm entry, а React и Next.js импортируют `SpriteViewer` из `@gromlab/svg-sprites/react`.
## Standalone, React и Next.js
Пакет генерирует низкоуровневые standalone-спрайты для static HTML, Vite и Webpack 5, а также типизированные React-компоненты для React и Next.js.

View File

@@ -1,34 +1,41 @@
# Third-Party Notices
The distributed React entry includes code from the projects listed below.
The distributed Viewer browser bundle includes code from the projects listed below.
## React, React DOM and Scheduler
## Lit
Copyright (c) Meta Platforms, Inc. and affiliates.
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## react-colorful
Copyright (c) 2020 Vlad Shilov <omgovich@ya.ru>
Copyright (c) 2017 Google LLC. All rights reserved.
BSD 3-Clause License
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
## vanilla-colorful
Copyright (c) 2020 Serhii Kulykov <iamkulykov@gmail.com>
MIT License

View File

@@ -2,7 +2,7 @@
[← Back to home](../../README.md)
The package is ESM-only and provides one Node.js generation API. The React runtime with `SpriteViewer` is available from the separate `@gromlab/svg-sprites/react` entry point.
The package is ESM-only and provides one Node.js generation API. The framework-neutral Viewer is available from `@gromlab/svg-sprites/viewer`, its auto-register entry from `@gromlab/svg-sprites/viewer/element`, and the React bridge from `@gromlab/svg-sprites/react`.
## `generateSprite`
@@ -129,10 +129,19 @@ import {
These functions are intended for custom orchestration. Standard generation should use `generateSprite`.
## React runtime
## Viewer runtime
```ts
import '@gromlab/svg-sprites/viewer/element'
import type { SpriteViewerElement } from '@gromlab/svg-sprites/viewer'
```
The browser entry registers `<gromlab-sprite-viewer>`. Bare standalone can also load the self-contained `dist/viewer-element.js` without a bundler.
The React bridge keeps the component API:
```tsx
import { SpriteViewer } from '@gromlab/svg-sprites/react'
```
`SpriteViewer` accepts generated manifests, lazy loaders, or an `import.meta.glob` result. This entry point contains `'use client'` and is intended for debug tools; production components are imported from local sprite modules.
`SpriteViewer` accepts generated manifests, remote standalone sources, lazy loaders, or an `import.meta.glob` result. The React entry contains `'use client'` and is intended for debug tools; production components are imported from local sprite modules.

View File

@@ -425,13 +425,43 @@ For a complex icon, you can disable `replaceColors` in a separate sprite configu
## SpriteViewer
`SpriteViewer` is imported from a separate client entry point:
The Viewer uses one Shadow DOM Web Component for every mode. React and future framework components are bridges to that same element, so the visuals and behavior are not duplicated.
Bare `standalone` loads the self-contained browser bundle and supplies the JSON manifest URL and the published SVG URL:
```html
<script
type="module"
src="https://unpkg.com/@gromlab/svg-sprites@<version>/dist/viewer-element.js"
></script>
<gromlab-sprite-viewer
viewer-title="Project icons"
manifest-url="/app-icons/manifest.json"
sprite-url="/app-icons/sprite.svg"
></gromlab-sprite-viewer>
```
`viewer-element.js` has no additional runtime files and can be copied with the other static assets for self-hosting.
`standalone@vite` and `standalone@webpack` register the same element through an npm entry and pass the generated JS manifest through the `sources` property:
```ts
import '@gromlab/svg-sprites/viewer/element'
import type { SpriteViewerElement } from '@gromlab/svg-sprites/viewer'
import spriteManifest from './svg-sprite/.svg-sprite/svg-sprite.manifest.js'
const viewer = document.querySelector<SpriteViewerElement>('gromlab-sprite-viewer')!
viewer.sources = [spriteManifest]
```
React and Next.js keep the component API:
```tsx
import { SpriteViewer } from '@gromlab/svg-sprites/react'
```
It accepts ready-made React/Next manifests, an array of lazy loaders, or a record in the format returned by `import.meta.glob`. The current Viewer does not load standalone manifests; standalone will use a separate viewer contract.
It accepts ready-made manifests, remote standalone sources, an array of lazy loaders, or a record in the format returned by `import.meta.glob`.
Vite:
@@ -461,7 +491,7 @@ export const IconsDebugPage = () => (
)
```
The Viewer displays groups, search, `viewBox`, CSS custom properties, fallback colors, and React, SVG, IMG, and CSS examples. You can change color values in the interface and immediately inspect the result.
The Viewer displays groups, search, `viewBox`, CSS custom properties, and fallback colors. React/Next manifests get React, SVG, IMG, and CSS tabs; standalone manifests get SVG, IMG, and CSS. You can change color values in the interface and immediately inspect the result.
### Viewer theme
@@ -481,7 +511,7 @@ To synchronize it with the application theme:
/>
```
`@gromlab/svg-sprites/react` contains `'use client'`. In the Next.js App Router, place the Viewer inside a separate Client Component boundary and use it only on a debug route or in an internal tool.
`@gromlab/svg-sprites/react` contains `'use client'` and renders the Web Component host; its internal Shadow DOM is created after the browser runtime loads. In the Next.js App Router, place the Viewer inside a separate Client Component boundary and use it only on a debug route or in an internal tool.
## Generated files, Git, and CI

View File

@@ -2,7 +2,7 @@
[← Главная](../../README_RU.md)
Пакет распространяется как ESM и предоставляет единый Node.js API генерации. React runtime с `SpriteViewer` находится в отдельной точке входа `@gromlab/svg-sprites/react`.
Пакет распространяется как ESM и предоставляет единый Node.js API генерации. Framework-neutral Viewer находится в `@gromlab/svg-sprites/viewer`, auto-register entry — в `@gromlab/svg-sprites/viewer/element`, React bridge — в `@gromlab/svg-sprites/react`.
## `generateSprite`
@@ -129,10 +129,19 @@ import {
Эти функции предназначены для собственного orchestration. Стандартная генерация должна выполняться через `generateSprite`.
## React runtime
## Viewer runtime
```ts
import '@gromlab/svg-sprites/viewer/element'
import type { SpriteViewerElement } from '@gromlab/svg-sprites/viewer'
```
Browser entry регистрирует `<gromlab-sprite-viewer>`. Bare standalone также может загрузить самостоятельный `dist/viewer-element.js` без bundler.
React bridge сохраняет компонентный API:
```tsx
import { SpriteViewer } from '@gromlab/svg-sprites/react'
```
`SpriteViewer` принимает generated manifests, lazy loaders или результат `import.meta.glob`. Эта точка входа содержит `'use client'` и предназначена для debug-инструментов; production-компоненты импортируются из локальных sprite-модулей приложения.
`SpriteViewer` принимает generated manifests, remote standalone sources, lazy loaders или результат `import.meta.glob`. React entry содержит `'use client'` и предназначен для debug-инструментов; production-компоненты импортируются из локальных sprite-модулей приложения.

View File

@@ -425,13 +425,43 @@ fill="var(--icon-color-3, #129d9d)"
## SpriteViewer
`SpriteViewer` подключается из отдельной клиентской точки входа:
Viewer использует один Web Component с Shadow DOM для всех modes. React и будущие framework-компоненты являются bridge к этому же элементу, поэтому визуал и поведение не дублируются.
Bare `standalone` подключает самостоятельный browser bundle и передаёт URL JSON manifest и опубликованного SVG:
```html
<script
type="module"
src="https://unpkg.com/@gromlab/svg-sprites@<version>/dist/viewer-element.js"
></script>
<gromlab-sprite-viewer
viewer-title="Иконки проекта"
manifest-url="/app-icons/manifest.json"
sprite-url="/app-icons/sprite.svg"
></gromlab-sprite-viewer>
```
`viewer-element.js` не имеет дополнительных runtime-файлов и может быть скопирован с остальными static assets для self-hosting.
`standalone@vite` и `standalone@webpack` регистрируют тот же элемент через npm entry и передают generated JS manifest через свойство `sources`:
```ts
import '@gromlab/svg-sprites/viewer/element'
import type { SpriteViewerElement } from '@gromlab/svg-sprites/viewer'
import spriteManifest from './svg-sprite/.svg-sprite/svg-sprite.manifest.js'
const viewer = document.querySelector<SpriteViewerElement>('gromlab-sprite-viewer')!
viewer.sources = [spriteManifest]
```
React и Next.js сохраняют компонентный API:
```tsx
import { SpriteViewer } from '@gromlab/svg-sprites/react'
```
Он принимает готовые React/Next manifests, массив lazy loaders или record формата `import.meta.glob`. Текущий Viewer не загружает standalone manifests; для standalone будет отдельный viewer-контракт.
Он принимает готовые manifests, remote standalone sources, массив lazy loaders или record формата `import.meta.glob`.
Vite:
@@ -461,7 +491,7 @@ export const IconsDebugPage = () => (
)
```
Viewer показывает группы, поиск, `viewBox`, CSS-переменные, fallback-цвета и примеры React, SVG, IMG и CSS. Цветовые значения можно менять в интерфейсе и сразу проверять результат.
Viewer показывает группы, поиск, `viewBox`, CSS-переменные и fallback-цвета. React/Next manifests получают вкладки React, SVG, IMG и CSS; standalone manifests получают SVG, IMG и CSS. Цветовые значения можно менять в интерфейсе и сразу проверять результат.
### Тема Viewer
@@ -481,7 +511,7 @@ Viewer показывает группы, поиск, `viewBox`, CSS-перем
/>
```
`@gromlab/svg-sprites/react` содержит `'use client'`. В Next.js App Router размещайте Viewer внутри отдельной Client Component boundary и используйте только на debug-маршруте или во внутреннем инструменте.
`@gromlab/svg-sprites/react` содержит `'use client'` и рендерит Web Component host; внутренний Shadow DOM создаётся после загрузки browser runtime. В Next.js App Router размещайте Viewer внутри отдельной Client Component boundary и используйте только на debug-маршруте или во внутреннем инструменте.
## Generated-файлы, Git и CI

View File

@@ -53,6 +53,8 @@ npm run dev --workspace @svg-sprites-fixtures/react-vite --prefix integration
Перед E2E production builds должны существовать. Тест запускает каждый server
последовательно, проверяет внешний `href`, HTTP status и Content-Type спрайта,
наличие symbol ID, отсутствие browser errors и зелёные пиксели отрисованной иконки.
Для каждого active mode тест также открывает единый Viewer, проверяет его Shadow DOM,
совпадение sprite URL, карточку `check`, dialog и mode-specific вкладки кода.
Static fixture копирует managed SVG и JSON manifest в `dist/app-icons/` и использует
literal `<use href="/app-icons/sprite.svg#check">`.

View File

@@ -1,4 +1,5 @@
import { IconsIcon } from '../src/sprite'
import { AppSpriteViewer } from './sprite-viewer'
export default function Page() {
return (
@@ -13,6 +14,7 @@ export default function Page() {
height={64}
style={{ '--icon-color-1': '#16a34a' }}
/>
<AppSpriteViewer />
</main>
)
}

View File

@@ -0,0 +1,11 @@
'use client'
import { SpriteViewer } from '@gromlab/svg-sprites/react'
const viewerSources = [
() => import('../src/sprite/.svg-sprite/svg-sprite.manifest.js'),
] as const
export function AppSpriteViewer() {
return <SpriteViewer sources={viewerSources} title="Next App Turbopack Viewer" style={{ marginTop: 32 }} />
}

View File

@@ -1,10 +1,10 @@
import path from 'node:path'
const integrationRoot = path.resolve(import.meta.dirname, '../..')
const repositoryRoot = path.resolve(import.meta.dirname, '../../..')
export default {
outputFileTracingRoot: integrationRoot,
outputFileTracingRoot: repositoryRoot,
turbopack: {
root: integrationRoot,
root: repositoryRoot,
},
}

View File

@@ -1,4 +1,5 @@
import { IconsIcon } from '../src/sprite'
import { AppSpriteViewer } from './sprite-viewer'
export default function Page() {
return (
@@ -13,6 +14,7 @@ export default function Page() {
height={64}
style={{ '--icon-color-1': '#16a34a' }}
/>
<AppSpriteViewer />
</main>
)
}

View File

@@ -0,0 +1,11 @@
'use client'
import { SpriteViewer } from '@gromlab/svg-sprites/react'
const viewerSources = [
() => import('../src/sprite/.svg-sprite/svg-sprite.manifest.js'),
] as const
export function AppSpriteViewer() {
return <SpriteViewer sources={viewerSources} title="Next App Webpack Viewer" style={{ marginTop: 32 }} />
}

View File

@@ -1,10 +1,10 @@
import path from 'node:path'
const integrationRoot = path.resolve(import.meta.dirname, '../..')
const repositoryRoot = path.resolve(import.meta.dirname, '../../..')
export default {
outputFileTracingRoot: integrationRoot,
outputFileTracingRoot: repositoryRoot,
turbopack: {
root: integrationRoot,
root: repositoryRoot,
},
}

View File

@@ -1,10 +1,10 @@
import path from 'node:path'
const integrationRoot = path.resolve(import.meta.dirname, '../..')
const repositoryRoot = path.resolve(import.meta.dirname, '../../..')
export default {
outputFileTracingRoot: integrationRoot,
outputFileTracingRoot: repositoryRoot,
turbopack: {
root: integrationRoot,
root: repositoryRoot,
},
}

View File

@@ -1,5 +1,10 @@
import { SpriteViewer } from '@gromlab/svg-sprites/react'
import { IconsIcon } from '../src/sprite'
const viewerSources = [
() => import('../src/sprite/.svg-sprite/svg-sprite.manifest.js'),
] as const
export default function Page() {
return (
<main>
@@ -13,6 +18,7 @@ export default function Page() {
height={64}
style={{ '--icon-color-1': '#16a34a' }}
/>
<SpriteViewer sources={viewerSources} title="Next Pages Turbopack Viewer" style={{ marginTop: 32 }} />
</main>
)
}

View File

@@ -1,10 +1,16 @@
import path from 'node:path'
const repositoryRoot = path.resolve(import.meta.dirname, '../../..')
const integrationRoot = path.resolve(import.meta.dirname, '../..')
export default {
outputFileTracingRoot: integrationRoot,
outputFileTracingRoot: repositoryRoot,
turbopack: {
root: integrationRoot,
root: repositoryRoot,
},
webpack(config) {
config.resolve.alias.react = path.resolve(integrationRoot, 'node_modules/react')
config.resolve.alias['react-dom'] = path.resolve(integrationRoot, 'node_modules/react-dom')
return config
},
}

View File

@@ -1,5 +1,10 @@
import { SpriteViewer } from '@gromlab/svg-sprites/react'
import { IconsIcon } from '../src/sprite'
const viewerSources = [
() => import('../src/sprite/.svg-sprite/svg-sprite.manifest.js'),
] as const
export default function Page() {
return (
<main>
@@ -13,6 +18,7 @@ export default function Page() {
height={64}
style={{ '--icon-color-1': '#16a34a' }}
/>
<SpriteViewer sources={viewerSources} title="Next Pages Webpack Viewer" style={{ marginTop: 32 }} />
</main>
)
}

View File

@@ -1,9 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { SpriteViewer } from '@gromlab/svg-sprites/react'
import { IconsIcon } from './sprite'
import './style.css'
const viewerSources = [
() => import('./sprite/.svg-sprite/svg-sprite.manifest.js'),
] as const
createRoot(document.getElementById('root')!).render(
<StrictMode>
<main>
@@ -17,6 +22,7 @@ createRoot(document.getElementById('root')!).render(
height={64}
style={{ '--icon-color-1': '#16a34a' }}
/>
<SpriteViewer sources={viewerSources} title="React Vite Viewer" style={{ marginTop: 32 }} />
</main>
</StrictMode>,
)

View File

@@ -3,4 +3,7 @@ import { defineConfig } from 'vite'
export default defineConfig({
plugins: [react()],
resolve: {
dedupe: ['react', 'react-dom'],
},
})

View File

@@ -1,9 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { SpriteViewer } from '@gromlab/svg-sprites/react'
import { IconsIcon } from './sprite'
import './style.css'
const viewerSources = [
() => import('./sprite/.svg-sprite/svg-sprite.manifest.js'),
] as const
createRoot(document.getElementById('root')!).render(
<StrictMode>
<main>
@@ -17,6 +22,7 @@ createRoot(document.getElementById('root')!).render(
height={64}
style={{ '--icon-color-1': '#16a34a' }}
/>
<SpriteViewer sources={viewerSources} title="React Webpack Viewer" style={{ marginTop: 32 }} />
</main>
</StrictMode>,
)

View File

@@ -15,6 +15,10 @@ export default {
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
alias: {
react: path.resolve(root, '../../node_modules/react'),
'react-dom': path.resolve(root, '../../node_modules/react-dom'),
},
},
module: {
rules: [

View File

@@ -1,3 +1,5 @@
import '@gromlab/svg-sprites/viewer/element'
import type { SpriteViewerElement } from '@gromlab/svg-sprites/viewer'
import spriteManifest from './sprite/.svg-sprite/svg-sprite.manifest.js'
import { getIconsIconHref, iconsSpriteUrl } from './sprite'
import './style.css'
@@ -17,4 +19,9 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
>
<use href="${getIconsIconHref('check')}"></use>
</svg>
<gromlab-sprite-viewer></gromlab-sprite-viewer>
`
const viewer = document.querySelector<SpriteViewerElement>('gromlab-sprite-viewer')!
viewer.viewerTitle = 'Standalone Vite Viewer'
viewer.sources = [spriteManifest]

View File

@@ -1,3 +1,5 @@
import '@gromlab/svg-sprites/viewer/element'
import type { SpriteViewerElement } from '@gromlab/svg-sprites/viewer'
import spriteManifest from './sprite/.svg-sprite/svg-sprite.manifest.js'
import { getIconsIconHref, iconsSpriteUrl } from './sprite'
@@ -17,4 +19,9 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
>
<use href="${getIconsIconHref('check')}"></use>
</svg>
<gromlab-sprite-viewer></gromlab-sprite-viewer>
`
const viewer = document.querySelector<SpriteViewerElement>('gromlab-sprite-viewer')!
viewer.viewerTitle = 'Standalone Webpack Viewer'
viewer.sources = [spriteManifest]

View File

@@ -3,6 +3,10 @@ import fs from 'node:fs'
fs.rmSync(new URL('./dist/', import.meta.url), { recursive: true, force: true })
fs.mkdirSync(new URL('./dist/app-icons/', import.meta.url), { recursive: true })
fs.copyFileSync(new URL('./index.html', import.meta.url), new URL('./dist/index.html', import.meta.url))
fs.copyFileSync(
new URL('../../../dist/viewer-element.js', import.meta.url),
new URL('./dist/viewer-element.js', import.meta.url),
)
fs.copyFileSync(
new URL('./src/sprite/.svg-sprite/sprite.svg', import.meta.url),
new URL('./dist/app-icons/sprite.svg', import.meta.url),

View File

@@ -7,6 +7,7 @@
<style>
body { margin: 0; padding: 40px; font-family: system-ui, sans-serif; }
[data-testid="icon"] { width: 64px; height: 64px; color: #16a34a; --icon-color-1: #16a34a; }
gromlab-sprite-viewer { margin-top: 32px; }
</style>
</head>
<body>
@@ -15,6 +16,12 @@
<svg data-testid="icon" data-app="standalone" viewBox="0 0 24 24" aria-label="Check icon">
<use href="/app-icons/sprite.svg#check"></use>
</svg>
<gromlab-sprite-viewer
viewer-title="Standalone Viewer"
manifest-url="/app-icons/manifest.json"
sprite-url="/app-icons/sprite.svg"
></gromlab-sprite-viewer>
</main>
<script type="module" src="/viewer-element.js"></script>
</body>
</html>

View File

@@ -34,11 +34,12 @@
"@types/react-dom": "^19.2.3",
"@types/svg-sprite": "^0.0.39",
"jsdom": "^26.1.0",
"lit": "^3.3.3",
"react": "^19.2.5",
"react-colorful": "^5.6.1",
"react-dom": "^19.2.5",
"tsup": "^8.4.0",
"typescript": "^5.8.3"
"typescript": "^5.8.3",
"vanilla-colorful": "^0.7.2"
},
"engines": {
"node": ">=18"

View File

@@ -104,6 +104,8 @@ for (const app of apps) {
try {
await waitForServer(server, origin)
await page.goto(origin)
await page.waitForLoadState('networkidle')
expect(browserErrors).toEqual([])
const icon = page.getByTestId('icon')
await expect(icon).toBeVisible()
@@ -122,6 +124,47 @@ for (const app of apps) {
expect(spriteResponse.headers.get('content-type') ?? '').toMatch(/image\/svg\+xml/)
expect(await spriteResponse.text()).toMatch(/id="check"/)
const viewer = page.locator('gromlab-sprite-viewer')
await expect(viewer).toBeVisible()
await expect(viewer.locator('[data-sprite-viewer]')).toBeVisible()
const viewerCard = viewer.locator('[data-icon-name="check"]')
await expect(viewerCard).toBeVisible()
const viewerHref = await viewerCard.locator('use').getAttribute('href')
expect(viewerHref).toBeTruthy()
expect(viewerHref!.split('#')[1]).toBe('check')
expect(new URL(viewerHref!.split('#')[0], page.url()).href).toBe(spriteUrl.href)
await viewerCard.click()
const dialog = viewer.getByRole('dialog', { name: 'check' })
await expect(dialog).toBeVisible()
await expect(dialog.getByRole('tab', { name: 'SVG' })).toBeVisible()
if (app.id.startsWith('standalone')) {
await expect(dialog.getByRole('tab', { name: 'React' })).toHaveCount(0)
} else {
await expect(dialog.getByRole('tab', { name: 'React' })).toBeVisible()
}
const colorSwatch = dialog.getByRole('button', { name: /Изменить цвет --icon-color-1/ })
await colorSwatch.click()
const colorPicker = dialog.locator('gromlab-hex-color-picker')
await expect(colorPicker).toBeVisible()
const saturationBounds = await colorPicker.locator('[part="saturation"]').boundingBox()
const hueBounds = await colorPicker.locator('[part="hue"]').boundingBox()
expect(saturationBounds?.height).toBeGreaterThan(100)
expect(hueBounds?.height).toBeGreaterThanOrEqual(20)
const popoverBounds = await dialog.locator('.gromlab-sprite-viewer__color-popover').boundingBox()
const colorInputBounds = await dialog.locator('gromlab-hex-input input').boundingBox()
expect(colorInputBounds!.x).toBeGreaterThanOrEqual(popoverBounds!.x)
expect(colorInputBounds!.x + colorInputBounds!.width).toBeLessThanOrEqual(
popoverBounds!.x + popoverBounds!.width,
)
await dialog.locator('.gromlab-sprite-viewer__color-label').click()
await expect(colorPicker).toHaveCount(0)
await dialog.getByRole('button', { name: 'Закрыть' }).click()
await expect(dialog).toHaveCount(0)
const screenshot = PNG.sync.read(await icon.screenshot())
let coloredPixels = 0
for (let index = 0; index < screenshot.data.length; index += 4) {

81
package-lock.json generated
View File

@@ -22,11 +22,12 @@
"@types/react-dom": "^19.2.3",
"@types/svg-sprite": "^0.0.39",
"jsdom": "^26.1.0",
"lit": "^3.3.3",
"react": "^19.2.5",
"react-colorful": "^5.6.1",
"react-dom": "^19.2.5",
"tsup": "^8.4.0",
"typescript": "^5.8.3"
"typescript": "^5.8.3",
"vanilla-colorful": "^0.7.2"
},
"engines": {
"node": ">=18"
@@ -670,6 +671,23 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lit-labs/ssr-dom-shim": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz",
"integrity": "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@lit/reactive-element": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.2.tgz",
"integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.5.0"
}
},
"node_modules/@resvg/resvg-js": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz",
@@ -1307,6 +1325,13 @@
"integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==",
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/vinyl": {
"version": "2.0.12",
"resolved": "https://registry.npmjs.org/@types/vinyl/-/vinyl-2.0.12.tgz",
@@ -2171,6 +2196,40 @@
"dev": true,
"license": "MIT"
},
"node_modules/lit": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz",
"integrity": "sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@lit/reactive-element": "^2.1.0",
"lit-element": "^4.2.0",
"lit-html": "^3.3.0"
}
},
"node_modules/lit-element": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.2.tgz",
"integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.5.0",
"@lit/reactive-element": "^2.1.0",
"lit-html": "^3.3.0"
}
},
"node_modules/lit-html": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.3.tgz",
"integrity": "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@types/trusted-types": "^2.0.2"
}
},
"node_modules/load-tsconfig": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz",
@@ -2489,17 +2548,6 @@
"node": ">=0.10.0"
}
},
"node_modules/react-colorful": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
"integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-dom": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
@@ -3053,6 +3101,13 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vanilla-colorful": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/vanilla-colorful/-/vanilla-colorful-0.7.2.tgz",
"integrity": "sha512-z2YZusTFC6KnLERx1cgoIRX2CjPRP0W75N+3CC6gbvdX5Ch47rZkEMGO2Xnf+IEmi3RiFLxS18gayMA27iU7Kg==",
"dev": true,
"license": "MIT"
},
"node_modules/vinyl": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/vinyl/-/vinyl-2.2.1.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "@gromlab/svg-sprites",
"version": "1.1.5",
"description": "Generate SVG sprites and typed icon components for React and Next.js",
"description": "Generate SVG sprites and typed icon components for standalone, React, and Next.js",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -13,18 +13,35 @@
"./react": {
"types": "./dist/react.d.ts",
"import": "./dist/react.js"
},
"./viewer": {
"types": "./dist/viewer.d.ts",
"import": "./dist/viewer.js"
},
"./viewer/element": {
"types": "./dist/viewer-element.d.ts",
"import": "./dist/viewer-element.js"
}
},
"bin": {
"svg-sprites": "./dist/cli.js"
},
"files": [
"dist/*.js",
"dist/*.js.map",
"dist/*.d.ts",
"dist/index.js",
"dist/index.js.map",
"dist/index.d.ts",
"dist/react.js",
"dist/react.js.map",
"dist/react.d.ts",
"dist/viewer.js",
"dist/viewer.js.map",
"dist/viewer.d.ts",
"dist/viewer-element.js",
"dist/viewer-element.js.map",
"dist/viewer-element.d.ts",
"dist/cli.js",
"dist/cli.js.map",
"dist/cli.d.ts",
@@ -39,11 +56,11 @@
],
"scripts": {
"build": "npm run build:package",
"build:package": "tsup",
"build:package": "tsup && tsup --config tsup.viewer.config.ts",
"build:skill": "node skills/svg-sprites/build.mjs",
"check:skill": "node skills/svg-sprites/build.mjs --check",
"dev": "tsup --watch",
"test": "tsup && node --test test/*.test.mjs",
"test": "npm run build:package && node --test test/*.test.mjs",
"typecheck": "tsc --noEmit",
"integration:install": "npm ci --prefix integration",
"integration:generate": "npm run build:package && npm run generate --prefix integration",
@@ -100,10 +117,11 @@
"@types/react-dom": "^19.2.3",
"@types/svg-sprite": "^0.0.39",
"jsdom": "^26.1.0",
"lit": "^3.3.3",
"react": "^19.2.5",
"react-colorful": "^5.6.1",
"react-dom": "^19.2.5",
"tsup": "^8.4.0",
"typescript": "^5.8.3"
"typescript": "^5.8.3",
"vanilla-colorful": "^0.7.2"
}
}

View File

@@ -1,317 +0,0 @@
import { HexColorInput, HexColorPicker } from 'react-colorful'
import { useEffect, useId, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import {
generateViewerCode,
highlightViewerCode,
normalizeHexColor,
tabsForFormat,
viewBoxSize,
} from './sprite-viewer-code.js'
import type { SpriteViewerTab } from './sprite-viewer-code.js'
import type { SpriteManifest, SpriteManifestIcon } from './types.js'
type SpriteViewerDialogProps = {
manifest: SpriteManifest
icon: SpriteManifestIcon
colorTheme: 'light' | 'dark'
onClose: () => void
}
type ColorControlProps = {
label: string
value: string
onChange: (value: string) => void
}
function ColorControl({ label, value, onChange }: ColorControlProps) {
const [open, setOpen] = useState(false)
const swatchRef = useRef<HTMLButtonElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const popoverId = useId()
useEffect(() => {
if (!open) return
function handlePointerDown(event: PointerEvent) {
const target = event.target as Node
const insideSwatch = swatchRef.current?.contains(target)
const insidePopover = popoverRef.current?.contains(target)
if (!insideSwatch && !insidePopover) setOpen(false)
}
document.addEventListener('pointerdown', handlePointerDown, true)
return () => document.removeEventListener('pointerdown', handlePointerDown, true)
}, [open])
return (
<div className="gromlab-sprite-viewer__color-row">
<button
ref={swatchRef}
className="gromlab-sprite-viewer__swatch"
type="button"
style={{ backgroundColor: value }}
aria-label={`Изменить цвет ${label}`}
aria-expanded={open}
aria-controls={open ? popoverId : undefined}
title={`Изменить цвет ${label}`}
onClick={() => setOpen((current) => !current)}
/>
<code className="gromlab-sprite-viewer__color-label">{label}</code>
{open && (
<div
ref={popoverRef}
id={popoverId}
className="gromlab-sprite-viewer__color-popover"
role="dialog"
aria-label={`Выбор цвета ${label}`}
onKeyDown={(event) => {
if (event.key !== 'Escape') return
event.preventDefault()
event.stopPropagation()
setOpen(false)
}}
>
<HexColorPicker color={value} onChange={onChange} />
<HexColorInput
className="gromlab-sprite-viewer__hex-input"
color={value}
onChange={onChange}
prefixed
aria-label={`HEX-значение ${label}`}
/>
</div>
)}
</div>
)
}
function initialColors(icon: SpriteManifestIcon, currentColor: string) {
return Object.fromEntries(icon.colors.map(({ variable, fallback }) => [
variable,
normalizeHexColor(fallback, currentColor),
]))
}
export function SpriteViewerDialog({ manifest, icon, colorTheme, onClose }: SpriteViewerDialogProps) {
const dialogRef = useRef<HTMLDialogElement>(null)
const titleId = useId()
const tabsId = useId()
const tabs = tabsForFormat(manifest.format)
const [activeTab, setActiveTab] = useState<SpriteViewerTab>('react')
const themeColor = colorTheme === 'dark' ? '#e5e5e5' : '#1a1a1a'
const [colors, setColors] = useState<Record<string, string>>(() => initialColors(icon, themeColor))
const [colorOverrides, setColorOverrides] = useState<Record<string, string>>({})
const [cssColor, setCssColor] = useState(themeColor)
const [cssColorOverridden, setCssColorOverridden] = useState(false)
const [copied, setCopied] = useState(false)
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
dialog.showModal()
return () => {
if (dialog.open) dialog.close()
}
}, [icon])
useEffect(() => {
setColors({ ...initialColors(icon, themeColor), ...colorOverrides })
if (!cssColorOverridden) setCssColor(themeColor)
}, [colorTheme, icon])
useEffect(() => {
if (!copied) return
const timeout = setTimeout(() => setCopied(false), 1500)
return () => clearTimeout(timeout)
}, [copied])
const previewStyle = Object.fromEntries(
Object.entries(colors).map(([variable, color]) => [variable, color]),
) as CSSProperties
const href = `${manifest.spriteUrl}#${icon.id}`
const dimensions = viewBoxSize(icon.viewBox)
const code = generateViewerCode({ manifest, icon, tab: activeTab, colorOverrides, cssColor })
function handleColorChange(variable: string, color: string) {
const normalized = normalizeHexColor(color)
setColors((current) => ({ ...current, [variable]: normalized }))
setColorOverrides((current) => ({ ...current, [variable]: normalized }))
}
function handleCssColorChange(color: string) {
setCssColor(normalizeHexColor(color))
setCssColorOverridden(true)
}
function handleBackdropClick(event: React.MouseEvent<HTMLDialogElement>) {
if (event.target !== event.currentTarget) return
const bounds = event.currentTarget.getBoundingClientRect()
const outside = event.clientX < bounds.left
|| event.clientX > bounds.right
|| event.clientY < bounds.top
|| event.clientY > bounds.bottom
if (outside) onClose()
}
function handleTabKeyDown(event: React.KeyboardEvent<HTMLButtonElement>, tabIndex: number) {
let nextIndex: number | null = null
if (event.key === 'ArrowRight') nextIndex = (tabIndex + 1) % tabs.length
if (event.key === 'ArrowLeft') nextIndex = (tabIndex - 1 + tabs.length) % tabs.length
if (event.key === 'Home') nextIndex = 0
if (event.key === 'End') nextIndex = tabs.length - 1
if (nextIndex === null) return
event.preventDefault()
setActiveTab(tabs[nextIndex].id)
const buttons = event.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
buttons?.[nextIndex]?.focus()
}
async function copyCode() {
if (!globalThis.navigator?.clipboard) return
try {
await globalThis.navigator.clipboard.writeText(code.code)
setCopied(true)
} catch {
setCopied(false)
}
}
function renderPreview() {
if (activeTab === 'img') {
return <img className="gromlab-sprite-viewer__dialog-img" src={href} alt={icon.name} />
}
if (activeTab === 'css') {
return (
<div
className="gromlab-sprite-viewer__dialog-mask"
role="img"
aria-label={icon.name}
style={{
backgroundColor: cssColor,
mask: `url('${href}') no-repeat center / contain`,
WebkitMask: `url('${href}') no-repeat center / contain`,
}}
/>
)
}
return (
<svg
className="gromlab-sprite-viewer__dialog-icon"
viewBox={icon.viewBox ?? undefined}
aria-label={icon.name}
role="img"
>
<use href={href} />
</svg>
)
}
return (
<dialog
ref={dialogRef}
className="gromlab-sprite-viewer__dialog"
aria-labelledby={titleId}
onCancel={(event) => {
event.preventDefault()
onClose()
}}
onClick={handleBackdropClick}
>
<div className="gromlab-sprite-viewer__dialog-shell">
<button
className="gromlab-sprite-viewer__close"
type="button"
aria-label="Закрыть"
autoFocus
onClick={onClose}
>
&#x2715;
</button>
<div className="gromlab-sprite-viewer__dialog-preview" style={previewStyle}>
<div className="gromlab-sprite-viewer__dialog-preview-canvas">
{renderPreview()}
</div>
</div>
<div className="gromlab-sprite-viewer__dialog-heading">
<h2 id={titleId} className="gromlab-sprite-viewer__dialog-title">{icon.name}</h2>
{dimensions && <span className="gromlab-sprite-viewer__viewbox">{dimensions}</span>}
</div>
<p className="gromlab-sprite-viewer__dialog-meta">
{manifest.name} · {manifest.format} · {manifest.target}
</p>
<div className="gromlab-sprite-viewer__colors">
{(activeTab === 'react' || activeTab === 'svg') && icon.colors.length > 0 && (
<>
<p className="gromlab-sprite-viewer__hint">
Цвета применяются к превью через CSS-переменные и попадут в пример кода.
</p>
<h3 className="gromlab-sprite-viewer__colors-title">CSS Variables</h3>
{icon.colors.map(({ variable, fallback }) => (
<ColorControl
key={variable}
value={colors[variable]}
label={`${variable}: ${fallback}`}
onChange={(color) => handleColorChange(variable, color)}
/>
))}
</>
)}
{(activeTab === 'react' || activeTab === 'svg') && icon.colors.length === 0 && (
<p className="gromlab-sprite-viewer__hint">У иконки нет настраиваемых цветовых переменных.</p>
)}
{activeTab === 'img' && (
<p className="gromlab-sprite-viewer__hint">
IMG изолирует SVG: CSS-переменные и currentColor внутрь изображения не передаются.
</p>
)}
{activeTab === 'css' && (
<>
<p className="gromlab-sprite-viewer__hint">
CSS mask отображает иконку одним цветом через background-color.
</p>
<ColorControl label="background-color" value={cssColor} onChange={handleCssColorChange} />
</>
)}
</div>
<div className="gromlab-sprite-viewer__tabs" role="tablist" aria-label="Способ подключения">
{tabs.map((tab, tabIndex) => (
<button
id={`${tabsId}-${tab.id}-tab`}
key={tab.id}
className="gromlab-sprite-viewer__tab"
type="button"
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={`${tabsId}-panel`}
tabIndex={activeTab === tab.id ? 0 : -1}
onClick={() => setActiveTab(tab.id)}
onKeyDown={(event) => handleTabKeyDown(event, tabIndex)}
>
{tab.label}
</button>
))}
</div>
<div
id={`${tabsId}-panel`}
className="gromlab-sprite-viewer__code"
role="tabpanel"
aria-labelledby={`${tabsId}-${activeTab}-tab`}
>
<pre><code dangerouslySetInnerHTML={{ __html: highlightViewerCode(code.code, code.language) }} /></pre>
<button className="gromlab-sprite-viewer__copy" type="button" onClick={() => void copyCode()}>
{copied ? 'Скопировано' : 'Копировать'}
</button>
</div>
</div>
</dialog>
)
}

View File

@@ -1,19 +1,10 @@
'use client'
import { useDeferredValue, useEffect, useState } from 'react'
import { createElement, useEffect, useRef } from 'react'
import type { CSSProperties, ReactElement } from 'react'
import { SpriteViewerDialog } from './sprite-viewer-dialog.js'
import { SPRITE_VIEWER_STYLES } from './sprite-viewer-styles.js'
import type {
SpriteManifest,
SpriteManifestIcon,
SpriteManifestLoader,
SpriteManifestModule,
SpriteViewerSource,
SpriteViewerSources,
} from './types.js'
import type { SpriteViewerColorTheme, SpriteViewerSources } from '../viewer/types.js'
export type SpriteViewerColorTheme = 'auto' | 'light' | 'dark'
export type { SpriteViewerColorTheme } from '../viewer/types.js'
export type SpriteViewerProps = {
sources: SpriteViewerSources
@@ -26,96 +17,17 @@ export type SpriteViewerProps = {
style?: CSSProperties
}
type SelectedIcon = {
key: string
manifest: SpriteManifest
icon: SpriteManifestIcon
type ViewerElement = HTMLElement & {
sources: SpriteViewerSources
viewerTitle: string
colorTheme: SpriteViewerColorTheme
themeControlled: boolean
showThemeToggle: boolean
}
const manifestLoaderCache = new WeakMap<SpriteManifestLoader, Promise<SpriteManifest>>()
type ThemeChangeEvent = CustomEvent<{ theme: SpriteViewerColorTheme }>
function compareManifests(left: SpriteManifest, right: SpriteManifest): number {
return left.name < right.name ? -1 : left.name > right.name ? 1 : 0
}
function sourceArray(sources: SpriteViewerSources): readonly SpriteViewerSource[] {
return Array.isArray(sources)
? sources
: Object.values(sources as Readonly<Record<string, SpriteViewerSource>>)
}
function isSpriteManifest(value: unknown): value is SpriteManifest {
if (!value || typeof value !== 'object') return false
const manifest = value as Partial<SpriteManifest>
return manifest.schemaVersion === 1
&& manifest.generator === '@gromlab/svg-sprites'
&& typeof manifest.name === 'string'
&& typeof manifest.componentName === 'string'
&& typeof manifest.spriteUrl === 'string'
&& Array.isArray(manifest.icons)
}
function manifestFromModule(value: SpriteManifest | SpriteManifestModule): SpriteManifest {
if (isSpriteManifest(value)) return value
if (isSpriteManifest(value.default)) return value.default
if (isSpriteManifest(value.spriteManifest)) return value.spriteManifest
throw new Error('The loaded module does not export a valid SVG sprite manifest.')
}
async function resolveSource(source: SpriteViewerSource): Promise<SpriteManifest> {
if (typeof source !== 'function') return source
const cached = manifestLoaderCache.get(source)
if (cached) return cached
const pending = Promise.resolve().then(source).then(manifestFromModule)
manifestLoaderCache.set(source, pending)
void pending.catch(() => {
if (manifestLoaderCache.get(source) === pending) manifestLoaderCache.delete(source)
})
return pending
}
function directManifests(sources: SpriteViewerSources): SpriteManifest[] {
return sourceArray(sources)
.filter((source): source is SpriteManifest => typeof source !== 'function')
.sort(compareManifests)
}
function countLabel(value: number, forms: readonly [string, string, string]): string {
const modulo100 = value % 100
const modulo10 = value % 10
const form = modulo100 >= 11 && modulo100 <= 19
? forms[2]
: modulo10 === 1
? forms[0]
: modulo10 >= 2 && modulo10 <= 4
? forms[1]
: forms[2]
return `${value} ${form}`
}
function currentSystemTheme(): 'light' | 'dark' {
return globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function useSystemTheme(): 'light' | 'dark' {
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(currentSystemTheme)
useEffect(() => {
const media = globalThis.matchMedia?.('(prefers-color-scheme: dark)')
if (!media) return
const update = () => setSystemTheme(media.matches ? 'dark' : 'light')
update()
media.addEventListener('change', update)
return () => media.removeEventListener('change', update)
}, [])
return systemTheme
}
/** Интерактивный каталог локальных React- и Next.js-спрайтов. */
/** React bridge к единому framework-neutral Viewer Web Component. */
export function SpriteViewer({
sources,
title = 'SVG Sprites',
@@ -124,178 +36,42 @@ export function SpriteViewer({
className,
style,
}: SpriteViewerProps): ReactElement {
const [manifests, setManifests] = useState<SpriteManifest[]>(() => directManifests(sources))
const [errors, setErrors] = useState<string[]>([])
const [loading, setLoading] = useState(() => sourceArray(sources).some((source) => typeof source === 'function'))
const [query, setQuery] = useState('')
const [localColorTheme, setLocalColorTheme] = useState<SpriteViewerColorTheme>('auto')
const [selected, setSelected] = useState<SelectedIcon | null>(null)
const systemTheme = useSystemTheme()
const activeColorTheme = colorTheme ?? localColorTheme
const resolvedColorTheme = activeColorTheme === 'auto' ? systemTheme : activeColorTheme
const canToggleTheme = colorTheme === undefined || onColorThemeChange !== undefined
const deferredQuery = useDeferredValue(query.trim().toLowerCase())
const elementRef = useRef<ViewerElement | null>(null)
useEffect(() => {
let active = true
const allSources = sourceArray(sources)
const direct = directManifests(sources)
const loaders = allSources.filter((source) => typeof source === 'function')
let element: ViewerElement | null = null
setManifests(direct)
setErrors([])
setLoading(loaders.length > 0)
setSelected(null)
const handleThemeChange = (event: Event) => {
onColorThemeChange?.((event as ThemeChangeEvent).detail.theme)
}
if (loaders.length === 0) return () => { active = false }
void import('../viewer-element.js').then(async ({ defineSpriteViewerElement }) => {
defineSpriteViewerElement()
await customElements.whenDefined('gromlab-sprite-viewer')
if (!active || !elementRef.current) return
Promise.allSettled(loaders.map(resolveSource)).then((results) => {
if (!active) return
const loaded: SpriteManifest[] = []
const failures: string[] = []
for (const result of results) {
if (result.status === 'fulfilled') loaded.push(result.value)
else failures.push(result.reason instanceof Error ? result.reason.message : String(result.reason))
}
setManifests([...direct, ...loaded].sort(compareManifests))
setErrors(failures)
setLoading(false)
element = elementRef.current
const wasThemeControlled = element.themeControlled
element.sources = sources
element.viewerTitle = title
if (colorTheme !== undefined) element.colorTheme = colorTheme
else if (wasThemeControlled) element.colorTheme = 'auto'
element.themeControlled = colorTheme !== undefined
element.showThemeToggle = colorTheme === undefined || onColorThemeChange !== undefined
element.addEventListener('color-theme-change', handleThemeChange)
})
return () => { active = false }
}, [sources])
return () => {
active = false
element?.removeEventListener('color-theme-change', handleThemeChange)
}
}, [colorTheme, onColorThemeChange, sources, title])
const visibleGroups = manifests
.map((manifest) => ({
manifest,
icons: manifest.icons.filter((icon) => (
deferredQuery === ''
|| icon.name.toLowerCase().includes(deferredQuery)
|| manifest.name.toLowerCase().includes(deferredQuery)
)),
}))
.filter((group) => group.icons.length > 0)
const totalIcons = manifests.reduce((total, manifest) => total + manifest.iconCount, 0)
const visibleIcons = visibleGroups.reduce((total, group) => total + group.icons.length, 0)
const rootClassName = ['gromlab-sprite-viewer', className].filter(Boolean).join(' ')
function toggleTheme() {
const nextTheme = resolvedColorTheme === 'dark' ? 'light' : 'dark'
if (colorTheme === undefined) setLocalColorTheme(nextTheme)
onColorThemeChange?.(nextTheme)
}
return (
<section
className={rootClassName}
style={style}
data-sprite-viewer=""
data-theme={activeColorTheme === 'auto' ? undefined : activeColorTheme}
>
<style>{SPRITE_VIEWER_STYLES}</style>
<header className="gromlab-sprite-viewer__header">
<h1 className="gromlab-sprite-viewer__title">{title}</h1>
<span className="gromlab-sprite-viewer__summary">
{countLabel(manifests.length, ['спрайт', 'спрайта', 'спрайтов'])}
{' · '}
{countLabel(totalIcons, ['иконка', 'иконки', 'иконок'])}
{deferredQuery && ` · найдено ${visibleIcons}`}
</span>
<div className="gromlab-sprite-viewer__toolbar">
<input
className="gromlab-sprite-viewer__search"
type="search"
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
placeholder="Найти иконку"
aria-label="Поиск иконок"
/>
{canToggleTheme && (
<button
className="gromlab-sprite-viewer__theme"
type="button"
aria-label="Переключить тему"
title="Переключить тему"
onClick={toggleTheme}
>
&#x25D1;
</button>
)}
</div>
</header>
{errors.length > 0 && (
<div className="gromlab-sprite-viewer__errors" role="alert">
{errors.map((error, index) => <div key={`${index}:${error}`}>{error}</div>)}
</div>
)}
{visibleGroups.map(({ manifest, icons }) => (
<section className="gromlab-sprite-viewer__group" key={`${manifest.name}:${manifest.spriteUrl}`}>
<div className="gromlab-sprite-viewer__group-header">
<h2 className="gromlab-sprite-viewer__group-title">{manifest.name}</h2>
<span className="gromlab-sprite-viewer__badge">{manifest.format}</span>
<span className="gromlab-sprite-viewer__group-count">{icons.length}</span>
{manifest.description && (
<p className="gromlab-sprite-viewer__description">{manifest.description}</p>
)}
</div>
<div className="gromlab-sprite-viewer__grid">
{icons.map((icon) => {
const key = `${manifest.name}:${manifest.spriteUrl}:${icon.id}`
return (
<button
className="gromlab-sprite-viewer__card"
type="button"
key={key}
data-icon-name={icon.name}
onClick={() => setSelected({ key, manifest, icon })}
title={`Открыть ${icon.name}`}
>
<span className="gromlab-sprite-viewer__icon-wrap">
<svg
className="gromlab-sprite-viewer__icon"
viewBox={icon.viewBox ?? undefined}
aria-hidden="true"
>
<use href={`${manifest.spriteUrl}#${icon.id}`} />
</svg>
</span>
<span className="gromlab-sprite-viewer__icon-name">{icon.name}</span>
</button>
)
})}
</div>
</section>
))}
{visibleGroups.length === 0 && (!loading || manifests.length > 0) && (
<div className="gromlab-sprite-viewer__status">
{manifests.length === 0 ? 'Спрайты не подключены' : 'Иконки не найдены'}
</div>
)}
{loading && manifests.length === 0 && (
<div className="gromlab-sprite-viewer__status">Загрузка спрайтов...</div>
)}
<footer className="gromlab-sprite-viewer__footer">
<span>@gromlab/svg-sprites</span>
<a href="https://github.com/gromlab-ru/svg-sprites" target="_blank" rel="noreferrer">Repository</a>
</footer>
{selected && (
<SpriteViewerDialog
key={selected.key}
manifest={selected.manifest}
icon={selected.icon}
colorTheme={resolvedColorTheme}
onClose={() => setSelected(null)}
/>
)}
</section>
)
return createElement('gromlab-sprite-viewer', {
ref: elementRef,
className,
style,
'data-sprite-viewer-host': '',
})
}

View File

@@ -1,41 +1,24 @@
import type { NextAssetTarget, ReactAssetTarget, ReactSpriteMode } from '../targets/types.js'
import type {
SpriteViewerManifest,
SpriteViewerManifestColor,
SpriteViewerManifestIcon,
SpriteViewerManifestLoader,
SpriteViewerManifestModule,
SpriteViewerSource,
SpriteViewerSources,
} from '../viewer/types.js'
export type SpriteManifestColor = {
variable: `--icon-color-${number}`
fallback: string
}
export type SpriteManifestColor = SpriteViewerManifestColor
export type SpriteManifestIcon = SpriteViewerManifestIcon
export type SpriteManifestIcon = {
name: string
id: string
viewBox: string | null
colors: readonly SpriteManifestColor[]
}
/** Публичные данные одного сгенерированного React-спрайта. */
export type SpriteManifest = {
schemaVersion: 1
generator: '@gromlab/svg-sprites'
name: string
description?: string
/** Публичные данные одного сгенерированного React- или Next.js-спрайта. */
export type SpriteManifest = SpriteViewerManifest & {
componentName: string
mode?: ReactSpriteMode | NextAssetTarget
target: ReactAssetTarget | NextAssetTarget
format: 'stack' | 'symbol'
iconCount: number
spriteUrl: string
icons: readonly SpriteManifestIcon[]
}
export type SpriteManifestModule = {
default?: SpriteManifest
spriteManifest?: SpriteManifest
}
export type SpriteManifestLoader = () => Promise<SpriteManifest | SpriteManifestModule>
export type SpriteViewerSource = SpriteManifest | SpriteManifestLoader
/** Массив источников либо результат import.meta.glob. */
export type SpriteViewerSources =
| readonly SpriteViewerSource[]
| Readonly<Record<string, SpriteViewerSource>>
export type SpriteManifestModule = SpriteViewerManifestModule
export type SpriteManifestLoader = SpriteViewerManifestLoader
export type { SpriteViewerSource, SpriteViewerSources }

18
src/viewer-element.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineSpriteViewerElement } from './viewer/element.js'
defineSpriteViewerElement()
export { defineSpriteViewerElement } from './viewer/element.js'
export type {
SpriteViewerColorTheme,
SpriteViewerElement,
SpriteViewerManifest,
SpriteViewerManifestColor,
SpriteViewerManifestIcon,
SpriteViewerManifestLoader,
SpriteViewerManifestModule,
SpriteViewerManifestUsage,
SpriteViewerRemoteSource,
SpriteViewerSource,
SpriteViewerSources,
} from './viewer/types.js'

14
src/viewer.ts Normal file
View File

@@ -0,0 +1,14 @@
export { defineSpriteViewerElement } from './viewer/element.js'
export type {
SpriteViewerColorTheme,
SpriteViewerElement,
SpriteViewerManifest,
SpriteViewerManifestColor,
SpriteViewerManifestIcon,
SpriteViewerManifestLoader,
SpriteViewerManifestModule,
SpriteViewerManifestUsage,
SpriteViewerRemoteSource,
SpriteViewerSource,
SpriteViewerSources,
} from './viewer/types.js'

View File

@@ -1,19 +1,29 @@
import type { SpriteManifest, SpriteManifestIcon } from './types.js'
import type { SpriteViewerManifest, SpriteViewerManifestIcon } from './types.js'
export type SpriteViewerTab = 'react' | 'svg' | 'img' | 'css'
export type SpriteViewerTab = 'react' | 'vue' | 'svg' | 'img' | 'css'
export type SpriteViewerCodeLanguage = 'tsx' | 'html' | 'css'
export const SPRITE_VIEWER_TABS: ReadonlyArray<{ id: SpriteViewerTab; label: string }> = [
{ id: 'react', label: 'React' },
const COMMON_TABS: ReadonlyArray<{ id: SpriteViewerTab; label: string }> = [
{ id: 'svg', label: 'SVG' },
{ id: 'img', label: 'IMG' },
{ id: 'css', label: 'CSS' },
]
export function tabsForFormat(format: SpriteManifest['format']) {
return format === 'stack'
? SPRITE_VIEWER_TABS
: SPRITE_VIEWER_TABS.filter((tab) => tab.id === 'react' || tab.id === 'svg')
function usageForManifest(manifest: SpriteViewerManifest): { framework: 'react' | 'vue'; componentName: string } | null {
if (manifest.usage) return manifest.usage
if (manifest.componentName) return { framework: 'react', componentName: manifest.componentName }
return null
}
export function tabsForManifest(manifest: SpriteViewerManifest): ReadonlyArray<{ id: SpriteViewerTab; label: string }> {
const usage = usageForManifest(manifest)
const frameworkTab = usage
? [{ id: usage.framework, label: usage.framework === 'react' ? 'React' : 'Vue' } as const]
: []
const tabs = [...frameworkTab, ...COMMON_TABS]
return manifest.format === 'stack'
? tabs
: tabs.filter((tab) => tab.id === 'react' || tab.id === 'vue' || tab.id === 'svg')
}
export function viewBoxSize(viewBox: string | null): string | null {
@@ -76,8 +86,8 @@ function cssUrl(value: string): string {
}
export function generateViewerCode(options: {
manifest: SpriteManifest
icon: SpriteManifestIcon
manifest: SpriteViewerManifest
icon: SpriteViewerManifestIcon
tab: SpriteViewerTab
colorOverrides: Readonly<Record<string, string>>
cssColor: string
@@ -86,16 +96,29 @@ export function generateViewerCode(options: {
const href = `${manifest.spriteUrl}#${icon.id}`
const overrides = styleLines(colorOverrides)
if (tab === 'react') {
if (tab === 'react' || tab === 'vue') {
const usage = usageForManifest(manifest)
if (!usage) throw new Error(`The ${tab} code tab requires component metadata.`)
if (tab === 'vue') {
const style = Object.entries(colorOverrides)
.map(([variable, color]) => `${JSON.stringify(variable)}: ${JSON.stringify(color)}`)
.join(', ')
return {
code: `<${usage.componentName} icon=${JSON.stringify(icon.name)}${style ? ` :style="{ ${htmlAttribute(style)} }"` : ''} />`,
language: 'html',
}
}
if (overrides.length === 0) {
return {
code: `<${manifest.componentName} icon=${JSON.stringify(icon.name)} />`,
code: `<${usage.componentName} icon=${JSON.stringify(icon.name)} />`,
language: 'tsx',
}
}
return {
code: [
`<${manifest.componentName}`,
`<${usage.componentName}`,
` icon=${JSON.stringify(icon.name)}`,
' style={{',
...overrides,

650
src/viewer/element.ts Normal file
View File

@@ -0,0 +1,650 @@
import { LitElement, html, nothing, unsafeCSS } from 'lit'
import type { PropertyValues, TemplateResult } from 'lit'
import { ifDefined } from 'lit/directives/if-defined.js'
import { styleMap } from 'lit/directives/style-map.js'
import { unsafeHTML } from 'lit/directives/unsafe-html.js'
import {
generateViewerCode,
highlightViewerCode,
normalizeHexColor,
tabsForManifest,
viewBoxSize,
} from './code.js'
import type { SpriteViewerTab } from './code.js'
import {
compareManifests,
isRemoteSource,
normalizeSpriteViewerManifest,
resolveViewerSource,
sourceArray,
} from './source.js'
import { SPRITE_VIEWER_STYLES } from './styles.js'
import type {
SpriteViewerColorTheme,
SpriteViewerManifest,
SpriteViewerManifestIcon,
SpriteViewerSource,
SpriteViewerSources,
} from './types.js'
const ELEMENT_NAME = 'gromlab-sprite-viewer'
const COLOR_PICKER_NAME = 'gromlab-hex-color-picker'
const COLOR_INPUT_NAME = 'gromlab-hex-input'
let colorElementsPromise: Promise<void> | undefined
function defineColorElements(): Promise<void> {
if (customElements.get(COLOR_PICKER_NAME) && customElements.get(COLOR_INPUT_NAME)) return Promise.resolve()
if (colorElementsPromise) return colorElementsPromise
colorElementsPromise = Promise.all([
import('vanilla-colorful/lib/entrypoints/hex'),
import('vanilla-colorful/lib/entrypoints/hex-input'),
]).then(([{ HexBase }, { HexInputBase }]) => {
if (!customElements.get(COLOR_PICKER_NAME)) {
customElements.define(COLOR_PICKER_NAME, class extends HexBase {})
}
if (!customElements.get(COLOR_INPUT_NAME)) {
customElements.define(COLOR_INPUT_NAME, class extends HexInputBase {})
}
})
return colorElementsPromise
}
function countLabel(value: number, forms: readonly [string, string, string]): string {
const modulo100 = value % 100
const modulo10 = value % 10
const form = modulo100 >= 11 && modulo100 <= 19
? forms[2]
: modulo10 === 1
? forms[0]
: modulo10 >= 2 && modulo10 <= 4
? forms[1]
: forms[2]
return `${value} ${form}`
}
function currentSystemTheme(): 'light' | 'dark' {
return globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function initialColors(icon: SpriteViewerManifestIcon, currentColor: string): Record<string, string> {
return Object.fromEntries(icon.colors.map(({ variable, fallback }) => [
variable,
normalizeHexColor(fallback, currentColor),
]))
}
type SelectedIcon = {
key: string
manifest: SpriteViewerManifest
icon: SpriteViewerManifestIcon
}
type ColorChangedEvent = CustomEvent<{ value: string }>
class GromlabSpriteViewerElement extends LitElement {
static properties = {
sources: { attribute: false },
viewerTitle: { attribute: 'viewer-title' },
colorTheme: { attribute: 'color-theme' },
themeControlled: { attribute: false },
showThemeToggle: { attribute: false },
manifestUrl: { attribute: 'manifest-url' },
spriteUrl: { attribute: 'sprite-url' },
_manifests: { state: true },
_errors: { state: true },
_loading: { state: true },
_query: { state: true },
_systemTheme: { state: true },
_selected: { state: true },
_activeTab: { state: true },
_colors: { state: true },
_colorOverrides: { state: true },
_cssColor: { state: true },
_cssColorOverridden: { state: true },
_openColorControl: { state: true },
_copied: { state: true },
}
static styles = unsafeCSS(SPRITE_VIEWER_STYLES)
declare sources: SpriteViewerSources
declare viewerTitle: string
declare colorTheme: SpriteViewerColorTheme
declare themeControlled: boolean
declare showThemeToggle: boolean
declare manifestUrl?: string
declare spriteUrl?: string
declare private _manifests: SpriteViewerManifest[]
declare private _errors: string[]
declare private _loading: boolean
declare private _query: string
declare private _systemTheme: 'light' | 'dark'
declare private _selected: SelectedIcon | null
declare private _activeTab: SpriteViewerTab
declare private _colors: Record<string, string>
declare private _colorOverrides: Record<string, string>
declare private _cssColor: string
declare private _cssColorOverridden: boolean
declare private _openColorControl: string | null
declare private _copied: boolean
private _loadVersion = 0
private _copyTimeout?: ReturnType<typeof setTimeout>
private _media?: MediaQueryList
constructor() {
super()
this.sources = []
this.viewerTitle = 'SVG Sprites'
this.colorTheme = 'auto'
this.themeControlled = false
this.showThemeToggle = true
this._manifests = []
this._errors = []
this._loading = false
this._query = ''
this._systemTheme = currentSystemTheme()
this._selected = null
this._activeTab = 'svg'
this._colors = {}
this._colorOverrides = {}
this._cssColor = '#1a1a1a'
this._cssColorOverridden = false
this._openColorControl = null
this._copied = false
}
private readonly _handleMediaChange = () => {
this._systemTheme = this._media?.matches ? 'dark' : 'light'
this._syncThemeColors()
}
private readonly _handleDocumentPointerDown = (event: PointerEvent) => {
if (!this._openColorControl) return
const insideCurrentControl = event.composedPath().some((node) => (
node instanceof HTMLElement
&& (node.dataset.colorTrigger === this._openColorControl
|| node.dataset.colorPopover === this._openColorControl)
))
if (!insideCurrentControl) this._openColorControl = null
}
connectedCallback(): void {
super.connectedCallback()
void defineColorElements()
this._media = globalThis.matchMedia?.('(prefers-color-scheme: dark)')
this._systemTheme = this._media?.matches ? 'dark' : 'light'
this._media?.addEventListener('change', this._handleMediaChange)
globalThis.document?.addEventListener('pointerdown', this._handleDocumentPointerDown, true)
}
disconnectedCallback(): void {
this._media?.removeEventListener('change', this._handleMediaChange)
globalThis.document?.removeEventListener('pointerdown', this._handleDocumentPointerDown, true)
if (this._copyTimeout) clearTimeout(this._copyTimeout)
super.disconnectedCallback()
}
protected updated(changed: PropertyValues<this>): void {
if (changed.has('sources') || changed.has('manifestUrl') || changed.has('spriteUrl')) {
void this._loadSources()
}
if (changed.has('colorTheme')) this._syncThemeColors()
}
private get _resolvedColorTheme(): 'light' | 'dark' {
return this.colorTheme === 'auto' ? this._systemTheme : this.colorTheme
}
private get _themeColor(): string {
return this._resolvedColorTheme === 'dark' ? '#e5e5e5' : '#1a1a1a'
}
private _effectiveSources(): readonly SpriteViewerSource[] {
const configured = sourceArray(this.sources)
if (configured.length > 0) return configured
if (this.manifestUrl && this.spriteUrl) {
return [{ manifestUrl: this.manifestUrl, spriteUrl: this.spriteUrl }]
}
return []
}
private async _loadSources(): Promise<void> {
const version = ++this._loadVersion
const sources = this._effectiveSources()
const direct: SpriteViewerManifest[] = []
const asynchronous: SpriteViewerSource[] = []
const errors: string[] = []
if (this.manifestUrl && !this.spriteUrl && sourceArray(this.sources).length === 0) {
errors.push('The sprite-url attribute is required with manifest-url.')
}
for (const source of sources) {
if (typeof source === 'function' || isRemoteSource(source)) {
asynchronous.push(source)
continue
}
try {
direct.push(normalizeSpriteViewerManifest(source))
} catch (error) {
errors.push(error instanceof Error ? error.message : String(error))
}
}
this._manifests = direct.sort(compareManifests)
this._errors = errors
this._loading = asynchronous.length > 0
this._selected = null
if (asynchronous.length === 0) return
const results = await Promise.allSettled(asynchronous.map(resolveViewerSource))
if (version !== this._loadVersion) return
const loaded: SpriteViewerManifest[] = []
for (const result of results) {
if (result.status === 'fulfilled') loaded.push(result.value)
else errors.push(result.reason instanceof Error ? result.reason.message : String(result.reason))
}
this._manifests = [...direct, ...loaded].sort(compareManifests)
this._errors = [...errors]
this._loading = false
}
private _syncThemeColors(): void {
if (!this._selected) return
this._colors = {
...initialColors(this._selected.icon, this._themeColor),
...this._colorOverrides,
}
if (!this._cssColorOverridden) this._cssColor = this._themeColor
}
private _toggleTheme(): void {
const theme: Exclude<SpriteViewerColorTheme, 'auto'> = this._resolvedColorTheme === 'dark' ? 'light' : 'dark'
if (!this.themeControlled) this.colorTheme = theme
this.dispatchEvent(new CustomEvent('color-theme-change', {
bubbles: true,
composed: true,
detail: { theme },
}))
}
private async _selectIcon(manifest: SpriteViewerManifest, icon: SpriteViewerManifestIcon): Promise<void> {
const key = `${manifest.name}:${manifest.spriteUrl}:${icon.id}`
this._selected = { key, manifest, icon }
this._activeTab = tabsForManifest(manifest)[0]?.id ?? 'svg'
this._colorOverrides = {}
this._colors = initialColors(icon, this._themeColor)
this._cssColor = this._themeColor
this._cssColorOverridden = false
this._openColorControl = null
this._copied = false
await this.updateComplete
const dialog = this.renderRoot.querySelector<HTMLDialogElement>('dialog')
if (dialog && !dialog.open) dialog.showModal()
}
private _closeDialog(): void {
const dialog = this.renderRoot.querySelector<HTMLDialogElement>('dialog')
if (dialog?.open) dialog.close()
this._selected = null
this._openColorControl = null
}
private _handleBackdropClick(event: MouseEvent): void {
if (!(event.currentTarget instanceof HTMLDialogElement) || event.target !== event.currentTarget) return
const bounds = event.currentTarget.getBoundingClientRect()
const outside = event.clientX < bounds.left
|| event.clientX > bounds.right
|| event.clientY < bounds.top
|| event.clientY > bounds.bottom
if (outside) this._closeDialog()
}
private _handleTabKeyDown(event: KeyboardEvent, tabIndex: number): void {
if (!this._selected) return
const tabs = tabsForManifest(this._selected.manifest)
let nextIndex: number | null = null
if (event.key === 'ArrowRight') nextIndex = (tabIndex + 1) % tabs.length
if (event.key === 'ArrowLeft') nextIndex = (tabIndex - 1 + tabs.length) % tabs.length
if (event.key === 'Home') nextIndex = 0
if (event.key === 'End') nextIndex = tabs.length - 1
if (nextIndex === null) return
event.preventDefault()
this._activeTab = tabs[nextIndex].id
const buttons = this.renderRoot.querySelectorAll<HTMLButtonElement>('[role="tab"]')
void this.updateComplete.then(() => buttons[nextIndex]?.focus())
}
private _handleColorChange(variable: string, color: string): void {
const normalized = normalizeHexColor(color)
this._colors = { ...this._colors, [variable]: normalized }
this._colorOverrides = { ...this._colorOverrides, [variable]: normalized }
}
private _handleCssColorChange(color: string): void {
this._cssColor = normalizeHexColor(color)
this._cssColorOverridden = true
}
private async _copyCode(code: string): Promise<void> {
if (!globalThis.navigator?.clipboard) return
try {
await globalThis.navigator.clipboard.writeText(code)
this._copied = true
if (this._copyTimeout) clearTimeout(this._copyTimeout)
this._copyTimeout = setTimeout(() => { this._copied = false }, 1500)
} catch {
this._copied = false
}
}
private _renderColorControl(label: string, value: string, key: string, onChange: (value: string) => void): TemplateResult {
const open = this._openColorControl === key
const popoverId = `color-${key.replace(/[^a-zA-Z0-9_-]/g, '-')}`
return html`
<div class="gromlab-sprite-viewer__color-row">
<button
class="gromlab-sprite-viewer__swatch"
type="button"
data-color-trigger=${key}
style=${styleMap({ backgroundColor: value })}
aria-label=${`Изменить цвет ${label}`}
aria-expanded=${String(open)}
aria-controls=${ifDefined(open ? popoverId : undefined)}
title=${`Изменить цвет ${label}`}
@click=${() => { this._openColorControl = open ? null : key }}
></button>
<code class="gromlab-sprite-viewer__color-label">${label}</code>
${open ? html`
<div
id=${popoverId}
class="gromlab-sprite-viewer__color-popover"
data-color-popover=${key}
role="dialog"
aria-label=${`Выбор цвета ${label}`}
@keydown=${(event: KeyboardEvent) => {
if (event.key !== 'Escape') return
event.preventDefault()
event.stopPropagation()
this._openColorControl = null
}}
>
<gromlab-hex-color-picker
class="gromlab-sprite-viewer__color-picker"
.color=${value}
@color-changed=${(event: ColorChangedEvent) => onChange(event.detail.value)}
></gromlab-hex-color-picker>
<gromlab-hex-input
class="gromlab-sprite-viewer__hex-input"
.color=${value}
.prefixed=${true}
aria-label=${`HEX-значение ${label}`}
@color-changed=${(event: ColorChangedEvent) => onChange(event.detail.value)}
></gromlab-hex-input>
</div>
` : nothing}
</div>
`
}
private _renderDialogPreview(manifest: SpriteViewerManifest, icon: SpriteViewerManifestIcon): TemplateResult {
const href = `${manifest.spriteUrl}#${icon.id}`
if (this._activeTab === 'img') {
return html`<img class="gromlab-sprite-viewer__dialog-img" src=${href} alt=${icon.name}>`
}
if (this._activeTab === 'css') {
return html`
<div
class="gromlab-sprite-viewer__dialog-mask"
role="img"
aria-label=${icon.name}
style=${styleMap({
backgroundColor: this._cssColor,
mask: `url('${href}') no-repeat center / contain`,
'-webkit-mask': `url('${href}') no-repeat center / contain`,
})}
></div>
`
}
return html`
<svg
class="gromlab-sprite-viewer__dialog-icon"
viewBox=${ifDefined(icon.viewBox ?? undefined)}
aria-label=${icon.name}
role="img"
>
<use href=${href}></use>
</svg>
`
}
private _renderDialog(): TemplateResult | typeof nothing {
if (!this._selected) return nothing
const { manifest, icon } = this._selected
const tabs = tabsForManifest(manifest)
const activeTab = tabs.some((tab) => tab.id === this._activeTab) ? this._activeTab : tabs[0].id
const code = generateViewerCode({
manifest,
icon,
tab: activeTab,
colorOverrides: this._colorOverrides,
cssColor: this._cssColor,
})
const dimensions = viewBoxSize(icon.viewBox)
const colorsVisible = activeTab === 'react' || activeTab === 'vue' || activeTab === 'svg'
return html`
<dialog
class="gromlab-sprite-viewer__dialog"
aria-labelledby="viewer-dialog-title"
@cancel=${(event: Event) => { event.preventDefault(); this._closeDialog() }}
@click=${this._handleBackdropClick}
>
<div class="gromlab-sprite-viewer__dialog-shell">
<button
class="gromlab-sprite-viewer__close"
type="button"
aria-label="Закрыть"
autofocus
@click=${this._closeDialog}
>&#x2715;</button>
<div class="gromlab-sprite-viewer__dialog-preview" style=${styleMap(this._colors)}>
<div class="gromlab-sprite-viewer__dialog-preview-canvas">
${this._renderDialogPreview(manifest, icon)}
</div>
</div>
<div class="gromlab-sprite-viewer__dialog-heading">
<h2 id="viewer-dialog-title" class="gromlab-sprite-viewer__dialog-title">${icon.name}</h2>
${dimensions ? html`<span class="gromlab-sprite-viewer__viewbox">${dimensions}</span>` : nothing}
</div>
<p class="gromlab-sprite-viewer__dialog-meta">${manifest.name} · ${manifest.format} · ${manifest.target}</p>
<div class="gromlab-sprite-viewer__colors">
${colorsVisible && icon.colors.length > 0 ? html`
<p class="gromlab-sprite-viewer__hint">
Цвета применяются к превью через CSS-переменные и попадут в пример кода.
</p>
<h3 class="gromlab-sprite-viewer__colors-title">CSS Variables</h3>
${icon.colors.map(({ variable, fallback }) => this._renderColorControl(
`${variable}: ${fallback}`,
this._colors[variable],
variable,
(color) => this._handleColorChange(variable, color),
))}
` : nothing}
${colorsVisible && icon.colors.length === 0 ? html`
<p class="gromlab-sprite-viewer__hint">У иконки нет настраиваемых цветовых переменных.</p>
` : nothing}
${activeTab === 'img' ? html`
<p class="gromlab-sprite-viewer__hint">
IMG изолирует SVG: CSS-переменные и currentColor внутрь изображения не передаются.
</p>
` : nothing}
${activeTab === 'css' ? html`
<p class="gromlab-sprite-viewer__hint">
CSS mask отображает иконку одним цветом через background-color.
</p>
${this._renderColorControl(
'background-color',
this._cssColor,
'background-color',
(color) => this._handleCssColorChange(color),
)}
` : nothing}
</div>
<div class="gromlab-sprite-viewer__tabs" role="tablist" aria-label="Способ подключения">
${tabs.map((tab, tabIndex) => html`
<button
id=${`viewer-${tab.id}-tab`}
class="gromlab-sprite-viewer__tab"
type="button"
role="tab"
aria-selected=${String(activeTab === tab.id)}
aria-controls="viewer-code-panel"
tabindex=${activeTab === tab.id ? 0 : -1}
@click=${() => { this._activeTab = tab.id }}
@keydown=${(event: KeyboardEvent) => this._handleTabKeyDown(event, tabIndex)}
>${tab.label}</button>
`)}
</div>
<div
id="viewer-code-panel"
class="gromlab-sprite-viewer__code"
role="tabpanel"
aria-labelledby=${`viewer-${activeTab}-tab`}
>
<pre><code>${unsafeHTML(highlightViewerCode(code.code, code.language))}</code></pre>
<button
class="gromlab-sprite-viewer__copy"
type="button"
@click=${() => { void this._copyCode(code.code) }}
>${this._copied ? 'Скопировано' : 'Копировать'}</button>
</div>
</div>
</dialog>
`
}
protected render(): TemplateResult {
const normalizedQuery = this._query.trim().toLowerCase()
const visibleGroups = this._manifests
.map((manifest) => ({
manifest,
icons: manifest.icons.filter((icon) => (
normalizedQuery === ''
|| icon.name.toLowerCase().includes(normalizedQuery)
|| manifest.name.toLowerCase().includes(normalizedQuery)
)),
}))
.filter((group) => group.icons.length > 0)
const totalIcons = this._manifests.reduce((total, manifest) => total + manifest.iconCount, 0)
const visibleIcons = visibleGroups.reduce((total, group) => total + group.icons.length, 0)
return html`
<section
class="gromlab-sprite-viewer"
data-sprite-viewer
data-theme=${ifDefined(this.colorTheme === 'auto' ? undefined : this.colorTheme)}
>
<header class="gromlab-sprite-viewer__header">
<h1 class="gromlab-sprite-viewer__title">${this.viewerTitle}</h1>
<span class="gromlab-sprite-viewer__summary">
${countLabel(this._manifests.length, ['спрайт', 'спрайта', 'спрайтов'])}
· ${countLabel(totalIcons, ['иконка', 'иконки', 'иконок'])}
${normalizedQuery ? ` · найдено ${visibleIcons}` : nothing}
</span>
<div class="gromlab-sprite-viewer__toolbar">
<input
class="gromlab-sprite-viewer__search"
type="search"
.value=${this._query}
@input=${(event: InputEvent) => { this._query = (event.currentTarget as HTMLInputElement).value }}
placeholder="Найти иконку"
aria-label="Поиск иконок"
>
${this.showThemeToggle ? html`
<button
class="gromlab-sprite-viewer__theme"
type="button"
aria-label="Переключить тему"
title="Переключить тему"
@click=${this._toggleTheme}
>&#x25D1;</button>
` : nothing}
</div>
</header>
${this._errors.length > 0 ? html`
<div class="gromlab-sprite-viewer__errors" role="alert">
${this._errors.map((error) => html`<div>${error}</div>`)}
</div>
` : nothing}
${visibleGroups.map(({ manifest, icons }) => html`
<section class="gromlab-sprite-viewer__group">
<div class="gromlab-sprite-viewer__group-header">
<h2 class="gromlab-sprite-viewer__group-title">${manifest.name}</h2>
<span class="gromlab-sprite-viewer__badge">${manifest.format}</span>
<span class="gromlab-sprite-viewer__group-count">${icons.length}</span>
${manifest.description ? html`
<p class="gromlab-sprite-viewer__description">${manifest.description}</p>
` : nothing}
</div>
<div class="gromlab-sprite-viewer__grid">
${icons.map((icon) => html`
<button
class="gromlab-sprite-viewer__card"
type="button"
data-icon-name=${icon.name}
title=${`Открыть ${icon.name}`}
@click=${() => { void this._selectIcon(manifest, icon) }}
>
<span class="gromlab-sprite-viewer__icon-wrap">
<svg
class="gromlab-sprite-viewer__icon"
viewBox=${ifDefined(icon.viewBox ?? undefined)}
aria-hidden="true"
>
<use href=${`${manifest.spriteUrl}#${icon.id}`}></use>
</svg>
</span>
<span class="gromlab-sprite-viewer__icon-name">${icon.name}</span>
</button>
`)}
</div>
</section>
`)}
${visibleGroups.length === 0 && (!this._loading || this._manifests.length > 0) ? html`
<div class="gromlab-sprite-viewer__status">
${this._manifests.length === 0 ? 'Спрайты не подключены' : 'Иконки не найдены'}
</div>
` : nothing}
${this._loading && this._manifests.length === 0 ? html`
<div class="gromlab-sprite-viewer__status">Загрузка спрайтов...</div>
` : nothing}
<footer class="gromlab-sprite-viewer__footer">
<span>@gromlab/svg-sprites</span>
<a href="https://github.com/gromlab-ru/svg-sprites" target="_blank" rel="noreferrer">Repository</a>
</footer>
${this._renderDialog()}
</section>
`
}
}
export function defineSpriteViewerElement(): void {
if (!customElements.get(ELEMENT_NAME)) customElements.define(ELEMENT_NAME, GromlabSpriteViewerElement)
}

107
src/viewer/source.ts Normal file
View File

@@ -0,0 +1,107 @@
import type {
SpriteViewerManifest,
SpriteViewerManifestIcon,
SpriteViewerManifestLoader,
SpriteViewerRemoteSource,
SpriteViewerSource,
SpriteViewerSources,
} from './types.js'
const manifestLoaderCache = new WeakMap<SpriteViewerManifestLoader, Promise<SpriteViewerManifest>>()
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object'
}
function isManifestIcon(value: unknown): value is SpriteViewerManifestIcon {
if (!isRecord(value)) return false
return typeof value.name === 'string'
&& typeof value.id === 'string'
&& (value.viewBox === null || typeof value.viewBox === 'string')
&& Array.isArray(value.colors)
}
export function isSpriteViewerManifest(value: unknown): value is SpriteViewerManifest {
if (!isRecord(value)) return false
return value.schemaVersion === 1
&& value.generator === '@gromlab/svg-sprites'
&& typeof value.name === 'string'
&& typeof value.target === 'string'
&& (value.format === 'stack' || value.format === 'symbol')
&& typeof value.iconCount === 'number'
&& typeof value.spriteUrl === 'string'
&& Array.isArray(value.icons)
&& value.icons.every(isManifestIcon)
}
function isManifestData(value: unknown): value is Omit<SpriteViewerManifest, 'spriteUrl'> {
return isRecord(value)
&& value.schemaVersion === 1
&& value.generator === '@gromlab/svg-sprites'
&& typeof value.name === 'string'
&& typeof value.target === 'string'
&& (value.format === 'stack' || value.format === 'symbol')
&& typeof value.iconCount === 'number'
&& Array.isArray(value.icons)
&& value.icons.every(isManifestIcon)
}
function manifestCandidate(value: unknown): unknown {
if (!isRecord(value)) return value
if (isSpriteViewerManifest(value) || isManifestData(value)) return value
return value.default ?? value.spriteManifest ?? value
}
export function normalizeSpriteViewerManifest(value: unknown, spriteUrl?: string): SpriteViewerManifest {
const candidate = manifestCandidate(value)
if (isSpriteViewerManifest(candidate)) return candidate
if (spriteUrl && isManifestData(candidate)) return { ...candidate, spriteUrl }
throw new Error('The loaded source does not contain a valid SVG sprite manifest.')
}
export function sourceArray(sources: SpriteViewerSources): readonly SpriteViewerSource[] {
return Array.isArray(sources)
? sources
: Object.values(sources as Readonly<Record<string, SpriteViewerSource>>)
}
export function isRemoteSource(source: SpriteViewerSource): source is SpriteViewerRemoteSource {
if (!isRecord(source)) return false
const candidate = source as Record<string, unknown>
return typeof candidate.manifestUrl === 'string'
&& typeof candidate.spriteUrl === 'string'
}
export function directManifests(sources: SpriteViewerSources): SpriteViewerManifest[] {
return sourceArray(sources)
.filter((source) => typeof source !== 'function' && !isRemoteSource(source))
.map((source) => normalizeSpriteViewerManifest(source))
.sort(compareManifests)
}
export function compareManifests(left: SpriteViewerManifest, right: SpriteViewerManifest): number {
return left.name < right.name ? -1 : left.name > right.name ? 1 : 0
}
async function loadRemoteSource(source: SpriteViewerRemoteSource): Promise<SpriteViewerManifest> {
const response = await fetch(source.manifestUrl)
if (!response.ok) {
throw new Error(`Cannot load SVG sprite manifest: ${response.status} ${response.statusText}`)
}
return normalizeSpriteViewerManifest(await response.json(), source.spriteUrl)
}
export async function resolveViewerSource(source: SpriteViewerSource): Promise<SpriteViewerManifest> {
if (isRemoteSource(source)) return loadRemoteSource(source)
if (typeof source !== 'function') return normalizeSpriteViewerManifest(source)
const cached = manifestLoaderCache.get(source)
if (cached) return cached
const pending = Promise.resolve().then(source).then((value) => normalizeSpriteViewerManifest(value))
manifestLoaderCache.set(source, pending)
void pending.catch(() => {
if (manifestLoaderCache.get(source) === pending) manifestLoaderCache.delete(source)
})
return pending
}

View File

@@ -1,4 +1,5 @@
export const SPRITE_VIEWER_STYLES = `
:host { display: block; }
.gromlab-sprite-viewer {
--sv-bg: #f0f0f3;
--sv-text: #1a1a1a;
@@ -271,9 +272,10 @@ export const SPRITE_VIEWER_STYLES = `
border-radius: 8px;
box-shadow: 0 8px 28px rgba(0, 0, 0, .22);
}
.gromlab-sprite-viewer__color-popover .react-colorful { width: 198px; max-width: 100%; height: 160px; }
.gromlab-sprite-viewer__hex-input { display: block; width: 100%; height: 32px; margin-top: 8px; padding: 0 8px; color: var(--sv-text); text-align: center; background: var(--sv-card); border: 1px solid var(--sv-border); border-radius: 4px; outline: none; font: 12px/1 ui-monospace, "SFMono-Regular", Consolas, monospace; }
.gromlab-sprite-viewer__hex-input:focus { border-color: var(--sv-accent); }
.gromlab-sprite-viewer__color-picker { display: flex; flex-direction: column; width: 198px; max-width: 100%; height: 160px; }
.gromlab-sprite-viewer__hex-input { display: block; width: 100%; margin-top: 8px; }
.gromlab-sprite-viewer__hex-input::part(input) { box-sizing: border-box; display: block; width: 100%; height: 32px; padding: 0 8px; color: var(--sv-text); text-align: center; background: var(--sv-card); border: 1px solid var(--sv-border); border-radius: 4px; outline: none; font: 12px/1 ui-monospace, "SFMono-Regular", Consolas, monospace; }
.gromlab-sprite-viewer__hex-input::part(input):focus { border-color: var(--sv-accent); }
.gromlab-sprite-viewer__tabs { display: flex; overflow-x: auto; margin-bottom: 12px; border-bottom: 1px solid var(--sv-border); }
.gromlab-sprite-viewer__tab { flex: 0 0 auto; padding: 8px 16px; color: var(--sv-muted); background: none; border: 0; border-bottom: 2px solid transparent; cursor: pointer; font-size: 12px; font-weight: 600; }
.gromlab-sprite-viewer__tab[aria-selected="true"] { color: var(--sv-accent); border-bottom-color: var(--sv-accent); }

68
src/viewer/types.ts Normal file
View File

@@ -0,0 +1,68 @@
export type SpriteViewerColorTheme = 'auto' | 'light' | 'dark'
export type SpriteViewerManifestColor = {
variable: `--icon-color-${number}`
fallback: string
}
export type SpriteViewerManifestIcon = {
name: string
id: string
viewBox: string | null
colors: readonly SpriteViewerManifestColor[]
}
export type SpriteViewerManifestUsage = {
framework: 'react' | 'vue'
componentName: string
}
/** Нормализованный manifest, который может отобразить framework-neutral Viewer. */
export type SpriteViewerManifest = {
schemaVersion: 1
generator: '@gromlab/svg-sprites'
name: string
description?: string
mode?: string
target: string
format: 'stack' | 'symbol'
iconCount: number
spriteUrl: string
icons: readonly SpriteViewerManifestIcon[]
/** Текущее поле React/Next manifests. */
componentName?: string
/** Расширяемое описание framework-specific примера. */
usage?: SpriteViewerManifestUsage
}
export type SpriteViewerManifestModule = {
default?: SpriteViewerManifest
spriteManifest?: SpriteViewerManifest
}
export type SpriteViewerManifestLoader = () => Promise<SpriteViewerManifest | SpriteViewerManifestModule>
export type SpriteViewerRemoteSource = {
manifestUrl: string
spriteUrl: string
}
export type SpriteViewerSource =
| SpriteViewerManifest
| SpriteViewerManifestModule
| SpriteViewerManifestLoader
| SpriteViewerRemoteSource
export type SpriteViewerSources =
| readonly SpriteViewerSource[]
| Readonly<Record<string, SpriteViewerSource>>
export type SpriteViewerElement = HTMLElement & {
sources: SpriteViewerSources
viewerTitle: string
colorTheme: SpriteViewerColorTheme
themeControlled: boolean
showThemeToggle: boolean
manifestUrl?: string
spriteUrl?: string
}

View File

@@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}

View File

@@ -11,6 +11,7 @@ const manifest = {
name: 'controls',
description: 'Control icons',
componentName: 'ControlsIcon',
mode: 'react@vite',
target: 'vite',
format: 'stack',
iconCount: 1,
@@ -25,7 +26,7 @@ const manifest = {
],
}
test('SpriteViewer opens an icon dialog with code tabs and copy action', async (context) => {
test('SpriteViewer bridges React to the interactive Viewer Web Component', async (context) => {
const dom = new JSDOM('<div id="root"></div>', { url: 'https://example.test/' })
const originals = new Map()
const mediaListeners = new Set()
@@ -45,10 +46,20 @@ test('SpriteViewer opens an icon dialog with code tabs and copy action', async (
self: dom.window,
navigator: dom.window.navigator,
Node: dom.window.Node,
Element: dom.window.Element,
Document: dom.window.Document,
DocumentFragment: dom.window.DocumentFragment,
Event: dom.window.Event,
CustomEvent: dom.window.CustomEvent,
HTMLElement: dom.window.HTMLElement,
HTMLDialogElement: dom.window.HTMLDialogElement,
HTMLInputElement: dom.window.HTMLInputElement,
KeyboardEvent: dom.window.KeyboardEvent,
MouseEvent: dom.window.MouseEvent,
ShadowRoot: dom.window.ShadowRoot,
CSSStyleSheet: dom.window.CSSStyleSheet,
MutationObserver: dom.window.MutationObserver,
customElements: dom.window.customElements,
matchMedia,
getComputedStyle: dom.window.getComputedStyle.bind(dom.window),
IS_REACT_ACT_ENVIRONMENT: true,
@@ -64,8 +75,8 @@ test('SpriteViewer opens an icon dialog with code tabs and copy action', async (
configurable: true,
value: { writeText: async (value) => { clipboardWrites.push(value) } },
})
dom.window.HTMLDialogElement.prototype.showModal = function showModal() { this.open = true }
dom.window.HTMLDialogElement.prototype.close = function close() { this.open = false }
dom.window.HTMLDialogElement.prototype.showModal = function showModal() { this.setAttribute('open', '') }
dom.window.HTMLDialogElement.prototype.close = function close() { this.removeAttribute('open') }
const reactModule = await import('react')
const { act: domAct } = await import('react-dom/test-utils')
@@ -74,6 +85,25 @@ test('SpriteViewer opens an icon dialog with code tabs and copy action', async (
const container = dom.window.document.querySelector('#root')
const root = createRoot(container)
async function viewerRoot() {
await act(async () => {
await dom.window.customElements.whenDefined('gromlab-sprite-viewer')
await Promise.resolve()
await new Promise((resolve) => setTimeout(resolve, 0))
})
const host = container.querySelector('gromlab-sprite-viewer')
assert.ok(host)
await host.updateComplete
return { host, shadow: host.shadowRoot }
}
async function settle(host) {
await act(async () => {
await host.updateComplete
await Promise.resolve()
})
}
context.after(async () => {
await act(async () => root.unmount())
dom.window.close()
@@ -83,98 +113,79 @@ test('SpriteViewer opens an icon dialog with code tabs and copy action', async (
}
})
await act(async () => root.render(createElement(SpriteViewer, { sources: [manifest] })))
assert.equal(container.querySelector('[data-sprite-viewer]').dataset.theme, undefined)
await act(async () => root.render(createElement(SpriteViewer, { sources: [manifest], title: 'Icon catalog' })))
let { host, shadow } = await viewerRoot()
assert.equal(host.viewerTitle, 'Icon catalog')
assert.equal(shadow.querySelector('[data-sprite-viewer]').dataset.theme, undefined)
assert.match(shadow.textContent, /Icon catalog/)
assert.match(shadow.textContent, /1 спрайт\s*· 1 иконка/)
const card = container.querySelector('[data-icon-name="check"]')
const card = shadow.querySelector('[data-icon-name="check"]')
assert.ok(card)
await act(async () => card.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
card.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true, composed: true }))
await settle(host)
const dialog = container.querySelector('dialog')
let dialog = shadow.querySelector('dialog')
assert.ok(dialog)
assert.equal(dialog.open, true)
assert.match(dialog.textContent, /check/)
assert.match(dialog.textContent, /16 × 16/)
assert.match(dialog.textContent, /ControlsIcon/)
const reactTab = [...dialog.querySelectorAll('[role="tab"]')]
.find((element) => element.textContent === 'React')
await act(async () => reactTab.dispatchEvent(new dom.window.KeyboardEvent('keydown', {
bubbles: true,
key: 'ArrowRight',
})))
reactTab.dispatchEvent(new dom.window.KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' }))
await settle(host)
assert.equal(dialog.querySelector('[role="tab"][aria-selected="true"]').textContent, 'SVG')
const colorSwatch = dialog.querySelector('[aria-label^="Изменить цвет --icon-color-1"]')
assert.ok(colorSwatch)
assert.equal(dialog.querySelector('.gromlab-sprite-viewer__hex-input'), null)
await act(async () => {
prefersDark = true
for (const listener of mediaListeners) listener({ matches: true, media: darkModeMedia.media })
})
prefersDark = true
for (const listener of mediaListeners) listener({ matches: true, media: darkModeMedia.media })
await settle(host)
assert.equal(colorSwatch.style.backgroundColor, 'rgb(229, 229, 229)')
await act(async () => colorSwatch.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
assert.ok(dialog.querySelector('.react-colorful'))
const colorRow = colorSwatch.closest('.gromlab-sprite-viewer__color-row')
await act(async () => colorRow.dispatchEvent(new dom.window.Event('pointerdown', { bubbles: true })))
assert.equal(dialog.querySelector('.react-colorful'), null)
await act(async () => colorRow.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
assert.equal(dialog.querySelector('.react-colorful'), null)
await act(async () => colorSwatch.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
const colorPopover = dialog.querySelector('.gromlab-sprite-viewer__color-popover')
await act(async () => colorPopover.querySelector('.react-colorful').dispatchEvent(
new dom.window.Event('pointerdown', { bubbles: true }),
))
assert.ok(dialog.querySelector('.react-colorful'))
await act(async () => colorPopover.dispatchEvent(new dom.window.KeyboardEvent('keydown', {
bubbles: true,
key: 'Escape',
})))
assert.equal(dialog.querySelector('.react-colorful'), null)
assert.ok(container.querySelector('dialog'))
await act(async () => colorSwatch.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
const colorInput = dialog.querySelector('.gromlab-sprite-viewer__hex-input')
assert.ok(colorInput)
const setInputValue = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, 'value').set
setInputValue.call(colorInput, '#ff0000')
await act(async () => colorInput.dispatchEvent(new dom.window.Event('input', { bubbles: true })))
colorSwatch.click()
await settle(host)
await dom.window.customElements.whenDefined('gromlab-hex-input')
const colorInputElement = dialog.querySelector('gromlab-hex-input')
assert.ok(colorInputElement)
const colorInput = colorInputElement.shadowRoot.querySelector('input')
colorInput.value = '#ff0000'
colorInput.dispatchEvent(new dom.window.Event('input', { bubbles: true, composed: true }))
await settle(host)
assert.match(dialog.querySelector('[role="tabpanel"]').textContent, /--icon-color-1/)
assert.doesNotMatch(dialog.querySelector('[role="tabpanel"]').textContent, /React\.CSSProperties/)
await act(async () => {
prefersDark = false
for (const listener of mediaListeners) listener({ matches: false, media: darkModeMedia.media })
})
assert.equal(colorSwatch.style.backgroundColor, 'rgb(255, 0, 0)')
const cssTab = [...dialog.querySelectorAll('[role="tab"]')]
.find((element) => element.textContent === 'CSS')
assert.ok(cssTab)
await act(async () => cssTab.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
cssTab.click()
await settle(host)
assert.match(dialog.querySelector('[role="tabpanel"]').textContent, /mask:/)
const copyButton = [...dialog.querySelectorAll('button')]
.find((element) => element.textContent === 'Копировать')
assert.ok(copyButton)
await act(async () => copyButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
copyButton.click()
await settle(host)
assert.equal(clipboardWrites.length, 1)
assert.match(clipboardWrites[0], /background-color/)
await act(async () => dialog.dispatchEvent(new dom.window.Event('cancel', { cancelable: true })))
assert.equal(container.querySelector('dialog'), null)
dialog.dispatchEvent(new dom.window.Event('cancel', { cancelable: true }))
await settle(host)
assert.equal(shadow.querySelector('dialog'), null)
const themeButton = container.querySelector('[aria-label="Переключить тему"]')
await act(async () => themeButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
assert.equal(container.querySelector('[data-sprite-viewer]').dataset.theme, 'dark')
const themeButton = shadow.querySelector('[aria-label="Переключить тему"]')
themeButton.click()
await settle(host)
assert.equal(shadow.querySelector('[data-sprite-viewer]').dataset.theme, 'light')
await act(async () => root.render(createElement(SpriteViewer, {
sources: [manifest],
colorTheme: 'light',
})))
assert.equal(container.querySelector('[data-sprite-viewer]').dataset.theme, 'light')
assert.equal(container.querySelector('[aria-label="Переключить тему"]'), null)
;({ host, shadow } = await viewerRoot())
await settle(host)
assert.equal(shadow.querySelector('[data-sprite-viewer]').dataset.theme, 'light')
assert.equal(shadow.querySelector('[aria-label="Переключить тему"]'), null)
const controlledChanges = []
await act(async () => root.render(createElement(SpriteViewer, {
@@ -182,18 +193,22 @@ test('SpriteViewer opens an icon dialog with code tabs and copy action', async (
colorTheme: 'light',
onColorThemeChange: (theme) => controlledChanges.push(theme),
})))
const controlledThemeButton = container.querySelector('[aria-label="Переключить тему"]')
await act(async () => controlledThemeButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
;({ host, shadow } = await viewerRoot())
await settle(host)
shadow.querySelector('[aria-label="Переключить тему"]').click()
await settle(host)
assert.deepEqual(controlledChanges, ['dark'])
assert.equal(container.querySelector('[data-sprite-viewer]').dataset.theme, 'light')
assert.equal(shadow.querySelector('[data-sprite-viewer]').dataset.theme, 'light')
await act(async () => root.render(createElement(SpriteViewer, {
sources: [{ ...manifest, format: 'symbol' }],
})))
const symbolCard = container.querySelector('[data-icon-name="check"]')
await act(async () => symbolCard.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
;({ host, shadow } = await viewerRoot())
await settle(host)
shadow.querySelector('[data-icon-name="check"]').click()
await settle(host)
assert.deepEqual(
[...container.querySelectorAll('[role="tab"]')].map((element) => element.textContent),
[...shadow.querySelectorAll('[role="tab"]')].map((element) => element.textContent),
['React', 'SVG'],
)
@@ -207,6 +222,12 @@ test('SpriteViewer opens an icon dialog with code tabs and copy action', async (
null,
createElement(SpriteViewer, { sources: [loader] }),
)))
;({ host, shadow } = await viewerRoot())
await act(async () => {
await Promise.resolve()
await Promise.resolve()
await host.updateComplete
})
assert.equal(loaderCalls, 1)
assert.match(container.textContent, /controls/)
assert.match(shadow.textContent, /controls/)
})

View File

@@ -40,14 +40,9 @@ test('SpriteViewer renders direct sprite manifests', () => {
title: 'Icon catalog',
}))
assert.match(markup, /Icon catalog/)
assert.match(markup, /controls/)
assert.match(markup, /Control icons/)
assert.match(markup, /check/)
assert.match(markup, /\/assets\/controls\.svg#check/)
assert.match(markup, /1 спрайт · 1 иконка/)
assert.match(markup, /gromlab-sprite-viewer__icon-wrap/)
assert.match(markup, /Открыть check/)
assert.match(markup, /^<gromlab-sprite-viewer/)
assert.match(markup, /data-sprite-viewer-host=""/)
assert.doesNotMatch(markup, /Icon catalog/)
})
test('SpriteViewer accepts a loader record from import.meta.glob', () => {
@@ -57,7 +52,7 @@ test('SpriteViewer accepts a loader record from import.meta.glob', () => {
},
}))
assert.match(markup, /Загрузка спрайтов/)
assert.match(markup, /^<gromlab-sprite-viewer/)
})
test('SpriteViewer supports an externally controlled color theme', () => {
@@ -66,6 +61,6 @@ test('SpriteViewer supports an externally controlled color theme', () => {
colorTheme: 'dark',
}))
assert.match(markup, /^<section[^>]*data-theme="dark"/)
assert.doesNotMatch(markup, /<button[^>]*aria-label="Переключить тему"/)
assert.match(markup, /^<gromlab-sprite-viewer/)
assert.doesNotMatch(markup, /data-theme=/)
})

View File

@@ -5,11 +5,12 @@ export default defineConfig({
index: 'src/index.ts',
cli: 'src/cli.ts',
react: 'src/react.ts',
viewer: 'src/viewer.ts',
},
format: 'esm',
dts: true,
clean: true,
sourcemap: true,
target: 'node18',
noExternal: ['react-colorful'],
noExternal: ['lit', 'vanilla-colorful'],
})

14
tsup.viewer.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'tsup'
export default defineConfig({
entry: {
'viewer-element': 'src/viewer-element.ts',
},
format: 'esm',
dts: true,
clean: false,
splitting: false,
sourcemap: true,
target: 'node18',
noExternal: ['lit', 'vanilla-colorful'],
})