'use client'; import { createElement, memo, forwardRef } from 'react'; /** * Default values for dimensions */ const defaultIconDimensions = Object.freeze({ left: 0, top: 0, width: 16, height: 16 }); /** * Default values for transformations */ const defaultIconTransformations = Object.freeze({ rotate: 0, vFlip: false, hFlip: false }); /** * Default values for all optional IconifyIcon properties */ const defaultIconProps = Object.freeze({ ...defaultIconDimensions, ...defaultIconTransformations }); /** * Default values for all properties used in ExtendedIconifyIcon */ const defaultExtendedIconProps = Object.freeze({ ...defaultIconProps, body: "", hidden: false }); /** * Resolve icon set icons * * Returns parent icon for each icon */ function getIconsTree(data, names) { const icons = data.icons; const aliases = data.aliases || Object.create(null); const resolved = Object.create(null); function resolve(name) { if (icons[name]) return resolved[name] = []; if (!(name in resolved)) { resolved[name] = null; const parent = aliases[name] && aliases[name].parent; const value = parent && resolve(parent); if (value) resolved[name] = [parent].concat(value); } return resolved[name]; } (Object.keys(icons).concat(Object.keys(aliases))).forEach(resolve); return resolved; } /** * Merge transformations */ function mergeIconTransformations(obj1, obj2) { const result = {}; if (!obj1.hFlip !== !obj2.hFlip) result.hFlip = true; if (!obj1.vFlip !== !obj2.vFlip) result.vFlip = true; const rotate = ((obj1.rotate || 0) + (obj2.rotate || 0)) % 4; if (rotate) result.rotate = rotate; return result; } /** * Merge icon and alias * * Can also be used to merge default values and icon */ function mergeIconData(parent, child) { const result = mergeIconTransformations(parent, child); for (const key in defaultExtendedIconProps) if (key in defaultIconTransformations) { if (key in parent && !(key in result)) result[key] = defaultIconTransformations[key]; } else if (key in child) result[key] = child[key]; else if (key in parent) result[key] = parent[key]; return result; } /** * Get icon data, using prepared aliases tree */ function internalGetIconData(data, name, tree) { const icons = data.icons; const aliases = data.aliases || Object.create(null); let currentProps = {}; function parse(name$1) { currentProps = mergeIconData(icons[name$1] || aliases[name$1], currentProps); } parse(name); tree.forEach(parse); return mergeIconData(data, currentProps); } /** * Extract icons from an icon set * * Returns list of icons that were found in icon set */ function parseIconSet(data, callback) { const names = []; if (typeof data !== "object" || typeof data.icons !== "object") return names; if (data.not_found instanceof Array) data.not_found.forEach((name) => { callback(name, null); names.push(name); }); const tree = getIconsTree(data); for (const name in tree) { const item = tree[name]; if (item) { callback(name, internalGetIconData(data, name, item)); names.push(name); } } return names; } /** * Optional properties */ const optionalPropertyDefaults = { provider: "", aliases: {}, not_found: {}, ...defaultIconDimensions }; /** * Check props */ function checkOptionalProps(item, defaults) { for (const prop in defaults) if (prop in item && typeof item[prop] !== typeof defaults[prop]) return false; return true; } /** * Validate icon set, return it as IconifyJSON on success, null on failure * * Unlike validateIconSet(), this function is very basic. * It does not throw exceptions, it does not check metadata, it does not fix stuff. */ function quicklyValidateIconSet(obj) { if (typeof obj !== "object" || obj === null) return null; const data = obj; if (typeof data.prefix !== "string" || !obj.icons || typeof obj.icons !== "object") return null; if (!checkOptionalProps(obj, optionalPropertyDefaults)) return null; const icons = data.icons; for (const name in icons) { const icon = icons[name]; if (!name || typeof icon.body !== "string" || !checkOptionalProps(icon, defaultExtendedIconProps)) return null; } const aliases = data.aliases || Object.create(null); for (const name in aliases) { const icon = aliases[name]; const parent = icon.parent; if (!name || typeof parent !== "string" || !icons[parent] && !aliases[parent] || !checkOptionalProps(icon, defaultExtendedIconProps)) return null; } return data; } /** * Default icon customisations values */ const defaultIconSizeCustomisations = Object.freeze({ width: null, height: null }); const defaultIconCustomisations = Object.freeze({ ...defaultIconSizeCustomisations, ...defaultIconTransformations }); /** * Convert IconifyIconCustomisations to FullIconCustomisations, checking value types */ function mergeCustomisations(defaults, item) { const result = { ...defaults }; for (const key in item) { const value = item[key]; const valueType = typeof value; if (key in defaultIconSizeCustomisations) { if (value === null || value && (valueType === "string" || valueType === "number")) result[key] = value; } else if (valueType === typeof result[key]) result[key] = key === "rotate" ? value % 4 : value; } return result; } const separator = /[\s,]+/; /** * Apply "flip" string to icon customisations */ function flipFromString(custom, flip) { flip.split(separator).forEach((str) => { const value = str.trim(); switch (value) { case "horizontal": custom.hFlip = true; break; case "vertical": custom.vFlip = true; break; } }); } /** * Get rotation value */ function rotateFromString(value, defaultValue = 0) { const units = value.replace(/^-?[0-9.]*/, ""); function cleanup(value$1) { while (value$1 < 0) value$1 += 4; return value$1 % 4; } if (units === "") { const num = parseInt(value); return isNaN(num) ? 0 : cleanup(num); } else if (units !== value) { let split = 0; switch (units) { case "%": split = 25; break; case "deg": split = 90; } if (split) { let num = parseFloat(value.slice(0, value.length - units.length)); if (isNaN(num)) return 0; num = num / split; return num % 1 === 0 ? cleanup(num) : 0; } } return defaultValue; } /** * Regular expressions for calculating dimensions */ const unitsSplit = /(-?[0-9.]*[0-9]+[0-9.]*)/g; const unitsTest = /^-?[0-9.]*[0-9]+[0-9.]*$/g; function calculateSize(size, ratio, precision) { if (ratio === 1) return size; precision = precision || 100; if (typeof size === "number") return Math.ceil(size * ratio * precision) / precision; if (typeof size !== "string") return size; const oldParts = size.split(unitsSplit); if (oldParts === null || !oldParts.length) return size; const newParts = []; let code = oldParts.shift(); let isNumber = unitsTest.test(code); while (true) { if (isNumber) { const num = parseFloat(code); if (isNaN(num)) newParts.push(code); else newParts.push(Math.ceil(num * ratio * precision) / precision); } else newParts.push(code); code = oldParts.shift(); if (code === void 0) return newParts.join(""); isNumber = !isNumber; } } function splitSVGDefs(content, tag = "defs") { let defs = ""; const index = content.indexOf("<" + tag); while (index >= 0) { const start = content.indexOf(">", index); const end = content.indexOf("", end); if (endEnd === -1) break; defs += content.slice(start + 1, end).trim(); content = content.slice(0, index).trim() + content.slice(endEnd + 1); } return { defs, content }; } /** * Merge defs and content */ function mergeDefsAndContent(defs, content) { return defs ? "" + defs + "" + content : content; } /** * Wrap SVG content, without wrapping definitions */ function wrapSVGContent(body, start, end) { const split = splitSVGDefs(body); return mergeDefsAndContent(split.defs, start + split.content + end); } /** * Check if value should be unset. Allows multiple keywords */ const isUnsetKeyword = (value) => value === "unset" || value === "undefined" || value === "none"; /** * Get SVG attributes and content from icon + customisations * * Does not generate style to make it compatible with frameworks that use objects for style, such as React. * Instead, it generates 'inline' value. If true, rendering engine should add verticalAlign: -0.125em to icon. * * Customisations should be normalised by platform specific parser. * Result should be converted to by platform specific parser. * Use replaceIDs to generate unique IDs for body. */ function iconToSVG(icon, customisations) { const fullIcon = { ...defaultIconProps, ...icon }; const fullCustomisations = { ...defaultIconCustomisations, ...customisations }; const box = { left: fullIcon.left, top: fullIcon.top, width: fullIcon.width, height: fullIcon.height }; let body = fullIcon.body; [fullIcon, fullCustomisations].forEach((props) => { const transformations = []; const hFlip = props.hFlip; const vFlip = props.vFlip; let rotation = props.rotate; if (hFlip) if (vFlip) rotation += 2; else { transformations.push("translate(" + (box.width + box.left).toString() + " " + (0 - box.top).toString() + ")"); transformations.push("scale(-1 1)"); box.top = box.left = 0; } else if (vFlip) { transformations.push("translate(" + (0 - box.left).toString() + " " + (box.height + box.top).toString() + ")"); transformations.push("scale(1 -1)"); box.top = box.left = 0; } let tempValue; if (rotation < 0) rotation -= Math.floor(rotation / 4) * 4; rotation = rotation % 4; switch (rotation) { case 1: tempValue = box.height / 2 + box.top; transformations.unshift("rotate(90 " + tempValue.toString() + " " + tempValue.toString() + ")"); break; case 2: transformations.unshift("rotate(180 " + (box.width / 2 + box.left).toString() + " " + (box.height / 2 + box.top).toString() + ")"); break; case 3: tempValue = box.width / 2 + box.left; transformations.unshift("rotate(-90 " + tempValue.toString() + " " + tempValue.toString() + ")"); break; } if (rotation % 2 === 1) { if (box.left !== box.top) { tempValue = box.left; box.left = box.top; box.top = tempValue; } if (box.width !== box.height) { tempValue = box.width; box.width = box.height; box.height = tempValue; } } if (transformations.length) body = wrapSVGContent(body, "", ""); }); const customisationsWidth = fullCustomisations.width; const customisationsHeight = fullCustomisations.height; const boxWidth = box.width; const boxHeight = box.height; let width; let height; if (customisationsWidth === null) { height = customisationsHeight === null ? "1em" : customisationsHeight === "auto" ? boxHeight : customisationsHeight; width = calculateSize(height, boxWidth / boxHeight); } else { width = customisationsWidth === "auto" ? boxWidth : customisationsWidth; height = customisationsHeight === null ? calculateSize(width, boxHeight / boxWidth) : customisationsHeight === "auto" ? boxHeight : customisationsHeight; } const attributes = {}; const setAttr = (prop, value) => { if (!isUnsetKeyword(value)) attributes[prop] = value.toString(); }; setAttr("width", width); setAttr("height", height); const viewBox = [ box.left, box.top, boxWidth, boxHeight ]; attributes.viewBox = viewBox.join(" "); return { attributes, viewBox, body }; } /** * IDs usage: * * id="{id}" * xlink:href="#{id}" * url(#{id}) * * From SVG animations: * * begin="0;{id}.end" * begin="{id}.end" * begin="{id}.click" */ /** * Regular expression for finding ids */ const regex = /\sid="(\S+)"/g; /** * New random-ish prefix for ids * * Do not use dash, it cannot be used in SVG 2 animations */ const randomPrefix = "IconifyId" + Date.now().toString(16) + (Math.random() * 16777216 | 0).toString(16); /** * Counter for ids, increasing with every replacement */ let counter = 0; /** * Replace IDs in SVG output with unique IDs */ function replaceIDs(body, prefix = randomPrefix) { const ids = []; let match; while (match = regex.exec(body)) ids.push(match[1]); if (!ids.length) return body; const suffix = "suffix" + (Math.random() * 16777216 | Date.now()).toString(16); ids.forEach((id) => { const newID = typeof prefix === "function" ? prefix(id) : prefix + (counter++).toString(); const escapedID = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); body = body.replace(new RegExp("([#;\"])(" + escapedID + ")([\")]|\\.[a-z])", "g"), "$1" + newID + suffix + "$3"); }); body = body.replace(new RegExp(suffix, "g"), ""); return body; } /** * Generate */ function iconToHTML(body, attributes) { let renderAttribsHTML = body.indexOf("xlink:") === -1 ? "" : " xmlns:xlink=\"http://www.w3.org/1999/xlink\""; for (const attr in attributes) renderAttribsHTML += " " + attr + "=\"" + attributes[attr] + "\""; return "" + body + ""; } /** * Encode SVG for use in url() * * Short alternative to encodeURIComponent() that encodes only stuff used in SVG, generating * smaller code. */ function encodeSVGforURL(svg) { return svg.replace(/"/g, "'").replace(/%/g, "%25").replace(/#/g, "%23").replace(//g, "%3E").replace(/\s+/g, " "); } /** * Generate data: URL from SVG */ function svgToData(svg) { return "data:image/svg+xml," + encodeSVGforURL(svg); } /** * Generate url() from SVG */ function svgToURL(svg) { return "url(\"" + svgToData(svg) + "\")"; } let policy; /** * Attempt to create policy */ function createPolicy() { try { policy = window.trustedTypes.createPolicy("iconify", { createHTML: (s) => s }); } catch (err) { policy = null; } } /** * Clean up value for innerHTML assignment * * This code doesn't actually clean up anything. * It is intended be used with Iconify icon data, which has already been validated */ function cleanUpInnerHTML(html) { if (policy === void 0) createPolicy(); return policy ? policy.createHTML(html) : html; } const defaultExtendedIconCustomisations = { ...defaultIconCustomisations, inline: false, }; /** * Expression to test part of icon name. * * Used when loading icons from Iconify API due to project naming convension. * Ignored when using custom icon sets - convension does not apply. */ /** * Convert string icon name to IconifyIconName object. */ const stringToIcon = (value, validate, allowSimpleName, provider = "") => { const colonSeparated = value.split(":"); if (value.slice(0, 1) === "@") { if (colonSeparated.length < 2 || colonSeparated.length > 3) return null; provider = colonSeparated.shift().slice(1); } if (colonSeparated.length > 3 || !colonSeparated.length) return null; if (colonSeparated.length > 1) { const name$1 = colonSeparated.pop(); const prefix = colonSeparated.pop(); const result = { provider: colonSeparated.length > 0 ? colonSeparated[0] : provider, prefix, name: name$1 }; return result; } const name = colonSeparated[0]; const dashSeparated = name.split("-"); if (dashSeparated.length > 1) { const result = { provider, prefix: dashSeparated.shift(), name: dashSeparated.join("-") }; return result; } if (provider === "") { const result = { provider, prefix: "", name }; return result; } return null; }; /** * Default SVG attributes */ const svgDefaults = { 'xmlns': 'http://www.w3.org/2000/svg', 'xmlnsXlink': 'http://www.w3.org/1999/xlink', 'aria-hidden': true, 'role': 'img', }; /** * Style modes */ const commonProps = { display: 'inline-block', }; const monotoneProps = { backgroundColor: 'currentColor', }; const coloredProps = { backgroundColor: 'transparent', }; // Dynamically add common props to variables above const propsToAdd = { Image: 'var(--svg)', Repeat: 'no-repeat', Size: '100% 100%', }; const propsToAddTo = { WebkitMask: monotoneProps, mask: monotoneProps, background: coloredProps, }; for (const prefix in propsToAddTo) { const list = propsToAddTo[prefix]; for (const prop in propsToAdd) { list[prefix + prop] = propsToAdd[prop]; } } /** * Default values for customisations for inline icon */ const inlineDefaults = { ...defaultExtendedIconCustomisations, inline: true, }; /** * Fix size: add 'px' to numbers */ function fixSize(value) { return value + (value.match(/^[-0-9.]+$/) ? 'px' : ''); } /** * Render icon */ const render = ( // Icon must be validated before calling this function icon, // Partial properties props, // Icon name name) => { // Get default properties const defaultProps = props.inline ? inlineDefaults : defaultExtendedIconCustomisations; // Get all customisations const customisations = mergeCustomisations(defaultProps, props); // Check mode const mode = props.mode || 'svg'; // Create style const style = {}; const customStyle = props.style || {}; // Create SVG component properties const componentProps = { ...(mode === 'svg' ? svgDefaults : {}), }; if (name) { const iconName = stringToIcon(name); if (iconName) { const classNames = ['iconify']; const props = [ 'provider', 'prefix', ]; for (const prop of props) { if (iconName[prop]) { classNames.push('iconify--' + iconName[prop]); } } componentProps.className = classNames.join(' '); } } // Get element properties for (let key in props) { const value = props[key]; if (value === void 0) { continue; } switch (key) { // Properties to ignore case 'icon': case 'style': case 'children': case 'onLoad': case 'mode': case 'ssr': case 'fallback': break; // Forward ref case '_ref': componentProps.ref = value; break; // Merge class names case 'className': componentProps[key] = (componentProps[key] ? componentProps[key] + ' ' : '') + value; break; // Boolean attributes case 'inline': case 'hFlip': case 'vFlip': customisations[key] = value === true || value === 'true' || value === 1; break; // Flip as string: 'horizontal,vertical' case 'flip': if (typeof value === 'string') { flipFromString(customisations, value); } break; // Color: copy to style case 'color': style.color = value; break; // Rotation as string case 'rotate': if (typeof value === 'string') { customisations[key] = rotateFromString(value); } else if (typeof value === 'number') { customisations[key] = value; } break; // Remove aria-hidden case 'ariaHidden': case 'aria-hidden': if (value !== true && value !== 'true') { delete componentProps['aria-hidden']; } break; // Copy missing property if it does not exist in customisations default: if (defaultProps[key] === void 0) { componentProps[key] = value; } } } // Generate icon const item = iconToSVG(icon, customisations); const renderAttribs = item.attributes; // Inline display if (customisations.inline) { style.verticalAlign = '-0.125em'; } if (mode === 'svg') { // Add style componentProps.style = { ...style, ...customStyle, }; // Add icon stuff Object.assign(componentProps, renderAttribs); // Counter for ids based on "id" property to render icons consistently on server and client let localCounter = 0; let id = props.id; if (typeof id === 'string') { // Convert '-' to '_' to avoid errors in animations id = id.replace(/-/g, '_'); } // Add icon stuff componentProps.dangerouslySetInnerHTML = { __html: cleanUpInnerHTML(replaceIDs(item.body, id ? () => id + 'ID' + localCounter++ : 'iconifyReact')), }; return createElement('svg', componentProps); } // Render with style const { body, width, height } = icon; const useMask = mode === 'mask' || (mode === 'bg' ? false : body.indexOf('currentColor') !== -1); // Generate SVG const html = iconToHTML(body, { ...renderAttribs, width: width + '', height: height + '', }); // Generate style componentProps.style = { ...style, '--svg': svgToURL(html), 'width': fixSize(renderAttribs.width), 'height': fixSize(renderAttribs.height), ...commonProps, ...(useMask ? monotoneProps : coloredProps), ...customStyle, }; return createElement('span', componentProps); }; /** * Storage for icons referred by name */ const storage = Object.create(null); function IconComponent(props) { const icon = props.icon; const data = typeof icon === 'string' ? storage[icon] : icon; if (!data) { return props.children ? props.children : createElement('span', {}); } return render({ ...defaultIconProps, ...data, }, props, typeof icon === 'string' ? icon : undefined); } /** * Block icon * * @param props - Component properties */ const Icon = memo(forwardRef((props, ref) => IconComponent({ ...props, _ref: ref, }))); /** * Inline icon (has negative verticalAlign that makes it behave like icon font) * * @param props - Component properties */ const InlineIcon = memo(forwardRef((props, ref) => IconComponent({ inline: true, ...props, _ref: ref, }))); /** * Add icon to storage, allowing to call it by name * * @param name * @param data */ function addIcon(name, data) { storage[name] = data; } /** * Add collection to storage, allowing to call icons by name * * @param data Icon set * @param prefix Optional prefix to add to icon names, true (default) if prefix from icon set should be used. */ function addCollection(data, prefix) { const iconPrefix = typeof prefix === 'string' ? prefix : prefix !== false && typeof data.prefix === 'string' ? data.prefix + ':' : ''; quicklyValidateIconSet(data) && parseIconSet(data, (name, icon) => { if (icon) { storage[iconPrefix + name] = icon; } }); } export { Icon, InlineIcon, addCollection, addIcon };