From 1280eb6fcd67b65379baee204a4abc360943d011 Mon Sep 17 00:00:00 2001 From: "S.Gromov" Date: Tue, 14 Jul 2026 09:54:36 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=9D=D0=BE=D0=B2=D1=8B=D0=B9=20=D0=BA?= =?UTF-8?q?=D1=80=D0=BE=D1=81=D1=81=20=D0=BF=D0=BB=D0=B0=D1=82=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=B2=D1=8C=D1=8E?= =?UTF-8?q?=D0=B2=D0=B5=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- README.md | 4 +- README_RU.md | 4 +- THIRD_PARTY_NOTICES.md | 61 +- docs/en/programmatic-api.md | 15 +- docs/en/reference.md | 38 +- docs/ru/programmatic-api.md | 15 +- docs/ru/reference.md | 38 +- integration/README.md | 2 + .../apps/next-app-turbopack/app/page.tsx | 2 + .../next-app-turbopack/app/sprite-viewer.tsx | 11 + .../apps/next-app-turbopack/next.config.mjs | 6 +- .../apps/next-app-webpack/app/page.tsx | 2 + .../next-app-webpack/app/sprite-viewer.tsx | 11 + .../apps/next-app-webpack/next.config.mjs | 6 +- .../apps/next-pages-turbopack/next.config.mjs | 6 +- .../apps/next-pages-turbopack/pages/index.tsx | 6 + .../apps/next-pages-webpack/next.config.mjs | 10 +- .../apps/next-pages-webpack/pages/index.tsx | 6 + integration/apps/react-vite/src/main.tsx | 6 + integration/apps/react-vite/vite.config.ts | 3 + integration/apps/react-webpack/src/main.tsx | 6 + .../apps/react-webpack/webpack.config.js | 4 + integration/apps/standalone-vite/src/main.ts | 7 + .../apps/standalone-webpack/src/main.ts | 7 + integration/apps/standalone/build.mjs | 4 + integration/apps/standalone/index.html | 7 + integration/package-lock.json | 5 +- integration/tests/apps.spec.ts | 43 ++ package-lock.json | 81 ++- package.json | 28 +- src/react/sprite-viewer-dialog.tsx | 317 --------- src/react/sprite-viewer.tsx | 304 ++------ src/react/types.ts | 49 +- src/viewer-element.ts | 18 + src/viewer.ts | 14 + .../sprite-viewer-code.ts => viewer/code.ts} | 49 +- src/viewer/element.ts | 650 ++++++++++++++++++ src/viewer/source.ts | 107 +++ .../styles.ts} | 8 +- src/viewer/types.ts | 68 ++ test-results/.last-run.json | 4 + test/react-viewer-dom.test.mjs | 157 +++-- test/react-viewer.test.mjs | 17 +- tsup.config.ts | 3 +- tsup.viewer.config.ts | 14 + 46 files changed, 1440 insertions(+), 785 deletions(-) create mode 100644 integration/apps/next-app-turbopack/app/sprite-viewer.tsx create mode 100644 integration/apps/next-app-webpack/app/sprite-viewer.tsx delete mode 100644 src/react/sprite-viewer-dialog.tsx create mode 100644 src/viewer-element.ts create mode 100644 src/viewer.ts rename src/{react/sprite-viewer-code.ts => viewer/code.ts} (76%) create mode 100644 src/viewer/element.ts create mode 100644 src/viewer/source.ts rename src/{react/sprite-viewer-styles.ts => viewer/styles.ts} (95%) create mode 100644 src/viewer/types.ts create mode 100644 test-results/.last-run.json create mode 100644 tsup.viewer.config.ts diff --git a/AGENTS.md b/AGENTS.md index fe92c92..2a9acf8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 0531b79..3ea21c9 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README_RU.md b/README_RU.md index 301d579..97ace8c 100644 --- a/README_RU.md +++ b/README_RU.md @@ -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. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 708d9bd..2941113 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -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 +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 MIT License diff --git a/docs/en/programmatic-api.md b/docs/en/programmatic-api.md index 1240785..d5c42a0 100644 --- a/docs/en/programmatic-api.md +++ b/docs/en/programmatic-api.md @@ -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 ``. 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. diff --git a/docs/en/reference.md b/docs/en/reference.md index e2761c5..f132563 100644 --- a/docs/en/reference.md +++ b/docs/en/reference.md @@ -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 + + + +``` + +`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('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 diff --git a/docs/ru/programmatic-api.md b/docs/ru/programmatic-api.md index 971490f..5a3c559 100644 --- a/docs/ru/programmatic-api.md +++ b/docs/ru/programmatic-api.md @@ -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 регистрирует ``. 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-модулей приложения. diff --git a/docs/ru/reference.md b/docs/ru/reference.md index 67eaf54..ce66bd1 100644 --- a/docs/ru/reference.md +++ b/docs/ru/reference.md @@ -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 + + + +``` + +`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('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 diff --git a/integration/README.md b/integration/README.md index 1cb05d4..9b3c9a6 100644 --- a/integration/README.md +++ b/integration/README.md @@ -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 ``. diff --git a/integration/apps/next-app-turbopack/app/page.tsx b/integration/apps/next-app-turbopack/app/page.tsx index b72c26d..e43bd8a 100644 --- a/integration/apps/next-app-turbopack/app/page.tsx +++ b/integration/apps/next-app-turbopack/app/page.tsx @@ -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' }} /> + ) } diff --git a/integration/apps/next-app-turbopack/app/sprite-viewer.tsx b/integration/apps/next-app-turbopack/app/sprite-viewer.tsx new file mode 100644 index 0000000..550501c --- /dev/null +++ b/integration/apps/next-app-turbopack/app/sprite-viewer.tsx @@ -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 +} diff --git a/integration/apps/next-app-turbopack/next.config.mjs b/integration/apps/next-app-turbopack/next.config.mjs index fe967c5..5f8e216 100644 --- a/integration/apps/next-app-turbopack/next.config.mjs +++ b/integration/apps/next-app-turbopack/next.config.mjs @@ -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, }, } diff --git a/integration/apps/next-app-webpack/app/page.tsx b/integration/apps/next-app-webpack/app/page.tsx index e4a92a3..ff2ff3e 100644 --- a/integration/apps/next-app-webpack/app/page.tsx +++ b/integration/apps/next-app-webpack/app/page.tsx @@ -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' }} /> + ) } diff --git a/integration/apps/next-app-webpack/app/sprite-viewer.tsx b/integration/apps/next-app-webpack/app/sprite-viewer.tsx new file mode 100644 index 0000000..b77ec20 --- /dev/null +++ b/integration/apps/next-app-webpack/app/sprite-viewer.tsx @@ -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 +} diff --git a/integration/apps/next-app-webpack/next.config.mjs b/integration/apps/next-app-webpack/next.config.mjs index fe967c5..5f8e216 100644 --- a/integration/apps/next-app-webpack/next.config.mjs +++ b/integration/apps/next-app-webpack/next.config.mjs @@ -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, }, } diff --git a/integration/apps/next-pages-turbopack/next.config.mjs b/integration/apps/next-pages-turbopack/next.config.mjs index fe967c5..5f8e216 100644 --- a/integration/apps/next-pages-turbopack/next.config.mjs +++ b/integration/apps/next-pages-turbopack/next.config.mjs @@ -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, }, } diff --git a/integration/apps/next-pages-turbopack/pages/index.tsx b/integration/apps/next-pages-turbopack/pages/index.tsx index c3e3162..31f8d54 100644 --- a/integration/apps/next-pages-turbopack/pages/index.tsx +++ b/integration/apps/next-pages-turbopack/pages/index.tsx @@ -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 (
@@ -13,6 +18,7 @@ export default function Page() { height={64} style={{ '--icon-color-1': '#16a34a' }} /> +
) } diff --git a/integration/apps/next-pages-webpack/next.config.mjs b/integration/apps/next-pages-webpack/next.config.mjs index fe967c5..7f7a39c 100644 --- a/integration/apps/next-pages-webpack/next.config.mjs +++ b/integration/apps/next-pages-webpack/next.config.mjs @@ -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 }, } diff --git a/integration/apps/next-pages-webpack/pages/index.tsx b/integration/apps/next-pages-webpack/pages/index.tsx index 494d220..5f21dfa 100644 --- a/integration/apps/next-pages-webpack/pages/index.tsx +++ b/integration/apps/next-pages-webpack/pages/index.tsx @@ -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 (
@@ -13,6 +18,7 @@ export default function Page() { height={64} style={{ '--icon-color-1': '#16a34a' }} /> +
) } diff --git a/integration/apps/react-vite/src/main.tsx b/integration/apps/react-vite/src/main.tsx index 53d19dd..39d6489 100644 --- a/integration/apps/react-vite/src/main.tsx +++ b/integration/apps/react-vite/src/main.tsx @@ -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(
@@ -17,6 +22,7 @@ createRoot(document.getElementById('root')!).render( height={64} style={{ '--icon-color-1': '#16a34a' }} /> +
, ) diff --git a/integration/apps/react-vite/vite.config.ts b/integration/apps/react-vite/vite.config.ts index ae74518..861c45a 100644 --- a/integration/apps/react-vite/vite.config.ts +++ b/integration/apps/react-vite/vite.config.ts @@ -3,4 +3,7 @@ import { defineConfig } from 'vite' export default defineConfig({ plugins: [react()], + resolve: { + dedupe: ['react', 'react-dom'], + }, }) diff --git a/integration/apps/react-webpack/src/main.tsx b/integration/apps/react-webpack/src/main.tsx index df02e60..963fff8 100644 --- a/integration/apps/react-webpack/src/main.tsx +++ b/integration/apps/react-webpack/src/main.tsx @@ -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(
@@ -17,6 +22,7 @@ createRoot(document.getElementById('root')!).render( height={64} style={{ '--icon-color-1': '#16a34a' }} /> +
, ) diff --git a/integration/apps/react-webpack/webpack.config.js b/integration/apps/react-webpack/webpack.config.js index 0dd921a..4c72130 100644 --- a/integration/apps/react-webpack/webpack.config.js +++ b/integration/apps/react-webpack/webpack.config.js @@ -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: [ diff --git a/integration/apps/standalone-vite/src/main.ts b/integration/apps/standalone-vite/src/main.ts index d50255d..ead284d 100644 --- a/integration/apps/standalone-vite/src/main.ts +++ b/integration/apps/standalone-vite/src/main.ts @@ -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('#app')!.innerHTML = ` > + ` + +const viewer = document.querySelector('gromlab-sprite-viewer')! +viewer.viewerTitle = 'Standalone Vite Viewer' +viewer.sources = [spriteManifest] diff --git a/integration/apps/standalone-webpack/src/main.ts b/integration/apps/standalone-webpack/src/main.ts index c0d93a6..a8063c8 100644 --- a/integration/apps/standalone-webpack/src/main.ts +++ b/integration/apps/standalone-webpack/src/main.ts @@ -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('#app')!.innerHTML = ` > + ` + +const viewer = document.querySelector('gromlab-sprite-viewer')! +viewer.viewerTitle = 'Standalone Webpack Viewer' +viewer.sources = [spriteManifest] diff --git a/integration/apps/standalone/build.mjs b/integration/apps/standalone/build.mjs index 257b706..de399bb 100644 --- a/integration/apps/standalone/build.mjs +++ b/integration/apps/standalone/build.mjs @@ -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), diff --git a/integration/apps/standalone/index.html b/integration/apps/standalone/index.html index a3c5eba..b1bd614 100644 --- a/integration/apps/standalone/index.html +++ b/integration/apps/standalone/index.html @@ -7,6 +7,7 @@ @@ -15,6 +16,12 @@ + + diff --git a/integration/package-lock.json b/integration/package-lock.json index ec322f1..8514732 100644 --- a/integration/package-lock.json +++ b/integration/package-lock.json @@ -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" diff --git a/integration/tests/apps.spec.ts b/integration/tests/apps.spec.ts index 79c67dc..3a4de4e 100644 --- a/integration/tests/apps.spec.ts +++ b/integration/tests/apps.spec.ts @@ -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) { diff --git a/package-lock.json b/package-lock.json index 5e63fdd..d6411fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6c68403..1056b34 100644 --- a/package.json +++ b/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" } } diff --git a/src/react/sprite-viewer-dialog.tsx b/src/react/sprite-viewer-dialog.tsx deleted file mode 100644 index 62c6a82..0000000 --- a/src/react/sprite-viewer-dialog.tsx +++ /dev/null @@ -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(null) - const popoverRef = useRef(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 ( -
-
- ) -} - -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(null) - const titleId = useId() - const tabsId = useId() - const tabs = tabsForFormat(manifest.format) - const [activeTab, setActiveTab] = useState('react') - const themeColor = colorTheme === 'dark' ? '#e5e5e5' : '#1a1a1a' - const [colors, setColors] = useState>(() => initialColors(icon, themeColor)) - const [colorOverrides, setColorOverrides] = useState>({}) - 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) { - 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, 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('[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 {icon.name} - } - if (activeTab === 'css') { - return ( -
- ) - } - return ( - - - - ) - } - - return ( - { - event.preventDefault() - onClose() - }} - onClick={handleBackdropClick} - > -
- - -
-
- {renderPreview()} -
-
- -
-

{icon.name}

- {dimensions && {dimensions}} -
-

- {manifest.name} · {manifest.format} · {manifest.target} -

- -
- {(activeTab === 'react' || activeTab === 'svg') && icon.colors.length > 0 && ( - <> -

- Цвета применяются к превью через CSS-переменные и попадут в пример кода. -

-

CSS Variables

- {icon.colors.map(({ variable, fallback }) => ( - handleColorChange(variable, color)} - /> - ))} - - )} - {(activeTab === 'react' || activeTab === 'svg') && icon.colors.length === 0 && ( -

У иконки нет настраиваемых цветовых переменных.

- )} - {activeTab === 'img' && ( -

- IMG изолирует SVG: CSS-переменные и currentColor внутрь изображения не передаются. -

- )} - {activeTab === 'css' && ( - <> -

- CSS mask отображает иконку одним цветом через background-color. -

- - - )} -
- -
- {tabs.map((tab, tabIndex) => ( - - ))} -
- -
-
- -
-
-
- ) -} diff --git a/src/react/sprite-viewer.tsx b/src/react/sprite-viewer.tsx index 7992a69..792da76 100644 --- a/src/react/sprite-viewer.tsx +++ b/src/react/sprite-viewer.tsx @@ -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>() +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>) -} - -function isSpriteManifest(value: unknown): value is SpriteManifest { - if (!value || typeof value !== 'object') return false - const manifest = value as Partial - 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 { - 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(() => directManifests(sources)) - const [errors, setErrors] = useState([]) - const [loading, setLoading] = useState(() => sourceArray(sources).some((source) => typeof source === 'function')) - const [query, setQuery] = useState('') - const [localColorTheme, setLocalColorTheme] = useState('auto') - const [selected, setSelected] = useState(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(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 ( -
- -
-

{title}

- - {countLabel(manifests.length, ['спрайт', 'спрайта', 'спрайтов'])} - {' · '} - {countLabel(totalIcons, ['иконка', 'иконки', 'иконок'])} - {deferredQuery && ` · найдено ${visibleIcons}`} - -
- setQuery(event.currentTarget.value)} - placeholder="Найти иконку" - aria-label="Поиск иконок" - /> - {canToggleTheme && ( - - )} -
-
- - {errors.length > 0 && ( -
- {errors.map((error, index) =>
{error}
)} -
- )} - - {visibleGroups.map(({ manifest, icons }) => ( -
-
-

{manifest.name}

- {manifest.format} - {icons.length} - {manifest.description && ( -

{manifest.description}

- )} -
-
- {icons.map((icon) => { - const key = `${manifest.name}:${manifest.spriteUrl}:${icon.id}` - return ( - - ) - })} -
-
- ))} - - {visibleGroups.length === 0 && (!loading || manifests.length > 0) && ( -
- {manifests.length === 0 ? 'Спрайты не подключены' : 'Иконки не найдены'} -
- )} - {loading && manifests.length === 0 && ( -
Загрузка спрайтов...
- )} - - - - {selected && ( - setSelected(null)} - /> - )} -
- ) + return createElement('gromlab-sprite-viewer', { + ref: elementRef, + className, + style, + 'data-sprite-viewer-host': '', + }) } diff --git a/src/react/types.ts b/src/react/types.ts index d709dc5..8bf1fc7 100644 --- a/src/react/types.ts +++ b/src/react/types.ts @@ -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 -export type SpriteViewerSource = SpriteManifest | SpriteManifestLoader - -/** Массив источников либо результат import.meta.glob. */ -export type SpriteViewerSources = - | readonly SpriteViewerSource[] - | Readonly> +export type SpriteManifestModule = SpriteViewerManifestModule +export type SpriteManifestLoader = SpriteViewerManifestLoader +export type { SpriteViewerSource, SpriteViewerSources } diff --git a/src/viewer-element.ts b/src/viewer-element.ts new file mode 100644 index 0000000..d7466f3 --- /dev/null +++ b/src/viewer-element.ts @@ -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' diff --git a/src/viewer.ts b/src/viewer.ts new file mode 100644 index 0000000..2ad3b1e --- /dev/null +++ b/src/viewer.ts @@ -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' diff --git a/src/react/sprite-viewer-code.ts b/src/viewer/code.ts similarity index 76% rename from src/react/sprite-viewer-code.ts rename to src/viewer/code.ts index 0d7d25f..890794b 100644 --- a/src/react/sprite-viewer-code.ts +++ b/src/viewer/code.ts @@ -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> 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, diff --git a/src/viewer/element.ts b/src/viewer/element.ts new file mode 100644 index 0000000..e054f38 --- /dev/null +++ b/src/viewer/element.ts @@ -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 | undefined + +function defineColorElements(): Promise { + 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 { + 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 + declare private _colorOverrides: Record + declare private _cssColor: string + declare private _cssColorOverridden: boolean + declare private _openColorControl: string | null + declare private _copied: boolean + private _loadVersion = 0 + private _copyTimeout?: ReturnType + 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): 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 { + 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 = 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 { + 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('dialog') + if (dialog && !dialog.open) dialog.showModal() + } + + private _closeDialog(): void { + const dialog = this.renderRoot.querySelector('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('[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 { + 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` +
+ + ${label} + ${open ? html` + + ` : nothing} +
+ ` + } + + private _renderDialogPreview(manifest: SpriteViewerManifest, icon: SpriteViewerManifestIcon): TemplateResult { + const href = `${manifest.spriteUrl}#${icon.id}` + if (this._activeTab === 'img') { + return html`${icon.name}` + } + if (this._activeTab === 'css') { + return html` + + ` + } + return html` + + + + ` + } + + 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` + { event.preventDefault(); this._closeDialog() }} + @click=${this._handleBackdropClick} + > +
+ + +
+
+ ${this._renderDialogPreview(manifest, icon)} +
+
+ +
+

${icon.name}

+ ${dimensions ? html`${dimensions}` : nothing} +
+

${manifest.name} · ${manifest.format} · ${manifest.target}

+ +
+ ${colorsVisible && icon.colors.length > 0 ? html` +

+ Цвета применяются к превью через CSS-переменные и попадут в пример кода. +

+

CSS Variables

+ ${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` +

У иконки нет настраиваемых цветовых переменных.

+ ` : nothing} + ${activeTab === 'img' ? html` +

+ IMG изолирует SVG: CSS-переменные и currentColor внутрь изображения не передаются. +

+ ` : nothing} + ${activeTab === 'css' ? html` +

+ CSS mask отображает иконку одним цветом через background-color. +

+ ${this._renderColorControl( + 'background-color', + this._cssColor, + 'background-color', + (color) => this._handleCssColorChange(color), + )} + ` : nothing} +
+ +
+ ${tabs.map((tab, tabIndex) => html` + + `)} +
+ +
+
${unsafeHTML(highlightViewerCode(code.code, code.language))}
+ +
+
+
+ ` + } + + 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` +
+
+

${this.viewerTitle}

+ + ${countLabel(this._manifests.length, ['спрайт', 'спрайта', 'спрайтов'])} + · ${countLabel(totalIcons, ['иконка', 'иконки', 'иконок'])} + ${normalizedQuery ? ` · найдено ${visibleIcons}` : nothing} + +
+ { this._query = (event.currentTarget as HTMLInputElement).value }} + placeholder="Найти иконку" + aria-label="Поиск иконок" + > + ${this.showThemeToggle ? html` + + ` : nothing} +
+
+ + ${this._errors.length > 0 ? html` + + ` : nothing} + + ${visibleGroups.map(({ manifest, icons }) => html` +
+
+

${manifest.name}

+ ${manifest.format} + ${icons.length} + ${manifest.description ? html` +

${manifest.description}

+ ` : nothing} +
+
+ ${icons.map((icon) => html` + + `)} +
+
+ `)} + + ${visibleGroups.length === 0 && (!this._loading || this._manifests.length > 0) ? html` +
+ ${this._manifests.length === 0 ? 'Спрайты не подключены' : 'Иконки не найдены'} +
+ ` : nothing} + ${this._loading && this._manifests.length === 0 ? html` +
Загрузка спрайтов...
+ ` : nothing} + + + + ${this._renderDialog()} +
+ ` + } +} + +export function defineSpriteViewerElement(): void { + if (!customElements.get(ELEMENT_NAME)) customElements.define(ELEMENT_NAME, GromlabSpriteViewerElement) +} diff --git a/src/viewer/source.ts b/src/viewer/source.ts new file mode 100644 index 0000000..289d31f --- /dev/null +++ b/src/viewer/source.ts @@ -0,0 +1,107 @@ +import type { + SpriteViewerManifest, + SpriteViewerManifestIcon, + SpriteViewerManifestLoader, + SpriteViewerRemoteSource, + SpriteViewerSource, + SpriteViewerSources, +} from './types.js' + +const manifestLoaderCache = new WeakMap>() + +function isRecord(value: unknown): value is Record { + 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 { + 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>) +} + +export function isRemoteSource(source: SpriteViewerSource): source is SpriteViewerRemoteSource { + if (!isRecord(source)) return false + const candidate = source as Record + 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 { + 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 { + 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 +} diff --git a/src/react/sprite-viewer-styles.ts b/src/viewer/styles.ts similarity index 95% rename from src/react/sprite-viewer-styles.ts rename to src/viewer/styles.ts index f51d4af..c00b5cd 100644 --- a/src/react/sprite-viewer-styles.ts +++ b/src/viewer/styles.ts @@ -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); } diff --git a/src/viewer/types.ts b/src/viewer/types.ts new file mode 100644 index 0000000..1c2daae --- /dev/null +++ b/src/viewer/types.ts @@ -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 + +export type SpriteViewerRemoteSource = { + manifestUrl: string + spriteUrl: string +} + +export type SpriteViewerSource = + | SpriteViewerManifest + | SpriteViewerManifestModule + | SpriteViewerManifestLoader + | SpriteViewerRemoteSource + +export type SpriteViewerSources = + | readonly SpriteViewerSource[] + | Readonly> + +export type SpriteViewerElement = HTMLElement & { + sources: SpriteViewerSources + viewerTitle: string + colorTheme: SpriteViewerColorTheme + themeControlled: boolean + showThemeToggle: boolean + manifestUrl?: string + spriteUrl?: string +} diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 0000000..cbcc1fb --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file diff --git a/test/react-viewer-dom.test.mjs b/test/react-viewer-dom.test.mjs index bbd6952..e19b621 100644 --- a/test/react-viewer-dom.test.mjs +++ b/test/react-viewer-dom.test.mjs @@ -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('
', { 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/) }) diff --git a/test/react-viewer.test.mjs b/test/react-viewer.test.mjs index fbbfcb6..d711a7f 100644 --- a/test/react-viewer.test.mjs +++ b/test/react-viewer.test.mjs @@ -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, /^ { @@ -57,7 +52,7 @@ test('SpriteViewer accepts a loader record from import.meta.glob', () => { }, })) - assert.match(markup, /Загрузка спрайтов/) + assert.match(markup, /^ { @@ -66,6 +61,6 @@ test('SpriteViewer supports an externally controlled color theme', () => { colorTheme: 'dark', })) - assert.match(markup, /^]*data-theme="dark"/) - assert.doesNotMatch(markup, /]*aria-label="Переключить тему"/) + assert.match(markup, /^