Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,31 @@ Icons({
})
```

To let CSS and the `viewBox` control sizing, use `unset` or `none` instead of `auto`:

```ts
Icons({
customCollections: {
logos: FileSystemIconLoader('./assets/logos'),
},
iconCustomizer(collection, icon, props) {
if (collection === 'logos') {
props.width = 'unset'
props.height = 'unset'
}
},
})
```

```css
.brand-logo {
width: 11rem;
height: auto;
}
```

This works for custom collections and Iconify collections. `auto` keeps a real SVG dimension value, while `unset` and `none` omit the root `width` and `height` attributes entirely.

or you can use `query` params to apply to individual icons:

<!-- eslint-skip -->
Expand Down
68 changes: 65 additions & 3 deletions src/core/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { compilers } from './compilers'

const URL_PREFIXES = ['/~icons/', '~icons/', 'virtual:icons/', 'virtual/icons/']
const iconPathRE = new RegExp(`${URL_PREFIXES.map(v => `^${v}`).join('|')}`)
const DIMENSION_SUPPRESSION_KEYWORDS = new Set(['none', 'unset'])

export interface ResolvedIconPath {
collection: string
Expand Down Expand Up @@ -65,6 +66,7 @@ export async function generateComponent({ collection, icon, query }: ResolvedIco
autoInstall = false,
collectionsNodeResolvePath,
} = options
const dimensionOverrides = createDimensionOverrideTracker()

const iconifyLoaderOptions: IconifyLoaderOptions = {
addXmlNs: false,
Expand All @@ -79,19 +81,21 @@ export async function generateComponent({ collection, icon, query }: ResolvedIco
customizations: {
transform,
async iconCustomizer(collection, icon, props) {
await providedIconCustomizer?.(collection, icon, props)
const trackedProps = dimensionOverrides.track(props)
await providedIconCustomizer?.(collection, icon, trackedProps)
Object.keys(query).forEach((p) => {
const v = query[p]
// exclude raw compiler entry to be serialized as svg attr
if (p !== 'raw' && v !== undefined && v !== null)
props[p] = v
trackedProps[p] = v
})
},
},
}
const svg = await loadNodeIcon(collection, icon, iconifyLoaderOptions)
let svg = await loadNodeIcon(collection, icon, iconifyLoaderOptions)
if (!svg)
throw new Error(`Icon \`${warn}\` not found`)
svg = stripRootSvgDimensions(svg, dimensionOverrides.explicit)

// accept raw compiler from query params
const _compiler = query.raw === 'true' ? 'raw' : options.compiler
Expand All @@ -114,3 +118,61 @@ export async function generateComponentFromPath(path: string, options: ResolvedO
return null
return generateComponent(resolved, options)
}

interface ExplicitDimensionOverrides {
width?: string
height?: string
}

function createDimensionOverrideTracker() {
const explicit: ExplicitDimensionOverrides = {}

return {
explicit,
track(props: Record<string, string>) {
return new Proxy(props, {
set(target, property, value) {
if (typeof property === 'string') {
if (property === 'width' || property === 'height')
explicit[property] = String(value)
target[property] = String(value)
return true
}
Reflect.set(target, property, value)
return true
},
})
},
}
}

function stripRootSvgDimensions(svg: string, explicit: ExplicitDimensionOverrides) {
const explicitWidth = explicit.width
const explicitHeight = explicit.height
const suppressWidth = isSuppressedDimension(explicitWidth)
const suppressHeight = isSuppressedDimension(explicitHeight)

if (!suppressWidth && !suppressHeight)
return svg

const removeWidth = suppressWidth
|| (suppressHeight && explicitWidth === undefined)
const removeHeight = suppressHeight
|| (suppressWidth && explicitHeight === undefined)

return svg.replace(/<svg\b([^>]*)>/, (full, attrs) => {
let nextAttrs = attrs as string

if (removeWidth)
nextAttrs = nextAttrs.replace(/\swidth=(['"]).*?\1/, '')

if (removeHeight)
nextAttrs = nextAttrs.replace(/\sheight=(['"]).*?\1/, '')

return `<svg${nextAttrs}>`
})
}

function isSuppressedDimension(value?: string) {
return value != null && DIMENSION_SUPPRESSION_KEYWORDS.has(value.toLowerCase())
}
127 changes: 127 additions & 0 deletions test/loader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest'
import { generateComponent } from '../src/core/loader'
import { resolveOptions } from '../src/core/options'

const logoSvg = '<svg viewBox="0 0 182.06 36.195"><rect width="182.06" height="36.195" /></svg>'

function getRootSvgOpenTag(svg: string) {
const match = svg.match(/<svg\b[^>]*>/)
if (!match)
throw new Error('Expected SVG root tag to be present')
return match[0]
}

async function renderCustomIcon(options: Parameters<typeof resolveOptions>[0], query: Record<string, string | undefined> = {}) {
const resolved = await resolveOptions({
compiler: 'none',
customCollections: {
logos: {
wordmark: logoSvg,
},
},
...options,
})

return generateComponent({
collection: 'logos',
icon: 'wordmark',
query,
}, resolved)
}

describe('generateComponent dimension suppression', () => {
it('omits width and height for custom collections via iconCustomizer', async () => {
const svg = await renderCustomIcon({
iconCustomizer(collection, icon, props) {
if (collection === 'logos' && icon === 'wordmark') {
props.width = 'unset'
props.height = 'unset'
}
},
})

const rootTag = getRootSvgOpenTag(svg)
expect(rootTag).not.toContain(' width=')
expect(rootTag).not.toContain(' height=')
expect(svg).toContain('<rect width="182.06" height="36.195" />')
})

it('omits width and height for custom collections via query params', async () => {
const svg = await renderCustomIcon({}, {
width: 'unset',
height: 'unset',
})

const rootTag = getRootSvgOpenTag(svg)
expect(rootTag).not.toContain(' width=')
expect(rootTag).not.toContain(' height=')
})

it('keeps explicit sizing when dimensions are set to real values', async () => {
const svg = await renderCustomIcon({
iconCustomizer(_, __, props) {
props.width = '3em'
props.height = '3em'
},
})

expect(getRootSvgOpenTag(svg)).toContain(' width="3em"')
expect(getRootSvgOpenTag(svg)).toContain(' height="3em"')
})

it('lets query params override iconCustomizer sizing', async () => {
const svg = await renderCustomIcon({
iconCustomizer(_, __, props) {
props.width = 'unset'
props.height = 'unset'
},
}, {
width: '3em',
height: '3em',
})

expect(getRootSvgOpenTag(svg)).toContain(' width="3em"')
expect(getRootSvgOpenTag(svg)).toContain(' height="3em"')
})

it('lets query params override iconCustomizer suppression', async () => {
const svg = await renderCustomIcon({
iconCustomizer(_, __, props) {
props.width = '3em'
props.height = '3em'
},
}, {
width: 'unset',
height: 'unset',
})

const rootTag = getRootSvgOpenTag(svg)
expect(rootTag).not.toContain(' width=')
expect(rootTag).not.toContain(' height=')
})

it('removes both dimensions when only one side is suppressed and the other is not explicit', async () => {
const svg = await renderCustomIcon({
iconCustomizer(_, __, props) {
props.width = 'unset'
},
})

const rootTag = getRootSvgOpenTag(svg)
expect(rootTag).not.toContain(' width=')
expect(rootTag).not.toContain(' height=')
})

it('removes only the suppressed dimension when the other side is explicit', async () => {
const svg = await renderCustomIcon({
iconCustomizer(_, __, props) {
props.width = 'unset'
props.height = '3em'
},
})

const rootTag = getRootSvgOpenTag(svg)
expect(rootTag).not.toContain(' width=')
expect(rootTag).toContain(' height="3em"')
})
})
Loading