mirror of
https://github.com/gromlab-ru/svg-sprites.git
synced 2026-07-21 20:30:16 +03:00
feat: Новый кросс платформенный вьювер
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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-модулей приложения.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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">`.
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
11
integration/apps/next-app-turbopack/app/sprite-viewer.tsx
Normal file
11
integration/apps/next-app-turbopack/app/sprite-viewer.tsx
Normal 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 }} />
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
11
integration/apps/next-app-webpack/app/sprite-viewer.tsx
Normal file
11
integration/apps/next-app-webpack/app/sprite-viewer.tsx
Normal 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 }} />
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -3,4 +3,7 @@ import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
dedupe: ['react', 'react-dom'],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
5
integration/package-lock.json
generated
5
integration/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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
81
package-lock.json
generated
@@ -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",
|
||||
|
||||
28
package.json
28
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
✕
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
◑
|
||||
</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': '',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
18
src/viewer-element.ts
Normal 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
14
src/viewer.ts
Normal 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'
|
||||
@@ -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
650
src/viewer/element.ts
Normal 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}
|
||||
>✕</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}
|
||||
>◑</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
107
src/viewer/source.ts
Normal 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
|
||||
}
|
||||
@@ -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
68
src/viewer/types.ts
Normal 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
|
||||
}
|
||||
4
test-results/.last-run.json
Normal file
4
test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
@@ -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=/)
|
||||
})
|
||||
|
||||
@@ -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
14
tsup.viewer.config.ts
Normal 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'],
|
||||
})
|
||||
Reference in New Issue
Block a user