diff --git a/plugin/README.md b/plugin/README.md index 1f068be..7375ed0 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -140,6 +140,26 @@ svgJar({ // Default: false currentColor: false, + // Attributes copied from the root of each source file to the + // that the runtime renders. See "Root attributes" below. + // Default: the list below. A list given here replaces the default list. + rootAttributes: [ + 'fill', + 'fill-opacity', + 'fill-rule', + 'stroke', + 'stroke-width', + 'stroke-linecap', + 'stroke-linejoin', + 'stroke-miterlimit', + 'stroke-dasharray', + 'stroke-dashoffset', + 'stroke-opacity', + 'clip-rule', + 'opacity', + 'color', + ], + // Sprite sheets to inline into the HTML document rather than emit as // external files. See "Embedded sprites" below. // true: embed all sprites | string[]: embed named sprites | false (default): external files @@ -168,6 +188,28 @@ These can be combined with any import mode: import Icon from './icons/icon.svg?current-color'; ``` +### Root attributes + +Many icon sets put presentation attributes on the root `` and leave them off the children. Heroicons outline icons look like this: + +```svg + + + +``` + +svg-jar does not keep the root `` of the source file. The runtime renders its own ``, and in sprite mode the children move into a ``. So svg-jar copies the attributes in `rootAttributes` from the source root to the rendered ``, and the children inherit them as before. + +Attributes you pass to the component, and CSS rules on the rendered ``, override the copied values. For example, `` or a Tailwind class like `stroke-2` gives a thicker stroke. + +The default list is: + +`fill`, `fill-opacity`, `fill-rule`, `stroke`, `stroke-width`, `stroke-linecap`, `stroke-linejoin`, `stroke-miterlimit`, `stroke-dasharray`, `stroke-dashoffset`, `stroke-opacity`, `clip-rule`, `opacity`, `color` + +A list in `rootAttributes` replaces the default list. It can name any attribute, such as `aria-hidden`. An empty list copies nothing. + +When `currentColor` is on, the root `fill` and `stroke` are replaced in the same way as on the children. + ## Targets The `target` option controls what each SVG import exports. All targets support sprite, inline, file, and named sprite modes. @@ -351,7 +393,7 @@ The `?file` query always exports a `string` URL regardless of target, so no cust ### Build mode 1. **`resolveId`**
Intercepts `.svg` imports and parses query strings -2. **`load`**
Reads the SVG, optimizes with SVGO, parses via [@eksml/xml](https://github.com/evoactivity/eksml), applies transforms (currentColor, strip dimensions), resolves embedded `` and `` references, registers symbols in the sprite registry +2. **`load`**
Reads the SVG, optimizes with SVGO, parses via [@eksml/xml](https://github.com/evoactivity/eksml), applies transforms (currentColor, strip dimensions), reads the root attributes, resolves embedded `` and `` references, registers symbols in the sprite registry 3. **`transform`**
Generates framework-specific component code with a sprite placeholder URL 4. **`renderChunk`**
Replaces placeholders with final sprite URLs, tracks which symbols are actually used (tree-shaking) 5. **`generateBundle`**
Assembles sprite sheets from used symbols only, resolves embedded refs to final asset URLs, emits sprite and file assets diff --git a/plugin/runtime/dom.ts b/plugin/runtime/dom.ts index 619123e..23df4ce 100644 --- a/plugin/runtime/dom.ts +++ b/plugin/runtime/dom.ts @@ -48,6 +48,17 @@ function applyOptions(svg: SVGSVGElement, options: SvgOptions): void { } } +/** + * Sets the root attributes of the source file on the rendered ``. + * Called before `applyOptions` so the caller's attributes win. + */ +function applyRootAttrs(svg: SVGSVGElement, rootAttrs: Record | null): void { + if (!rootAttrs) return; + for (const [key, value] of Object.entries(rootAttrs)) { + svg.setAttribute(key, value); + } +} + /** * Returns a factory function that creates an SVG element referencing a * sprite symbol via ``. Each call to the factory produces a @@ -62,6 +73,7 @@ function applyOptions(svg: SVGSVGElement, options: SvgOptions): void { * @param width The `width` attribute, or `null` to omit. * @param height The `height` attribute, or `null` to omit. * @param href The sprite symbol reference (e.g. `/assets/sprite.svg#abc123`). + * @param rootAttrs Attributes from the root of the source ``, or `null` if none. * @returns A factory function that creates a new SVG DOM element on each call. * * @example @@ -74,6 +86,7 @@ export function createSvg( width: string | null, height: string | null, href: string, + rootAttrs: Record | null = null, ): (options?: SvgOptions) => SVGSVGElement { return (options?: SvgOptions) => { const svg = document.createElementNS(SVG_NS, 'svg'); @@ -81,6 +94,7 @@ export function createSvg( if (viewBox) svg.setAttribute('viewBox', viewBox); if (width) svg.setAttribute('width', width); if (height) svg.setAttribute('height', height); + applyRootAttrs(svg, rootAttrs); const use = document.createElementNS(SVG_NS, 'use'); use.setAttributeNS(XLINK_NS, 'xlink:href', href); @@ -105,6 +119,7 @@ export function createSvg( * @param width The `width` attribute. * @param height The `height` attribute. * @param svgMarkup The inner SVG markup string. + * @param rootAttrs Attributes from the root of the source ``, or `null` if none. * @returns A factory function that creates a new SVG DOM element on each call. * * @example @@ -117,6 +132,7 @@ export function createSvgInline( width: string | null, height: string | null, svgMarkup: string, + rootAttrs: Record | null = null, ): (options?: SvgOptions) => SVGSVGElement { return (options?: SvgOptions) => { const svg = document.createElementNS(SVG_NS, 'svg'); @@ -124,6 +140,7 @@ export function createSvgInline( if (viewBox) svg.setAttribute('viewBox', viewBox); if (width) svg.setAttribute('width', width); if (height) svg.setAttribute('height', height); + applyRootAttrs(svg, rootAttrs); svg.innerHTML = svgMarkup; diff --git a/plugin/runtime/ember.ts b/plugin/runtime/ember.ts index 43b907f..5ba4ea2 100644 --- a/plugin/runtime/ember.ts +++ b/plugin/runtime/ember.ts @@ -1,17 +1,50 @@ // @ts-expect-error @ember/template-compiler is a peer dependency provided by the consuming Ember app import { template } from '@ember/template-compiler'; +// @ts-expect-error @ember/modifier is provided by the consuming Ember app +import { capabilities, setModifierManager } from '@ember/modifier'; + +/** + * Modifier that sets the root attributes of the source file on the + * rendered ``. Used as `{{rootAttrsModifier rootAttrs}}`. + * + * Modifiers run after `...attributes` is applied, so an attribute the + * element already has came from the caller and is left alone. This lets + * the caller's attributes win. + * + * A modifier is used because the Ember build precompiles `template()` + * calls and rejects template strings built at runtime. Ember has no + * default manager for plain function modifiers, so this sets one. + */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call +const rootAttrsModifier: object = /*#__PURE__*/ setModifierManager( + () => ({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call + capabilities: capabilities('3.22'), + createModifier: () => ({}), + installModifier(_state: unknown, el: Element, args: { positional: [Record | null] }) { + for (const [name, value] of Object.entries(args.positional[0] ?? {})) { + if (!el.hasAttribute(name)) el.setAttribute(name, value); + } + }, + updateModifier: () => {}, + destroyModifier: () => {}, + }), + {}, +); /** * Creates an Ember component that renders an SVG sprite symbol via ``. * Used in production builds where SVGs are collected into sprite sheets. * * The template is compiled once with scoped bindings rather than string - * interpolation, so each SVG reuses the same compiled template. + * interpolation, so each SVG reuses the same compiled template. The root + * attributes are set by a modifier that leaves the caller's attributes alone. * * @param viewBox The `viewBox` attribute for the `` element. * @param width The `width` attribute, or `null` to omit. * @param height The `height` attribute, or `null` to omit. * @param symbol The sprite symbol reference (e.g. `/assets/sprite.svg#abc123`). + * @param rootAttrs Attributes from the root of the source ``, or `null` if none. * @returns An Ember template component. */ export const createSvg = /*#__NO_SIDE_EFFECTS__*/ ( @@ -19,11 +52,12 @@ export const createSvg = /*#__NO_SIDE_EFFECTS__*/ ( width: string | null, height: string | null, symbol: string, + rootAttrs: Record | null = null, ): unknown => { // eslint-disable-next-line @typescript-eslint/no-unsafe-call return template( - `{{yield}}`, - { scope: () => ({ viewBox, width, height, symbol }) }, + `{{yield}}`, + { scope: () => ({ viewBox, width, height, symbol, rootAttrs, rootAttrsModifier }) }, ); }; @@ -33,12 +67,14 @@ export const createSvg = /*#__NO_SIDE_EFFECTS__*/ ( * * The SVG markup is injected as raw HTML via triple-curlies `{{{xml}}}`, * wrapped in an `` element that receives `...attributes` for - * splattributes support. + * splattributes support. The root attributes are set by a modifier that + * leaves the caller's attributes alone. * * @param viewBox The `viewBox` attribute for the wrapper ``. * @param width The `width` attribute, or `null` to omit. * @param height The `height` attribute, or `null` to omit. * @param xml The inner SVG markup string (children of the root ``). + * @param rootAttrs Attributes from the root of the source ``, or `null` if none. * @returns An Ember template component. */ export const createSvgInline = /*#__NO_SIDE_EFFECTS__*/ ( @@ -46,9 +82,11 @@ export const createSvgInline = /*#__NO_SIDE_EFFECTS__*/ ( width: string | null, height: string | null, xml: string, + rootAttrs: Record | null = null, ): unknown => { // eslint-disable-next-line @typescript-eslint/no-unsafe-call - return template(`{{yield}}{{{xml}}}`, { - scope: () => ({ viewBox, width, height, xml }), - }); + return template( + `{{yield}}{{{xml}}}`, + { scope: () => ({ viewBox, width, height, xml, rootAttrs, rootAttrsModifier }) }, + ); }; diff --git a/plugin/runtime/preact.ts b/plugin/runtime/preact.ts index 438e130..77eaad6 100644 --- a/plugin/runtime/preact.ts +++ b/plugin/runtime/preact.ts @@ -16,6 +16,7 @@ type SvgProps = JSX.SVGAttributes & { * @param width The `width` attribute, or `null` to omit. * @param height The `height` attribute, or `null` to omit. * @param href The sprite symbol reference (e.g. `/assets/sprite.svg#abc123`). + * @param rootAttrs Attributes from the root of the source ``, or `null` if none. * @returns A Preact function component. */ export const createSvg: ( @@ -23,14 +24,15 @@ export const createSvg: ( width: string | null, height: string | null, href: string, -) => unknown = /*#__NO_SIDE_EFFECTS__*/ (viewBox, width, height, href) => { + rootAttrs?: Record | null, +) => unknown = /*#__NO_SIDE_EFFECTS__*/ (viewBox, width, height, href, rootAttrs) => { const useEl = h('use', { href }); return (props: SvgProps) => { const { children, ...rest } = props; return h( 'svg', - { viewBox: viewBox ?? undefined, width: width ?? undefined, height: height ?? undefined, ...rest }, + { viewBox: viewBox ?? undefined, width: width ?? undefined, height: height ?? undefined, ...rootAttrs, ...rest }, children, useEl, ); @@ -49,6 +51,7 @@ export const createSvg: ( * @param width The `width` attribute, or `null` to omit. * @param height The `height` attribute, or `null` to omit. * @param xml The inner SVG markup string. + * @param rootAttrs Attributes from the root of the source ``, or `null` if none. * @returns A Preact function component. */ export const createSvgInline: ( @@ -56,7 +59,8 @@ export const createSvgInline: ( width: string | null, height: string | null, xml: string, -) => unknown = /*#__NO_SIDE_EFFECTS__*/ (viewBox, width, height, xml) => { + rootAttrs?: Record | null, +) => unknown = /*#__NO_SIDE_EFFECTS__*/ (viewBox, width, height, xml, rootAttrs) => { return (props: SvgProps) => { const { children, ...rest } = props; const setMarkup = useCallback((node: SVGGElement | null) => { @@ -65,7 +69,7 @@ export const createSvgInline: ( return h( 'svg', - { viewBox: viewBox ?? undefined, width: width ?? undefined, height: height ?? undefined, ...rest }, + { viewBox: viewBox ?? undefined, width: width ?? undefined, height: height ?? undefined, ...rootAttrs, ...rest }, children, h('g', { ref: setMarkup }), ); diff --git a/plugin/runtime/react.ts b/plugin/runtime/react.ts index bd478c4..7cca905 100644 --- a/plugin/runtime/react.ts +++ b/plugin/runtime/react.ts @@ -2,6 +2,20 @@ // @ts-expect-error react is a peer dependency provided by the consuming app import { createElement, useCallback } from 'react'; +/** + * Converts attribute names to the camelCase prop names React expects, + * e.g. `stroke-width` to `strokeWidth`. React keeps `aria-*` and `data-*` + * hyphenated. + */ +function toReactProps(rootAttrs: Record | null | undefined): Record { + const props: Record = {}; + for (const [key, value] of Object.entries(rootAttrs ?? {})) { + const name = /^(aria|data)-/.test(key) ? key : key.replace(/[-:]([a-z])/g, (_, c: string) => c.toUpperCase()); + props[name] = value; + } + return props; +} + /** * Creates a React component that renders an SVG sprite symbol via ``. * Used in production builds where SVGs are collected into sprite sheets. @@ -13,6 +27,7 @@ import { createElement, useCallback } from 'react'; * @param width The `width` attribute, or `null` to omit. * @param height The `height` attribute, or `null` to omit. * @param href The sprite symbol reference (e.g. `/assets/sprite.svg#abc123`). + * @param rootAttrs Attributes from the root of the source ``, or `null` if none. * @returns A React function component. */ export const createSvg: ( @@ -20,14 +35,22 @@ export const createSvg: ( width: string | null, height: string | null, href: string, -) => (props: unknown) => Record = /*#__NO_SIDE_EFFECTS__*/ (viewBox, width, height, href) => { + rootAttrs?: Record | null, +) => (props: unknown) => Record = /*#__NO_SIDE_EFFECTS__*/ ( + viewBox, + width, + height, + href, + rootAttrs, +) => { const useEl = createElement('use', { href }); + const rootProps = toReactProps(rootAttrs); return (props: unknown) => { const { children, ...rest } = props as Record; return createElement( 'svg', - { viewBox: viewBox ?? undefined, width: width ?? undefined, height: height ?? undefined, ...rest }, + { viewBox: viewBox ?? undefined, width: width ?? undefined, height: height ?? undefined, ...rootProps, ...rest }, children, useEl, ); @@ -46,6 +69,7 @@ export const createSvg: ( * @param width The `width` attribute, or `null` to omit. * @param height The `height` attribute, or `null` to omit. * @param xml The inner SVG markup string. + * @param rootAttrs Attributes from the root of the source ``, or `null` if none. * @returns A React function component. */ export const createSvgInline: ( @@ -53,7 +77,16 @@ export const createSvgInline: ( width: string | null, height: string | null, xml: string, -) => (props: unknown) => Record = /*#__NO_SIDE_EFFECTS__*/ (viewBox, width, height, xml) => { + rootAttrs?: Record | null, +) => (props: unknown) => Record = /*#__NO_SIDE_EFFECTS__*/ ( + viewBox, + width, + height, + xml, + rootAttrs, +) => { + const rootProps = toReactProps(rootAttrs); + return (props: unknown) => { const { children, ...rest } = props as Record; const setMarkup = useCallback((node: SVGGElement | null) => { @@ -62,7 +95,7 @@ export const createSvgInline: ( return createElement( 'svg', - { viewBox: viewBox ?? undefined, width: width ?? undefined, height: height ?? undefined, ...rest }, + { viewBox: viewBox ?? undefined, width: width ?? undefined, height: height ?? undefined, ...rootProps, ...rest }, children, createElement('g', { ref: setMarkup }), ); diff --git a/plugin/runtime/solid.ts b/plugin/runtime/solid.ts index ee673a2..fdf7b45 100644 --- a/plugin/runtime/solid.ts +++ b/plugin/runtime/solid.ts @@ -3,6 +3,16 @@ import { insert, spread, template } from 'solid-js/web'; type SvgProps = ParentProps>; +/** + * Sets the root attributes of the source file on the rendered ``. + * Called before `spread` so the caller's props win. + */ +function applyRootAttrs(el: SVGSVGElement, rootAttrs: Record | null | undefined): void { + for (const [key, value] of Object.entries(rootAttrs ?? {})) { + el.setAttribute(key, value); + } +} + /** * Creates a Solid component that renders an SVG sprite symbol via ``. * Used in production builds where SVGs are collected into sprite sheets. @@ -14,6 +24,7 @@ type SvgProps = ParentProps>; * @param width The `width` attribute, or `null` to omit. * @param height The `height` attribute, or `null` to omit. * @param href The sprite symbol reference (e.g. `/assets/sprite.svg#abc123`). + * @param rootAttrs Attributes from the root of the source ``, or `null` if none. * @returns A Solid component. */ export const createSvg: ( @@ -21,7 +32,8 @@ export const createSvg: ( width: string | null, height: string | null, href: string, -) => unknown = /*#__NO_SIDE_EFFECTS__*/ (viewBox, width, height, href) => { + rootAttrs?: Record | null, +) => unknown = /*#__NO_SIDE_EFFECTS__*/ (viewBox, width, height, href, rootAttrs) => { const tmpl = template( ``, ); @@ -31,6 +43,7 @@ export const createSvg: ( if (!(el instanceof SVGSVGElement)) { throw new Error('svg-jar: the SVG template did not produce an element'); } + applyRootAttrs(el, rootAttrs); const [local, rest] = splitProps(props, ['children']); spread(el, rest, true, true); @@ -55,6 +68,7 @@ export const createSvg: ( * @param width The `width` attribute, or `null` to omit. * @param height The `height` attribute, or `null` to omit. * @param xml The inner SVG markup string. + * @param rootAttrs Attributes from the root of the source ``, or `null` if none. * @returns A Solid component. */ export const createSvgInline: ( @@ -62,7 +76,8 @@ export const createSvgInline: ( width: string | null, height: string | null, xml: string, -) => unknown = /*#__NO_SIDE_EFFECTS__*/ (viewBox, width, height, xml) => { + rootAttrs?: Record | null, +) => unknown = /*#__NO_SIDE_EFFECTS__*/ (viewBox, width, height, xml, rootAttrs) => { const tmpl = template( ``, ); @@ -72,6 +87,7 @@ export const createSvgInline: ( if (!(el instanceof SVGSVGElement)) { throw new Error('svg-jar: the SVG template did not produce an element'); } + applyRootAttrs(el, rootAttrs); const [local, rest] = splitProps(props, ['children']); spread(el, rest, true, true); diff --git a/plugin/runtime/vue.ts b/plugin/runtime/vue.ts index 3cd8fb0..8fea183 100644 --- a/plugin/runtime/vue.ts +++ b/plugin/runtime/vue.ts @@ -11,6 +11,7 @@ import { h, markRaw, type VNode, type VNodeArrayChildren } from 'vue'; * @param width The `width` attribute, or `null` to omit. * @param height The `height` attribute, or `null` to omit. * @param href The sprite symbol reference (e.g. `/assets/sprite.svg#abc123`). + * @param rootAttrs Attributes from the root of the source ``, or `null` if none. * @returns A Vue component. */ export const createSvg: ( @@ -18,11 +19,12 @@ export const createSvg: ( width: string | null, height: string | null, href: string, -) => unknown = /*#__NO_SIDE_EFFECTS__*/ (viewBox, width, height, href) => { + rootAttrs?: Record | null, +) => unknown = /*#__NO_SIDE_EFFECTS__*/ (viewBox, width, height, href, rootAttrs) => { return markRaw({ inheritAttrs: false, render(this: { $attrs: Record; $slots: { default?: () => VNode[] } }) { - const attrs = { viewBox, width, height, ...this.$attrs }; + const attrs = { viewBox, width, height, ...rootAttrs, ...this.$attrs }; const children: VNodeArrayChildren = [...(this.$slots.default?.() ?? []), h('use', { href })]; return h('svg', attrs, children); }, @@ -41,6 +43,7 @@ export const createSvg: ( * @param width The `width` attribute, or `null` to omit. * @param height The `height` attribute, or `null` to omit. * @param xml The inner SVG markup string. + * @param rootAttrs Attributes from the root of the source ``, or `null` if none. * @returns A Vue component. */ export const createSvgInline: ( @@ -48,11 +51,12 @@ export const createSvgInline: ( width: string | null, height: string | null, xml: string, -) => unknown = /*#__NO_SIDE_EFFECTS__*/ (viewBox, width, height, xml) => { + rootAttrs?: Record | null, +) => unknown = /*#__NO_SIDE_EFFECTS__*/ (viewBox, width, height, xml, rootAttrs) => { return markRaw({ inheritAttrs: false, render(this: { $attrs: Record; $slots: { default?: () => VNode[] } }) { - const attrs = { viewBox, width, height, ...this.$attrs }; + const attrs = { viewBox, width, height, ...rootAttrs, ...this.$attrs }; const children: VNodeArrayChildren = [...(this.$slots.default?.() ?? []), h('g', { innerHTML: xml })]; return h('svg', attrs, children); }, diff --git a/plugin/runtime/web-component.ts b/plugin/runtime/web-component.ts index 57bebe8..bb63455 100644 --- a/plugin/runtime/web-component.ts +++ b/plugin/runtime/web-component.ts @@ -17,6 +17,7 @@ const XLINK_NS = 'http://www.w3.org/1999/xlink'; * @param width The `width` attribute, or `null` to omit. * @param height The `height` attribute, or `null` to omit. * @param href The sprite symbol reference (e.g. `/assets/sprite.svg#abc123`). + * @param rootAttrs Attributes from the root of the source ``, or `null` if none. * @returns A custom element class extending HTMLElement. */ export function createSvg( @@ -24,6 +25,7 @@ export function createSvg( width: string | null, height: string | null, href: string, + rootAttrs: Record | null = null, ): typeof HTMLElement { return class extends HTMLElement { connectedCallback(): void { @@ -33,6 +35,9 @@ export function createSvg( if (viewBox) svg.setAttribute('viewBox', viewBox); if (width) svg.setAttribute('width', width); if (height) svg.setAttribute('height', height); + for (const [key, value] of Object.entries(rootAttrs ?? {})) { + svg.setAttribute(key, value); + } // Move any light DOM children (title, desc) into the SVG while (this.firstChild) { @@ -58,6 +63,7 @@ export function createSvg( * @param width The `width` attribute, or `null` to omit. * @param height The `height` attribute, or `null` to omit. * @param xml The inner SVG markup string. + * @param rootAttrs Attributes from the root of the source ``, or `null` if none. * @returns A custom element class extending HTMLElement. */ export function createSvgInline( @@ -65,6 +71,7 @@ export function createSvgInline( width: string | null, height: string | null, xml: string, + rootAttrs: Record | null = null, ): typeof HTMLElement { return class extends HTMLElement { connectedCallback(): void { @@ -74,6 +81,9 @@ export function createSvgInline( if (viewBox) svg.setAttribute('viewBox', viewBox); if (width) svg.setAttribute('width', width); if (height) svg.setAttribute('height', height); + for (const [key, value] of Object.entries(rootAttrs ?? {})) { + svg.setAttribute(key, value); + } // Move any light DOM children (title, desc) into the SVG while (this.firstChild) { diff --git a/plugin/src/codegen/dev.ts b/plugin/src/codegen/dev.ts index 7b12eaa..1de1eb9 100644 --- a/plugin/src/codegen/dev.ts +++ b/plugin/src/codegen/dev.ts @@ -13,6 +13,7 @@ import { RUNTIME_PATHS } from '../core/constants.ts'; * @param width The `width` attribute value. * @param height The `height` attribute value. * @param svgMarkup The full SVG markup string. + * @param rootAttrs Attributes for the rendered ``, or `null` if none. * * @example * generateDev('dom', '0 0 24 24', '24', '24', '...') @@ -24,12 +25,13 @@ export function generateDev( width: string | null, height: string | null, svgMarkup: string, + rootAttrs: Record | null = null, ): string { const runtimePath = RUNTIME_PATHS[target]; return [ `import { createSvgInline } from '${runtimePath}';`, - `export default /*#__PURE__*/ createSvgInline(${JSON.stringify(viewBox)}, ${JSON.stringify(width)}, ${JSON.stringify(height)}, ${JSON.stringify(svgMarkup)});`, + `export default /*#__PURE__*/ createSvgInline(${JSON.stringify(viewBox)}, ${JSON.stringify(width)}, ${JSON.stringify(height)}, ${JSON.stringify(svgMarkup)}, ${JSON.stringify(rootAttrs)});`, ].join('\n'); } @@ -50,6 +52,7 @@ export function generateDev( * @param symbolMarkup The SVG content as a `` element string. * @param symbolId The symbol ID for the `` reference. * @param refSymbols Markup for referenced `` entries to embed. + * @param rootAttrs Attributes for the rendered ``, or `null` if none. * * @example * generateDevSprite('dom', '0 0 24 24', null, null, '...', 'a', ['...']) @@ -62,6 +65,7 @@ export function generateDevSprite( symbolMarkup: string, symbolId: string, refSymbols: string[], + rootAttrs: Record | null = null, ): string { const runtimePath = RUNTIME_PATHS[target]; @@ -71,6 +75,6 @@ export function generateDevSprite( return [ `import { createSvgInline } from '${runtimePath}';`, - `export default /*#__PURE__*/ createSvgInline(${JSON.stringify(viewBox)}, ${JSON.stringify(width)}, ${JSON.stringify(height)}, ${JSON.stringify(inner)});`, + `export default /*#__PURE__*/ createSvgInline(${JSON.stringify(viewBox)}, ${JSON.stringify(width)}, ${JSON.stringify(height)}, ${JSON.stringify(inner)}, ${JSON.stringify(rootAttrs)});`, ].join('\n'); } diff --git a/plugin/src/codegen/index.ts b/plugin/src/codegen/index.ts index 903c6ac..fe0e3b0 100644 --- a/plugin/src/codegen/index.ts +++ b/plugin/src/codegen/index.ts @@ -24,7 +24,7 @@ export function generateCode(ctx: CodegenContext): string { if (ctx.mode === 'inline') { // Inline mode embeds the full SVG markup in both dev and prod. - return generateDev(ctx.target, ctx.viewBox, ctx.width, ctx.height, ctx.svgMarkup); + return generateDev(ctx.target, ctx.viewBox, ctx.width, ctx.height, ctx.svgMarkup, ctx.rootAttrs); } if (ctx.isDev) { @@ -39,10 +39,11 @@ export function generateCode(ctx: CodegenContext): string { ctx.symbolMarkup, ctx.symbolId, ctx.refSymbols, + ctx.rootAttrs, ); } // Production sprite mode — embed a placeholder that renderChunk resolves, // or a local fragment ref for embedded sprites. - return generateProd(ctx.target, ctx.symbolId, ctx.viewBox, ctx.width, ctx.height, ctx.isEmbedded); + return generateProd(ctx.target, ctx.symbolId, ctx.viewBox, ctx.width, ctx.height, ctx.isEmbedded, ctx.rootAttrs); } diff --git a/plugin/src/codegen/prod.ts b/plugin/src/codegen/prod.ts index 3541b86..3b9921c 100644 --- a/plugin/src/codegen/prod.ts +++ b/plugin/src/codegen/prod.ts @@ -12,11 +12,13 @@ import { RUNTIME_PATHS, makePlaceholder } from '../core/constants.ts'; * is unused, the bundler eliminates it, and the symbol is excluded from * the sprite. * - * @param target Framework target. - * @param symbolId Deterministic hash for the placeholder. - * @param viewBox The `viewBox` attribute value. - * @param width The `width` attribute value. - * @param height The `height` attribute value. + * @param target Framework target. + * @param symbolId Deterministic hash for the placeholder. + * @param viewBox The `viewBox` attribute value. + * @param width The `width` attribute value. + * @param height The `height` attribute value. + * @param isEmbedded Whether the sprite is inlined in the HTML document. + * @param rootAttrs Attributes for the rendered ``, or `null` if none. * * @example * generateProd('dom', 'abc123', '0 0 24 24', '24', '24') @@ -29,6 +31,7 @@ export function generateProd( width: string | null, height: string | null, isEmbedded = false, + rootAttrs: Record | null = null, ): string { const runtimePath = RUNTIME_PATHS[target]; @@ -40,6 +43,6 @@ export function generateProd( return [ `import { createSvg } from '${runtimePath}';`, - `export default /*#__PURE__*/ createSvg(${JSON.stringify(viewBox)}, ${JSON.stringify(width)}, ${JSON.stringify(height)}, ${href});`, + `export default /*#__PURE__*/ createSvg(${JSON.stringify(viewBox)}, ${JSON.stringify(width)}, ${JSON.stringify(height)}, ${href}, ${JSON.stringify(rootAttrs)});`, ].join('\n'); } diff --git a/plugin/src/codegen/types.ts b/plugin/src/codegen/types.ts index 31e72c8..049bc43 100644 --- a/plugin/src/codegen/types.ts +++ b/plugin/src/codegen/types.ts @@ -31,4 +31,6 @@ export interface CodegenContext { symbolMarkup: string; /** Markup for `` entries referenced via `` (for dev sprite mode). */ refSymbols: string[]; + /** Attributes from the root `` to set on the rendered ``, or `null` if none. */ + rootAttrs: Record | null; } diff --git a/plugin/src/core/constants.ts b/plugin/src/core/constants.ts index f1c2946..acf0f20 100644 --- a/plugin/src/core/constants.ts +++ b/plugin/src/core/constants.ts @@ -11,6 +11,27 @@ export const RUNTIME_PATHS = { 'web-component': '@svg-jar/plugin/runtime/web-component', } as const; +/** + * Root `` attributes passed to the rendered `` by default. + * These are presentation attributes that the children of the root inherit. + */ +export const DEFAULT_ROOT_ATTRIBUTES: readonly string[] = [ + 'fill', + 'fill-opacity', + 'fill-rule', + 'stroke', + 'stroke-width', + 'stroke-linecap', + 'stroke-linejoin', + 'stroke-miterlimit', + 'stroke-dasharray', + 'stroke-dashoffset', + 'stroke-opacity', + 'clip-rule', + 'opacity', + 'color', +]; + /** * Placeholder prefix inserted into generated JS during the `transform` hook. * Resolved to final sprite URLs during `renderChunk`. diff --git a/plugin/src/core/options.ts b/plugin/src/core/options.ts index 3f62180..43336e9 100644 --- a/plugin/src/core/options.ts +++ b/plugin/src/core/options.ts @@ -1,4 +1,5 @@ import type { Config as SvgoConfig } from 'svgo'; +import { DEFAULT_ROOT_ATTRIBUTES } from './constants.ts'; /** Supported framework targets for component generation. */ export type SvgJarTarget = 'dom' | 'ember' | 'react' | 'preact' | 'vue' | 'solid' | 'web-component'; @@ -24,6 +25,21 @@ export interface SvgJarOptions { /** Replace non-`none` fill/stroke with `currentColor`. Default: `false`. */ currentColor?: boolean; + /** + * Attributes to copy from the root `` of each source file to the + * `` that the runtime renders. Attributes passed by the caller, + * and CSS rules on the rendered ``, override them. + * + * The default list holds the presentation attributes that children + * inherit, such as `fill`, `stroke` and `stroke-width`. A list given + * here replaces the default list. An empty list copies nothing. + * + * @example + * // Also copy aria-hidden from each source file + * svgJar({ rootAttributes: ['fill', 'stroke', 'stroke-width', 'aria-hidden'] }) + */ + rootAttributes?: string[]; + /** * Sprite sheets to inline directly into the HTML document rather than * emit as external asset files (Vite only, via `transformIndexHtml`). @@ -74,6 +90,7 @@ export interface ResolvedOptions { svgo: boolean | SvgoConfig; defaultSprite: string; currentColor: boolean; + rootAttributes: string[]; embedded: boolean | string[]; /** Normalized base path (trailing slash ensured), or undefined to use the bundler default. */ base: string | undefined; @@ -91,6 +108,7 @@ export function resolveOptions(options: SvgJarOptions = {}): ResolvedOptions { svgo: options.svgo ?? true, defaultSprite: options.defaultSprite ?? 'sprite', currentColor: options.currentColor ?? false, + rootAttributes: options.rootAttributes ?? [...DEFAULT_ROOT_ATTRIBUTES], embedded: options.embedded ?? false, base: options.base == null ? undefined : options.base.endsWith('/') ? options.base : `${options.base}/`, }; diff --git a/plugin/src/core/types.ts b/plugin/src/core/types.ts index e654af5..26a9dd1 100644 --- a/plugin/src/core/types.ts +++ b/plugin/src/core/types.ts @@ -48,6 +48,8 @@ export interface SvgModule { currentColor: boolean | undefined; /** Parsed SVG metadata and lossless entries. */ data: SvgData; + /** Attributes from the root `` to set on the rendered ``, or `null` if none. */ + rootAttrs: Record | null; /** Inner SVG content (children of ``, no root wrapper). Used by codegen. */ svgMarkup: string; /** Full SVG document string (with root ``). Used for file asset emission. */ diff --git a/plugin/src/hooks/load.ts b/plugin/src/hooks/load.ts index f0d0778..ab4bdbd 100644 --- a/plugin/src/hooks/load.ts +++ b/plugin/src/hooks/load.ts @@ -4,7 +4,7 @@ import type { UnpluginBuildContext, UnpluginContext } from 'unplugin'; import type { PluginState } from '../core/state.ts'; import { parseSvgId, isSvgId, parseSvgDtsId } from '../core/query.ts'; import { generateDts } from '../codegen/dts.ts'; -import { parseSvg, hashSvg } from '../svg/parse.ts'; +import { parseSvg, hashSvg, findRootAttrs } from '../svg/parse.ts'; import { optimizeSvg } from '../svg/optimize.ts'; import { applyCurrentColor, stripDimensions, findEmbeddedRefs } from '../svg/transform.ts'; import { createSymbol, serializeSvg, serializeSvgInner } from '../svg/serialize.ts'; @@ -35,7 +35,8 @@ function hasResolve(ctx: unknown): ctx is ResolveContext { * 2. Generates a deterministic symbol ID from the raw content hash. * 3. Optimizes the SVG with SVGO (inlines CSS, strips titles, etc.). * 4. Re-parses the optimized SVG via eksml lossless format. - * 5. Applies transforms (currentColor, strip dimensions). + * 5. Applies transforms (currentColor, strip dimensions) and reads the + * root attributes that the runtime sets on the rendered ``. * 6. Resolves embedded references (``, ``) via * the bundler's `this.resolve()` (Rollup/Vite only). * 7. For sprite mode: creates a `` and registers it in the registry. @@ -80,6 +81,9 @@ export function createLoadHook( // Strip width/height from the root so SVGs scale to their container stripDimensions(data.entries); + // Read after applyCurrentColor so root fill/stroke include its changes + const rootAttrs = findRootAttrs(data.entries, state.options.rootAttributes); + // Resolve embedded references (, ). // Only available under Rollup/Vite where `this.resolve()` exists. const imports: string[] = []; @@ -151,6 +155,7 @@ export function createLoadHook( spriteName, currentColor: parsed.currentColor, data, + rootAttrs, svgMarkup, svgSource, useRefs, diff --git a/plugin/src/hooks/transform.ts b/plugin/src/hooks/transform.ts index 8676f8b..6f9d8c8 100644 --- a/plugin/src/hooks/transform.ts +++ b/plugin/src/hooks/transform.ts @@ -103,6 +103,7 @@ export function createTransformHook( svgMarkup: svgModule.svgMarkup, symbolMarkup, refSymbols, + rootAttrs: svgModule.rootAttrs, }); return withEmptyMap(`${preamble}${generated}`); diff --git a/plugin/src/svg/parse.ts b/plugin/src/svg/parse.ts index 94aa2fc..1274aae 100644 --- a/plugin/src/svg/parse.ts +++ b/plugin/src/svg/parse.ts @@ -64,6 +64,35 @@ function findRootSvgAttrs(entries: LosslessEntry[]): Record` element. + * + * Run this after any transform that changes root attributes (such as + * `applyCurrentColor`) so the result includes those changes. + * + * @param entries Lossless SVG entries. + * @param names Attribute names to read. + * @returns The attributes found, in source order, or `null` if none. + * + * @example + * const data = parseSvg('...'); + * findRootAttrs(data.entries, ['stroke', 'fill']); + * // → { fill: 'none', stroke: 'red' } + */ +export function findRootAttrs(entries: LosslessEntry[], names: readonly string[]): Record | null { + const attrs = findRootSvgAttrs(entries); + if (!attrs) return null; + + const found: Record = {}; + for (const [name, value] of Object.entries(attrs)) { + if (typeof value === 'string' && names.includes(name)) { + found[name] = value; + } + } + + return Object.keys(found).length > 0 ? found : null; +} + /** * Generates a deterministic symbol ID from SVG content by hashing * the source string. Returns an 8-character hex string. diff --git a/plugin/test/_fixtures/entry-root-attrs.js b/plugin/test/_fixtures/entry-root-attrs.js new file mode 100644 index 0000000..7cd3e75 --- /dev/null +++ b/plugin/test/_fixtures/entry-root-attrs.js @@ -0,0 +1,4 @@ +import SpriteIcon from './heroicon-outline.svg'; +import InlineIcon from './heroicon-outline.svg?unsafe-inline'; + +console.log(SpriteIcon, InlineIcon); diff --git a/plugin/test/_fixtures/heroicon-outline.svg b/plugin/test/_fixtures/heroicon-outline.svg new file mode 100644 index 0000000..eafbff4 --- /dev/null +++ b/plugin/test/_fixtures/heroicon-outline.svg @@ -0,0 +1,3 @@ + diff --git a/plugin/test/e2e/ember.spec.ts b/plugin/test/e2e/ember.spec.ts index aa822e5..134acfc 100644 --- a/plugin/test/e2e/ember.spec.ts +++ b/plugin/test/e2e/ember.spec.ts @@ -11,6 +11,9 @@ import { expectNamedSpriteContainsCircle, expectChunkContainsSpriteRefs, expectChunkContainsInlineMarkup, + expectRootAttrs, + expectInlineChildrenInheritRootAttrs, + expectRootAttrOverride, } from './helpers.ts'; const ASSETS = assetsDir('ember'); @@ -40,6 +43,25 @@ test.describe('Ember', () => { await expect(page.locator('svg.exist').first()).toBeAttached(); }); + // -- Root attributes -- + + test('sprite gets the root attributes', async ({ page }) => { + await expectRootAttrs(page, 'svg.hero-sprite'); + }); + + test('inline gets the root attributes', async ({ page }) => { + await expectRootAttrs(page, 'svg.hero-inline'); + await expectInlineChildrenInheritRootAttrs(page, 'svg.hero-inline'); + }); + + test('caller attribute overrides a root attribute on a sprite ', async ({ page }) => { + await expectRootAttrOverride(page, 'svg.hero-sprite-override'); + }); + + test('caller attribute overrides a root attribute on an inline ', async ({ page }) => { + await expectRootAttrOverride(page, 'svg.hero-inline-override'); + }); + // -- Visual regression -- test('all SVGs render correctly', async ({ page }) => { diff --git a/plugin/test/e2e/ember.spec.ts-snapshots/all-svgs-ember-darwin.png b/plugin/test/e2e/ember.spec.ts-snapshots/all-svgs-ember-darwin.png index 0a4d1e2..ca27b96 100644 Binary files a/plugin/test/e2e/ember.spec.ts-snapshots/all-svgs-ember-darwin.png and b/plugin/test/e2e/ember.spec.ts-snapshots/all-svgs-ember-darwin.png differ diff --git a/plugin/test/e2e/ember.spec.ts-snapshots/all-svgs-ember-linux.png b/plugin/test/e2e/ember.spec.ts-snapshots/all-svgs-ember-linux.png index 038c1c1..2c60b03 100644 Binary files a/plugin/test/e2e/ember.spec.ts-snapshots/all-svgs-ember-linux.png and b/plugin/test/e2e/ember.spec.ts-snapshots/all-svgs-ember-linux.png differ diff --git a/plugin/test/e2e/helpers.ts b/plugin/test/e2e/helpers.ts index aa57f0b..c3fdb73 100644 --- a/plugin/test/e2e/helpers.ts +++ b/plugin/test/e2e/helpers.ts @@ -58,6 +58,47 @@ export async function expectChildrenSupport(page: Page, titleText: string): Prom await expect(title.first()).toHaveText(titleText); } +/** + * Checks that the root attributes from chevron-up.svg (a Heroicons outline + * icon) are set on the rendered `` and take effect. + * + * @param svgSelector Selector for the rendered `` element. + */ +export async function expectRootAttrs(page: Page, svgSelector: string): Promise { + const svg = page.locator(svgSelector).first(); + await expect(svg).toHaveAttribute('fill', 'none'); + await expect(svg).toHaveAttribute('stroke', 'currentColor'); + await expect(svg).toHaveAttribute('stroke-width', '1.5'); + await expect(svg).toHaveCSS('fill', 'none'); + await expect(svg).toHaveCSS('stroke-width', '1.5px'); +} + +/** + * Checks that a `stroke-width` of 3 passed by the caller wins over the + * `stroke-width` of 1.5 from the root of chevron-up.svg. + * + * @param svgSelector Selector for the rendered `` element. + */ +export async function expectRootAttrOverride(page: Page, svgSelector: string): Promise { + const svg = page.locator(svgSelector).first(); + await expect(svg).toHaveAttribute('stroke-width', '3'); + await expect(svg).toHaveCSS('stroke-width', '3px'); + await expect(svg).toHaveAttribute('fill', 'none'); +} + +/** + * Checks that the children of an inline `` inherit the root attributes. + * Sprite icons render their children in a `` shadow tree, which + * Playwright cannot query, so this only applies to inline icons. + * + * @param svgSelector Selector for the rendered `` element. + */ +export async function expectInlineChildrenInheritRootAttrs(page: Page, svgSelector: string): Promise { + const path = page.locator(`${svgSelector} path`).first(); + await expect(path).toHaveCSS('fill', 'none'); + await expect(path).toHaveCSS('stroke-width', '1.5px'); +} + // -- Build output assertions -- export function expectDefaultSprite(assets: string): void { diff --git a/plugin/test/e2e/preact.spec.ts b/plugin/test/e2e/preact.spec.ts index 01a5a63..7286ffb 100644 --- a/plugin/test/e2e/preact.spec.ts +++ b/plugin/test/e2e/preact.spec.ts @@ -13,6 +13,9 @@ import { expectChunkContainsSpriteRefs, expectChunkContainsInlineMarkup, expectChunkContainsChildren, + expectRootAttrs, + expectInlineChildrenInheritRootAttrs, + expectRootAttrOverride, } from './helpers.ts'; const ASSETS = assetsDir('preact'); @@ -46,6 +49,25 @@ test.describe('Preact', () => { await expect(page.locator('svg.icon').first()).toBeAttached(); }); + // -- Root attributes -- + + test('sprite gets the root attributes', async ({ page }) => { + await expectRootAttrs(page, 'svg.hero-sprite'); + }); + + test('inline gets the root attributes', async ({ page }) => { + await expectRootAttrs(page, 'svg.hero-inline'); + await expectInlineChildrenInheritRootAttrs(page, 'svg.hero-inline'); + }); + + test('caller attribute overrides a root attribute on a sprite ', async ({ page }) => { + await expectRootAttrOverride(page, 'svg.hero-sprite-override'); + }); + + test('caller attribute overrides a root attribute on an inline ', async ({ page }) => { + await expectRootAttrOverride(page, 'svg.hero-inline-override'); + }); + // -- Visual regression -- test('all SVGs render correctly', async ({ page }) => { diff --git a/plugin/test/e2e/preact.spec.ts-snapshots/all-svgs-preact-darwin.png b/plugin/test/e2e/preact.spec.ts-snapshots/all-svgs-preact-darwin.png index dbe4952..bdadbe8 100644 Binary files a/plugin/test/e2e/preact.spec.ts-snapshots/all-svgs-preact-darwin.png and b/plugin/test/e2e/preact.spec.ts-snapshots/all-svgs-preact-darwin.png differ diff --git a/plugin/test/e2e/preact.spec.ts-snapshots/all-svgs-preact-linux.png b/plugin/test/e2e/preact.spec.ts-snapshots/all-svgs-preact-linux.png index 7df5ec1..8fe77b1 100644 Binary files a/plugin/test/e2e/preact.spec.ts-snapshots/all-svgs-preact-linux.png and b/plugin/test/e2e/preact.spec.ts-snapshots/all-svgs-preact-linux.png differ diff --git a/plugin/test/e2e/react.spec.ts b/plugin/test/e2e/react.spec.ts index 0269224..56dea02 100644 --- a/plugin/test/e2e/react.spec.ts +++ b/plugin/test/e2e/react.spec.ts @@ -13,6 +13,9 @@ import { expectChunkContainsSpriteRefs, expectChunkContainsInlineMarkup, expectChunkContainsChildren, + expectRootAttrs, + expectInlineChildrenInheritRootAttrs, + expectRootAttrOverride, } from './helpers.ts'; const ASSETS = assetsDir('react'); @@ -46,6 +49,25 @@ test.describe('React', () => { await expect(page.locator('svg.icon').first()).toBeAttached(); }); + // -- Root attributes -- + + test('sprite gets the root attributes', async ({ page }) => { + await expectRootAttrs(page, 'svg.hero-sprite'); + }); + + test('inline gets the root attributes', async ({ page }) => { + await expectRootAttrs(page, 'svg.hero-inline'); + await expectInlineChildrenInheritRootAttrs(page, 'svg.hero-inline'); + }); + + test('caller attribute overrides a root attribute on a sprite ', async ({ page }) => { + await expectRootAttrOverride(page, 'svg.hero-sprite-override'); + }); + + test('caller attribute overrides a root attribute on an inline ', async ({ page }) => { + await expectRootAttrOverride(page, 'svg.hero-inline-override'); + }); + // -- Visual regression -- test('all SVGs render correctly', async ({ page }) => { diff --git a/plugin/test/e2e/react.spec.ts-snapshots/all-svgs-react-darwin.png b/plugin/test/e2e/react.spec.ts-snapshots/all-svgs-react-darwin.png index 6db10af..5da74ec 100644 Binary files a/plugin/test/e2e/react.spec.ts-snapshots/all-svgs-react-darwin.png and b/plugin/test/e2e/react.spec.ts-snapshots/all-svgs-react-darwin.png differ diff --git a/plugin/test/e2e/react.spec.ts-snapshots/all-svgs-react-linux.png b/plugin/test/e2e/react.spec.ts-snapshots/all-svgs-react-linux.png index 9d76331..0b6ac94 100644 Binary files a/plugin/test/e2e/react.spec.ts-snapshots/all-svgs-react-linux.png and b/plugin/test/e2e/react.spec.ts-snapshots/all-svgs-react-linux.png differ diff --git a/plugin/test/e2e/rolldown-vanilla.spec.ts-snapshots/all-svgs-rolldown-vanilla-darwin.png b/plugin/test/e2e/rolldown-vanilla.spec.ts-snapshots/all-svgs-rolldown-vanilla-darwin.png new file mode 100644 index 0000000..0ce481c Binary files /dev/null and b/plugin/test/e2e/rolldown-vanilla.spec.ts-snapshots/all-svgs-rolldown-vanilla-darwin.png differ diff --git a/plugin/test/e2e/solid.spec.ts b/plugin/test/e2e/solid.spec.ts index 940efda..cb93abf 100644 --- a/plugin/test/e2e/solid.spec.ts +++ b/plugin/test/e2e/solid.spec.ts @@ -13,6 +13,9 @@ import { expectChunkContainsSpriteRefs, expectChunkContainsInlineMarkup, expectChunkContainsChildren, + expectRootAttrs, + expectInlineChildrenInheritRootAttrs, + expectRootAttrOverride, } from './helpers.ts'; const ASSETS = assetsDir('solid'); @@ -46,6 +49,25 @@ test.describe('Solid', () => { await expect(page.locator('svg.icon').first()).toBeAttached(); }); + // -- Root attributes -- + + test('sprite gets the root attributes', async ({ page }) => { + await expectRootAttrs(page, 'svg.hero-sprite'); + }); + + test('inline gets the root attributes', async ({ page }) => { + await expectRootAttrs(page, 'svg.hero-inline'); + await expectInlineChildrenInheritRootAttrs(page, 'svg.hero-inline'); + }); + + test('caller attribute overrides a root attribute on a sprite ', async ({ page }) => { + await expectRootAttrOverride(page, 'svg.hero-sprite-override'); + }); + + test('caller attribute overrides a root attribute on an inline ', async ({ page }) => { + await expectRootAttrOverride(page, 'svg.hero-inline-override'); + }); + // -- Visual regression -- test('all SVGs render correctly', async ({ page }) => { diff --git a/plugin/test/e2e/solid.spec.ts-snapshots/all-svgs-solid-darwin.png b/plugin/test/e2e/solid.spec.ts-snapshots/all-svgs-solid-darwin.png index 3b88387..ca7da14 100644 Binary files a/plugin/test/e2e/solid.spec.ts-snapshots/all-svgs-solid-darwin.png and b/plugin/test/e2e/solid.spec.ts-snapshots/all-svgs-solid-darwin.png differ diff --git a/plugin/test/e2e/solid.spec.ts-snapshots/all-svgs-solid-linux.png b/plugin/test/e2e/solid.spec.ts-snapshots/all-svgs-solid-linux.png index c0a19cd..093554e 100644 Binary files a/plugin/test/e2e/solid.spec.ts-snapshots/all-svgs-solid-linux.png and b/plugin/test/e2e/solid.spec.ts-snapshots/all-svgs-solid-linux.png differ diff --git a/plugin/test/e2e/vanilla.spec.ts b/plugin/test/e2e/vanilla.spec.ts index 940a74d..46bddd2 100644 --- a/plugin/test/e2e/vanilla.spec.ts +++ b/plugin/test/e2e/vanilla.spec.ts @@ -15,6 +15,9 @@ import { expectNamedSpriteContainsCircle, expectChunkContainsSpriteRefs, expectChunkContainsInlineMarkup, + expectRootAttrs, + expectInlineChildrenInheritRootAttrs, + expectRootAttrOverride, } from './helpers.ts'; function readDistFile(projectName: string, fileName: string): string { @@ -82,6 +85,29 @@ test.describe('Vanilla DOM', () => { expect(src).toMatch(/\.svg/); }); + // -- Root attributes -- + + test('sprite gets the root attributes', async ({ page }) => { + await expectRootAttrs(page, 'svg.hero-sprite'); + }); + + test('inline gets the root attributes', async ({ page }) => { + await expectRootAttrs(page, 'svg.hero-inline'); + await expectInlineChildrenInheritRootAttrs(page, 'svg.hero-inline'); + }); + + test('caller attribute overrides a root attribute on a sprite ', async ({ page }) => { + await expectRootAttrOverride(page, 'svg.hero-sprite-override'); + }); + + test('caller attribute overrides a root attribute on an inline ', async ({ page }) => { + await expectRootAttrOverride(page, 'svg.hero-inline-override'); + }); + + test('CSS rule overrides a root attribute on a sprite ', async ({ page }) => { + await expect(page.locator('svg.hero-css-override')).toHaveCSS('stroke-width', '3px'); + }); + // -- Visual regression -- test('source order SVG renders with correct z-layering', async ({ page }) => { diff --git a/plugin/test/e2e/vanilla.spec.ts-snapshots/all-svgs-vanilla-darwin.png b/plugin/test/e2e/vanilla.spec.ts-snapshots/all-svgs-vanilla-darwin.png index c7ebe13..6536dba 100644 Binary files a/plugin/test/e2e/vanilla.spec.ts-snapshots/all-svgs-vanilla-darwin.png and b/plugin/test/e2e/vanilla.spec.ts-snapshots/all-svgs-vanilla-darwin.png differ diff --git a/plugin/test/e2e/vanilla.spec.ts-snapshots/all-svgs-vanilla-linux.png b/plugin/test/e2e/vanilla.spec.ts-snapshots/all-svgs-vanilla-linux.png index 5ecae06..d4a087e 100644 Binary files a/plugin/test/e2e/vanilla.spec.ts-snapshots/all-svgs-vanilla-linux.png and b/plugin/test/e2e/vanilla.spec.ts-snapshots/all-svgs-vanilla-linux.png differ diff --git a/plugin/test/e2e/vanilla.spec.ts-snapshots/source-order-vanilla-darwin.png b/plugin/test/e2e/vanilla.spec.ts-snapshots/source-order-vanilla-darwin.png index f1fcb97..827cb05 100644 Binary files a/plugin/test/e2e/vanilla.spec.ts-snapshots/source-order-vanilla-darwin.png and b/plugin/test/e2e/vanilla.spec.ts-snapshots/source-order-vanilla-darwin.png differ diff --git a/plugin/test/e2e/vanilla.spec.ts-snapshots/source-order-vanilla-linux.png b/plugin/test/e2e/vanilla.spec.ts-snapshots/source-order-vanilla-linux.png index aa65cc8..5eac9bb 100644 Binary files a/plugin/test/e2e/vanilla.spec.ts-snapshots/source-order-vanilla-linux.png and b/plugin/test/e2e/vanilla.spec.ts-snapshots/source-order-vanilla-linux.png differ diff --git a/plugin/test/e2e/vue.spec.ts b/plugin/test/e2e/vue.spec.ts index 83d0fac..fe7663a 100644 --- a/plugin/test/e2e/vue.spec.ts +++ b/plugin/test/e2e/vue.spec.ts @@ -13,6 +13,9 @@ import { expectChunkContainsSpriteRefs, expectChunkContainsInlineMarkup, expectChunkContainsChildren, + expectRootAttrs, + expectInlineChildrenInheritRootAttrs, + expectRootAttrOverride, } from './helpers.ts'; const ASSETS = assetsDir('vue'); @@ -46,6 +49,25 @@ test.describe('Vue', () => { await expect(page.locator('svg.icon').first()).toBeAttached(); }); + // -- Root attributes -- + + test('sprite gets the root attributes', async ({ page }) => { + await expectRootAttrs(page, 'svg.hero-sprite'); + }); + + test('inline gets the root attributes', async ({ page }) => { + await expectRootAttrs(page, 'svg.hero-inline'); + await expectInlineChildrenInheritRootAttrs(page, 'svg.hero-inline'); + }); + + test('caller attribute overrides a root attribute on a sprite ', async ({ page }) => { + await expectRootAttrOverride(page, 'svg.hero-sprite-override'); + }); + + test('caller attribute overrides a root attribute on an inline ', async ({ page }) => { + await expectRootAttrOverride(page, 'svg.hero-inline-override'); + }); + // -- Visual regression -- test('all SVGs render correctly', async ({ page }) => { diff --git a/plugin/test/e2e/vue.spec.ts-snapshots/all-svgs-vue-darwin.png b/plugin/test/e2e/vue.spec.ts-snapshots/all-svgs-vue-darwin.png index 728ed98..99d762d 100644 Binary files a/plugin/test/e2e/vue.spec.ts-snapshots/all-svgs-vue-darwin.png and b/plugin/test/e2e/vue.spec.ts-snapshots/all-svgs-vue-darwin.png differ diff --git a/plugin/test/e2e/vue.spec.ts-snapshots/all-svgs-vue-linux.png b/plugin/test/e2e/vue.spec.ts-snapshots/all-svgs-vue-linux.png index 4b06ac0..26b52eb 100644 Binary files a/plugin/test/e2e/vue.spec.ts-snapshots/all-svgs-vue-linux.png and b/plugin/test/e2e/vue.spec.ts-snapshots/all-svgs-vue-linux.png differ diff --git a/plugin/test/e2e/web-component.spec.ts b/plugin/test/e2e/web-component.spec.ts index 78dcf95..6a4f67f 100644 --- a/plugin/test/e2e/web-component.spec.ts +++ b/plugin/test/e2e/web-component.spec.ts @@ -10,6 +10,8 @@ import { expectNamedSpriteContainsCircle, expectChunkContainsSpriteRefs, expectChunkContainsInlineMarkup, + expectRootAttrs, + expectInlineChildrenInheritRootAttrs, } from './helpers.ts'; const ASSETS = assetsDir('web-component'); @@ -46,6 +48,25 @@ test.describe('Web Component', () => { await expect(title.first()).toHaveText('Forward arrow'); }); + // -- Root attributes -- + + test('sprite gets the root attributes', async ({ page }) => { + await expectRootAttrs(page, 'svg-chevron-up.hero-sprite svg'); + }); + + test('inline gets the root attributes', async ({ page }) => { + await expectRootAttrs(page, 'svg-chevron-up-inline.hero-inline svg'); + await expectInlineChildrenInheritRootAttrs(page, 'svg-chevron-up-inline.hero-inline svg'); + }); + + test('CSS rule overrides a root attribute on a sprite ', async ({ page }) => { + await expect(page.locator('svg-chevron-up.hero-css-override svg')).toHaveCSS('stroke-width', '3px'); + }); + + test('CSS rule overrides a root attribute on an inline ', async ({ page }) => { + await expect(page.locator('svg-chevron-up-inline.hero-css-override svg')).toHaveCSS('stroke-width', '3px'); + }); + // -- Visual regression -- test('all SVGs render correctly', async ({ page }) => { diff --git a/plugin/test/e2e/web-component.spec.ts-snapshots/all-svgs-web-component-darwin.png b/plugin/test/e2e/web-component.spec.ts-snapshots/all-svgs-web-component-darwin.png index 74cf48e..8a77816 100644 Binary files a/plugin/test/e2e/web-component.spec.ts-snapshots/all-svgs-web-component-darwin.png and b/plugin/test/e2e/web-component.spec.ts-snapshots/all-svgs-web-component-darwin.png differ diff --git a/plugin/test/e2e/web-component.spec.ts-snapshots/all-svgs-web-component-linux.png b/plugin/test/e2e/web-component.spec.ts-snapshots/all-svgs-web-component-linux.png index 1e5532b..0bed0d5 100644 Binary files a/plugin/test/e2e/web-component.spec.ts-snapshots/all-svgs-web-component-linux.png and b/plugin/test/e2e/web-component.spec.ts-snapshots/all-svgs-web-component-linux.png differ diff --git a/plugin/test/integration/rollup.test.ts b/plugin/test/integration/rollup.test.ts index 50d43d1..ecb99e3 100644 --- a/plugin/test/integration/rollup.test.ts +++ b/plugin/test/integration/rollup.test.ts @@ -214,6 +214,66 @@ describe('Rollup integration', () => { }); }); + describe('root attributes', () => { + const ENTRY_ROOT_ATTRS = path.join(FIXTURES_DIR, 'entry-root-attrs.js'); + const HEROICON_ATTRS = { fill: 'none', 'stroke-width': '1.5', stroke: 'currentColor' }; + + function chunkCode(result: RollupOutput): string { + const chunk = result.output.find((o) => o.type === 'chunk'); + if (chunk?.type !== 'chunk') throw new Error('No chunk in output'); + return chunk.code; + } + + /** + * Returns the root attributes object passed as the last argument of the + * first call to `fn`. SVGO sorts attributes, so tests compare objects + * rather than strings. + */ + function rootAttrsArg(code: string, fn: string): unknown { + const line = code.split('\n').find((l) => l.includes(`${fn}(`)); + const match = line === undefined ? null : /, (\{[^}]*\}|null)\);$/.exec(line); + if (!match) throw new Error(`No ${fn} call with a root attributes argument`); + return JSON.parse(match[1]); + } + + it('passes the root attributes to createSvg for sprite imports', async () => { + const code = chunkCode(await build({ target: 'dom' }, ENTRY_ROOT_ATTRS)); + + expect(rootAttrsArg(code, 'createSvg')).toEqual(HEROICON_ATTRS); + }); + + it('passes the root attributes to createSvgInline for inline imports', async () => { + const code = chunkCode(await build({ target: 'dom' }, ENTRY_ROOT_ATTRS)); + + expect(rootAttrsArg(code, 'createSvgInline')).toEqual(HEROICON_ATTRS); + }); + + it('leaves the sprite symbol without the root attributes', async () => { + const result = await build({ target: 'dom' }, ENTRY_ROOT_ATTRS); + const sprite = result.output.find( + (o) => o.type === 'asset' && o.fileName.startsWith('assets/sprite-') && o.fileName.endsWith('.svg'), + ); + if (sprite?.type !== 'asset' || typeof sprite.source !== 'string') throw new Error('No sprite asset'); + + expect(sprite.source).toMatch(//); + }); + + it('uses the rootAttributes option in place of the default list', async () => { + const code = chunkCode(await build({ target: 'dom', rootAttributes: ['stroke-width'] }, ENTRY_ROOT_ATTRS)); + + expect(rootAttrsArg(code, 'createSvg')).toEqual({ 'stroke-width': '1.5' }); + expect(rootAttrsArg(code, 'createSvgInline')).toEqual({ 'stroke-width': '1.5' }); + }); + + it('passes aria-hidden and data-slot when the user list names them', async () => { + const code = chunkCode( + await build({ target: 'dom', rootAttributes: ['aria-hidden', 'data-slot'] }, ENTRY_ROOT_ATTRS), + ); + + expect(rootAttrsArg(code, 'createSvg')).toEqual({ 'aria-hidden': 'true', 'data-slot': 'icon' }); + }); + }); + describe('currentColor', () => { it('applies currentColor when enabled globally', async () => { const result = await build({ target: 'dom', currentColor: true }); diff --git a/plugin/test/unit/codegen.test.ts b/plugin/test/unit/codegen.test.ts index 1c043c7..bcf729f 100644 --- a/plugin/test/unit/codegen.test.ts +++ b/plugin/test/unit/codegen.test.ts @@ -15,8 +15,12 @@ const BASE_CTX: CodegenContext = { svgMarkup: '', symbolMarkup: '', refSymbols: [], + rootAttrs: null, }; +const ROOT_ATTRS = { fill: 'none', 'stroke-width': '1.5', stroke: 'currentColor' }; +const ROOT_ATTRS_JSON = JSON.stringify(ROOT_ATTRS); + function ctx(overrides: Partial): CodegenContext { return { ...BASE_CTX, ...overrides }; } @@ -62,6 +66,39 @@ describe('generateCode', () => { }); }); + describe('root presentation attributes', () => { + it('passes them as the last createSvg argument in production sprite mode', () => { + const code = generateCode(ctx({ rootAttrs: ROOT_ATTRS })); + const placeholder = makePlaceholder('abc12345'); + + expect(code).toContain(`"${placeholder}", ${ROOT_ATTRS_JSON});`); + }); + + it('passes them for embedded sprites', () => { + const code = generateCode(ctx({ isEmbedded: true, rootAttrs: ROOT_ATTRS })); + + expect(code).toContain(`"#abc12345", ${ROOT_ATTRS_JSON});`); + }); + + it('passes them as the last createSvgInline argument in dev sprite mode', () => { + const code = generateCode(ctx({ isDev: true, rootAttrs: ROOT_ATTRS })); + + expect(code).toContain(`", ${ROOT_ATTRS_JSON});`); + }); + + it('passes them as the last createSvgInline argument in inline mode', () => { + const code = generateCode(ctx({ mode: 'inline', rootAttrs: ROOT_ATTRS })); + + expect(code).toContain(`", ${ROOT_ATTRS_JSON});`); + }); + + it('passes null when there are none', () => { + expect(generateCode(ctx({}))).toMatch(/createSvg\(.*, null\);$/m); + expect(generateCode(ctx({ isDev: true }))).toMatch(/createSvgInline\(.*, null\);$/m); + expect(generateCode(ctx({ mode: 'inline' }))).toMatch(/createSvgInline\(.*, null\);$/m); + }); + }); + describe('dev sprite mode', () => { it('uses createSvgInline for the runtime', () => { const code = generateCode(ctx({ isDev: true })); diff --git a/plugin/test/unit/options.test.ts b/plugin/test/unit/options.test.ts index 6b8d098..3a3f0b1 100644 --- a/plugin/test/unit/options.test.ts +++ b/plugin/test/unit/options.test.ts @@ -32,6 +32,38 @@ describe('resolveOptions', () => { expect(resolved.svgo).toBe(svgoConfig); }); + describe('rootAttributes', () => { + it('defaults to a list of inheritable presentation attributes', () => { + const resolved = resolveOptions(); + + expect(resolved.rootAttributes).toEqual( + expect.arrayContaining([ + 'fill', + 'fill-rule', + 'stroke', + 'stroke-width', + 'stroke-linecap', + 'stroke-linejoin', + 'clip-rule', + 'opacity', + 'color', + ]), + ); + }); + + it('replaces the default list with the user list', () => { + const resolved = resolveOptions({ rootAttributes: ['stroke-width'] }); + + expect(resolved.rootAttributes).toEqual(['stroke-width']); + }); + + it('accepts an empty list', () => { + const resolved = resolveOptions({ rootAttributes: [] }); + + expect(resolved.rootAttributes).toEqual([]); + }); + }); + describe('base', () => { it('defaults to undefined (bundler decides)', () => { expect(resolveOptions({}).base).toBeUndefined(); diff --git a/plugin/test/unit/parse.test.ts b/plugin/test/unit/parse.test.ts index 4113df8..3792bc8 100644 --- a/plugin/test/unit/parse.test.ts +++ b/plugin/test/unit/parse.test.ts @@ -1,7 +1,8 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; import { describe, it, expect } from 'vitest'; -import { parseSvg, hashSvg } from '../../src/svg/parse.ts'; +import { parseSvg, hashSvg, findRootAttrs } from '../../src/svg/parse.ts'; +import { applyCurrentColor } from '../../src/svg/transform.ts'; const FIXTURES_DIR = path.join(import.meta.dirname, '..', '_fixtures'); @@ -67,6 +68,72 @@ describe('parseSvg', () => { }); }); +describe('findRootAttrs', () => { + const NAMES = ['fill', 'stroke', 'stroke-width', 'stroke-linecap', 'opacity']; + + it('returns the listed attributes found on the root ', () => { + const data = parseSvg(readFixture('heroicon-outline.svg')); + + expect(findRootAttrs(data.entries, NAMES)).toEqual({ + fill: 'none', + 'stroke-width': '1.5', + stroke: 'currentColor', + }); + }); + + it('leaves out attributes that are not in the list', () => { + const data = parseSvg(readFixture('heroicon-outline.svg')); + const attrs = findRootAttrs(data.entries, NAMES); + + expect(attrs).not.toHaveProperty('xmlns'); + expect(attrs).not.toHaveProperty('viewBox'); + expect(attrs).not.toHaveProperty('aria-hidden'); + expect(attrs).not.toHaveProperty('data-slot'); + }); + + it('ignores listed attributes that only appear on child elements', () => { + const data = parseSvg(readFixture('heroicon-outline.svg')); + const attrs = findRootAttrs(data.entries, NAMES); + + // stroke-linecap is set on the , not the root + expect(attrs).not.toHaveProperty('stroke-linecap'); + }); + + it('returns only the attributes in a custom list', () => { + const data = parseSvg(readFixture('heroicon-outline.svg')); + + expect(findRootAttrs(data.entries, ['stroke-width'])).toEqual({ 'stroke-width': '1.5' }); + }); + + it('returns attributes that are not presentation attributes when the list names them', () => { + const data = parseSvg(readFixture('heroicon-outline.svg')); + + expect(findRootAttrs(data.entries, ['aria-hidden', 'data-slot'])).toEqual({ + 'aria-hidden': 'true', + 'data-slot': 'icon', + }); + }); + + it('returns null when the list is empty', () => { + const data = parseSvg(readFixture('heroicon-outline.svg')); + + expect(findRootAttrs(data.entries, [])).toBeNull(); + }); + + it('returns null when the root has none of the listed attributes', () => { + const data = parseSvg(readFixture('simple.svg')); + + expect(findRootAttrs(data.entries, NAMES)).toBeNull(); + }); + + it('reflects the currentColor replacement when run after applyCurrentColor', () => { + const data = parseSvg(''); + applyCurrentColor(data.entries); + + expect(findRootAttrs(data.entries, NAMES)).toEqual({ fill: 'none', stroke: 'currentColor' }); + }); +}); + describe('hashSvg', () => { it('returns an 8-character hex string', () => { const hash = hashSvg(''); diff --git a/test-projects/vite/ember/app/icons/chevron-up.svg b/test-projects/vite/ember/app/icons/chevron-up.svg new file mode 100644 index 0000000..eafbff4 --- /dev/null +++ b/test-projects/vite/ember/app/icons/chevron-up.svg @@ -0,0 +1,3 @@ + diff --git a/test-projects/vite/ember/app/templates/application.gjs b/test-projects/vite/ember/app/templates/application.gjs index 0a8b7f9..392eb8e 100644 --- a/test-projects/vite/ember/app/templates/application.gjs +++ b/test-projects/vite/ember/app/templates/application.gjs @@ -7,6 +7,10 @@ import Circle from '../icons/circle.svg?sprite=shapes'; // Inline mode import Square from '../icons/square.svg?unsafe-inline'; +// Root attributes - fill, stroke and stroke-width set only on the root +import ChevronUp from '../icons/chevron-up.svg'; +import ChevronUpInline from '../icons/chevron-up.svg?unsafe-inline'; +

SVG Jar Ember Test

@@ -25,5 +29,12 @@ import Square from '../icons/square.svg?unsafe-inline'; +
+ + + + +
+ {{outlet}} diff --git a/test-projects/vite/preact/src/app.tsx b/test-projects/vite/preact/src/app.tsx index 19ac9d6..4101743 100644 --- a/test-projects/vite/preact/src/app.tsx +++ b/test-projects/vite/preact/src/app.tsx @@ -7,6 +7,10 @@ import Circle from './icons/circle.svg?sprite=shapes'; // Inline mode import Square from './icons/square.svg?unsafe-inline'; +// Root attributes - fill, stroke and stroke-width set only on the root +import ChevronUp from './icons/chevron-up.svg'; +import ChevronUpInline from './icons/chevron-up.svg?unsafe-inline'; + export function App() { return (
@@ -34,6 +38,14 @@ export function App() {

Inline mode

+ +
+

Root attributes

+ + + + +
); } diff --git a/test-projects/vite/preact/src/icons/chevron-up.svg b/test-projects/vite/preact/src/icons/chevron-up.svg new file mode 100644 index 0000000..eafbff4 --- /dev/null +++ b/test-projects/vite/preact/src/icons/chevron-up.svg @@ -0,0 +1,3 @@ + diff --git a/test-projects/vite/react/src/App.tsx b/test-projects/vite/react/src/App.tsx index 2fa2a60..dc35315 100644 --- a/test-projects/vite/react/src/App.tsx +++ b/test-projects/vite/react/src/App.tsx @@ -7,6 +7,10 @@ import Circle from './icons/circle.svg?sprite=shapes'; // Inline mode import Square from './icons/square.svg?unsafe-inline'; +// Root attributes - fill, stroke and stroke-width set only on the root +import ChevronUp from './icons/chevron-up.svg'; +import ChevronUpInline from './icons/chevron-up.svg?unsafe-inline'; + function App() { return (
@@ -34,6 +38,14 @@ function App() {

Inline mode

+ +
+

Root attributes

+ + + + +
); } diff --git a/test-projects/vite/react/src/icons/chevron-up.svg b/test-projects/vite/react/src/icons/chevron-up.svg new file mode 100644 index 0000000..eafbff4 --- /dev/null +++ b/test-projects/vite/react/src/icons/chevron-up.svg @@ -0,0 +1,3 @@ + diff --git a/test-projects/vite/solid/src/App.tsx b/test-projects/vite/solid/src/App.tsx index 931deba..cfbe98f 100644 --- a/test-projects/vite/solid/src/App.tsx +++ b/test-projects/vite/solid/src/App.tsx @@ -7,6 +7,10 @@ import Circle from './icons/circle.svg?sprite=shapes'; // Inline mode import Square from './icons/square.svg?unsafe-inline'; +// Root attributes - fill, stroke and stroke-width set only on the root +import ChevronUp from './icons/chevron-up.svg'; +import ChevronUpInline from './icons/chevron-up.svg?unsafe-inline'; + function App() { return (
@@ -34,6 +38,14 @@ function App() {

Inline mode

+ +
+

Root attributes

+ + + + +
); } diff --git a/test-projects/vite/solid/src/icons/chevron-up.svg b/test-projects/vite/solid/src/icons/chevron-up.svg new file mode 100644 index 0000000..eafbff4 --- /dev/null +++ b/test-projects/vite/solid/src/icons/chevron-up.svg @@ -0,0 +1,3 @@ + diff --git a/test-projects/vite/vanilla/src/icons/chevron-up.svg b/test-projects/vite/vanilla/src/icons/chevron-up.svg new file mode 100644 index 0000000..eafbff4 --- /dev/null +++ b/test-projects/vite/vanilla/src/icons/chevron-up.svg @@ -0,0 +1,3 @@ + diff --git a/test-projects/vite/vanilla/src/main.ts b/test-projects/vite/vanilla/src/main.ts index 2bf90bf..2d14a05 100644 --- a/test-projects/vite/vanilla/src/main.ts +++ b/test-projects/vite/vanilla/src/main.ts @@ -20,6 +20,10 @@ import ImageRef from './icons/with-image-ref.svg'; import ExternalImage from './icons/with-external-image.svg'; import FontRef from './icons/with-font-ref.svg?sprite=fonts'; +// Root attributes - fill, stroke and stroke-width set only on the root +import ChevronUp from './icons/chevron-up.svg'; +import ChevronUpInline from './icons/chevron-up.svg?unsafe-inline'; + // Source order - complex SVG with layered shapes, gradients, clip-paths, text import SourceOrder from './icons/source-order.svg'; @@ -61,6 +65,18 @@ app.appendChild(ImageRef()); app.appendChild(ExternalImage()); app.appendChild(FontRef()); +// Root attributes, then a caller stroke-width that must win over them +app.appendChild(ChevronUp({ class: 'hero-sprite' })); +app.appendChild(ChevronUpInline({ class: 'hero-inline' })); +app.appendChild(ChevronUp({ class: 'hero-sprite-override', 'stroke-width': '3' })); +app.appendChild(ChevronUpInline({ class: 'hero-inline-override', 'stroke-width': '3' })); + +// A CSS rule on the must also win over the root attributes +const style = document.createElement('style'); +style.textContent = '.hero-css-override { stroke-width: 3; }'; +document.head.appendChild(style); +app.appendChild(ChevronUp({ class: 'hero-css-override' })); + // Source order - render at larger size to see detail const sourceOrderEl = SourceOrder(); sourceOrderEl.style.width = '100px'; diff --git a/test-projects/vite/vue/src/App.vue b/test-projects/vite/vue/src/App.vue index b1686c3..59fce04 100644 --- a/test-projects/vite/vue/src/App.vue +++ b/test-projects/vite/vue/src/App.vue @@ -7,6 +7,10 @@ import Circle from './icons/circle.svg?sprite=shapes'; // Inline mode import Square from './icons/square.svg?unsafe-inline'; + +// Root attributes - fill, stroke and stroke-width set only on the root +import ChevronUp from './icons/chevron-up.svg'; +import ChevronUpInline from './icons/chevron-up.svg?unsafe-inline';

Inline mode

+ +
+

Root attributes

+ + + + +
diff --git a/test-projects/vite/vue/src/icons/chevron-up.svg b/test-projects/vite/vue/src/icons/chevron-up.svg new file mode 100644 index 0000000..eafbff4 --- /dev/null +++ b/test-projects/vite/vue/src/icons/chevron-up.svg @@ -0,0 +1,3 @@ + diff --git a/test-projects/vite/web-component/src/icons/chevron-up.svg b/test-projects/vite/web-component/src/icons/chevron-up.svg new file mode 100644 index 0000000..eafbff4 --- /dev/null +++ b/test-projects/vite/web-component/src/icons/chevron-up.svg @@ -0,0 +1,3 @@ + diff --git a/test-projects/vite/web-component/src/main.ts b/test-projects/vite/web-component/src/main.ts index 9066e62..4fc4c69 100644 --- a/test-projects/vite/web-component/src/main.ts +++ b/test-projects/vite/web-component/src/main.ts @@ -7,10 +7,16 @@ import SvgCircle from './icons/circle.svg?sprite=shapes'; // Inline mode import SvgSquare from './icons/square.svg?unsafe-inline'; +// Root attributes - fill, stroke and stroke-width set only on the root +import SvgChevronUp from './icons/chevron-up.svg'; +import SvgChevronUpInline from './icons/chevron-up.svg?unsafe-inline'; + // Register custom elements customElements.define('svg-arrow', SvgArrow); customElements.define('svg-circle', SvgCircle); customElements.define('svg-square', SvgSquare); +customElements.define('svg-chevron-up', SvgChevronUp); +customElements.define('svg-chevron-up-inline', SvgChevronUpInline); // Use them in the page const app = document.getElementById('app')!; @@ -39,4 +45,13 @@ app.innerHTML = `

Inline mode

+ +
+

Root attributes

+ + + + + +
`;