web/js/icons.js
Rama main · réplica de lectura del repositorio original.
/* web/js/icons.js — inline SVG icon set for product chrome.
*
* No emoji in chrome: render variance, poor contrast on navy, no
* accessible name story. Icons are 16×16, 1.5px stroke, currentColor,
* decorative by default (aria-hidden) unless the caller passes a label.
*
* Built with DOM APIs so CSP `script-src 'self'` stays clean — no
* inline event handlers, no innerHTML of untrusted text.
*
* See docs/design/DESIGN.md §7.
*/
"use strict";
/** @type {Record<string, string>} path data only — viewBox 0 0 24 24. */
const PATHS = {
menu: "M4 7h16M4 12h16M4 17h16",
close: "M6 6l12 12M18 6L6 18",
sun: "M12 4v2m0 12v2M4 12H2m20 0h-2M6.3 6.3l1.4 1.4m8.6 8.6l1.4 1.4M6.3 17.7l1.4-1.4m8.6-8.6l1.4-1.4M12 8a4 4 0 100 8 4 4 0 000-8z",
moon: "M20 14.5A8.5 8.5 0 019.5 4a7 7 0 1010.5 10.5z",
theme: "M12 3a9 9 0 100 18 9 9 0 000-18zm0 0v18",
memory: "M9 8a3 3 0 016 0v1h1a2 2 0 012 2v6a2 2 0 01-2 2H8a2 2 0 01-2-2v-6a2 2 0 012-2h1V8zm3 4v4m-2-2h4",
refresh: "M4 12a8 8 0 0114-5.3M20 12a8 8 0 01-14 5.3M18 4v4h-4M6 20v-4h4",
copy: "M8 8h10v10H8zM6 16H4V4h12v2",
download: "M12 4v10m0 0l-4-4m4 4l4-4M5 19h14",
chevronDown: "M6 9l6 6 6-6",
chevronUp: "M6 15l6-6 6 6",
check: "M5 12l5 5L20 7",
alert: "M12 8v5m0 3h.01M10.3 4.3L2.5 18a2 2 0 001.7 3h15.6a2 2 0 001.7-3L13.7 4.3a2 2 0 00-3.4 0z",
database: "M4 6c0-1.7 3.6-3 8-3s8 1.3 8 3-3.6 3-8 3-8-1.3-8-3zm0 0v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6m-16 6c0 1.7 3.6 3 8 3s8-1.3 8-3",
shield: "M12 3l8 3v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6l8-3z",
pin: "M12 3v4m0 0l-2 6h4l-2-6zm0 10v8",
edit: "M4 20h4l10-10-4-4L4 16v4zm10-12l4 4",
// Lid + body + two score lines on the same 24×24 stroke grid as the rest
// of the set — kept purely geometric (no emoji glyph variance across
// platforms) so the delete action reads identically everywhere.
trash: "M4 7h16M9 7V4h6v3M6 7l1 13a2 2 0 002 2h6a2 2 0 002-2l1-13M10 11v6m4-6v6",
};
/**
* Create an SVG icon element.
*
* @param {keyof typeof PATHS} name
* @param {{ className?: string, label?: string, size?: number }} [opts]
* @returns {SVGSVGElement}
*
* @example
* const el = icon("menu", { label: "فهرست" });
*/
export function icon(name, opts = {}) {
const { className = "", label, size = 16 } = opts;
const NS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(NS, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("width", String(size));
svg.setAttribute("height", String(size));
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "1.5");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
if (className) svg.setAttribute("class", `icon icon-${name} ${className}`.trim());
else svg.setAttribute("class", `icon icon-${name}`);
if (label) {
svg.setAttribute("role", "img");
const title = document.createElementNS(NS, "title");
title.textContent = label;
svg.appendChild(title);
} else {
svg.setAttribute("aria-hidden", "true");
}
const path = document.createElementNS(NS, "path");
path.setAttribute("d", PATHS[name] || "");
svg.appendChild(path);
return svg;
}
/**
* Replace the text content of *host* with a single icon (keeps any
* trailing text node the caller already put there via textContent split).
*
* @param {HTMLElement} host
* @param {keyof typeof PATHS} name
* @param {{ className?: string, label?: string, size?: number }} [opts]
* @returns {SVGSVGElement}
*/
export function mountIcon(host, name, opts = {}) {
const svg = icon(name, opts);
host.insertBefore(svg, host.firstChild);
return svg;
}