import { adopt, define, sheet } from "../internals.ts"; import type { IconName } from "./names.ts"; import { ICON_SYMBOLS } from "./sprite.generated.ts"; const SVG_NS = "http://www.w3.org/2000/svg"; const iconSheet = sheet(` :host { display: inline-block; width: 1em; height: 1em; flex: none; color: inherit; } svg { display: block; width: 100%; height: 100%; fill: currentColor; } `); /** Parses each symbol's markup once; both `ensureSymbol` and `icon` clone from this. */ const parser = new DOMParser(); const templates = new Map(); function template(name: IconName): SVGSymbolElement { let node = templates.get(name); if (!node) { const markup = ICON_SYMBOLS[name]; if (!markup) throw new Error(`Unknown icon: ${name}`); const doc = parser.parseFromString(`${markup}`, "image/svg+xml"); templates.set(name, (node = doc.documentElement.firstElementChild as SVGSymbolElement)); } return node; } /** A `` only resolves an id within its own tree scope, so each scope needs its own sprite. */ export type IconRoot = Document | ShadowRoot; /** Each root's own hidden sprite: a `` per icon that root has actually used, added lazily. */ const sprites = new WeakMap(); function spriteFor(root: IconRoot): SVGSVGElement { let sprite = sprites.get(root); if (!sprite) { sprite = document.createElementNS(SVG_NS, "svg"); // Present in the tree (so `` can resolve against it) but invisible and out of the a11y // tree: zero-sized, clipped, and aria-hidden, like the codicon dist sprite's own wrapper. sprite.setAttribute("aria-hidden", "true"); sprite.setAttribute("focusable", "false"); Object.assign(sprite.style, { position: "absolute", width: "0", height: "0", overflow: "hidden" }); sprites.set(root, sprite); (root instanceof Document ? (root.body ?? root.documentElement) : root).append(sprite); } return sprite; } /** Adds `name`'s `` to `root`'s sprite the first time `root` uses it; a no-op after that. */ function ensureSymbol(root: IconRoot, name: IconName): SVGSymbolElement { const def = template(name); const sprite = spriteFor(root); const id = `nib-icon-${name}`; const existing = sprite.querySelector(`#${id}`); if (existing) return existing as SVGSymbolElement; const clone = def.cloneNode(true) as SVGSymbolElement; sprite.append(clone); return clone; } /** * Builds a ``-based icon referencing `name` in `root`'s sprite (injecting that one `` * into `root` first if nothing there has used it yet). Every icon drawn into the same root after the * first shares that root's sprite instead of carrying its own copy: a tree row that shows one * chevron costs one `` in its own shadow root, not one per row across the whole tree plus a * second per re-render. For code that builds shadow content directly (`MenuItemElement`, * `TreeItemElement`, `TabElement`) rather than using ``. */ export function icon(root: IconRoot, name: IconName): SVGSVGElement { const def = ensureSymbol(root, name); const svg = document.createElementNS(SVG_NS, "svg"); svg.setAttribute("width", "1em"); svg.setAttribute("height", "1em"); // Each codicon has its own native viewBox (most are 0 0 16 16, but not all — e.g. "files" and // "terminal" are 0 0 24 24); without this the generated instance is sized off the element's // unscaled natural size instead of its rendered CSS box, so wider glyphs overflow and clip. svg.setAttribute("viewBox", def.getAttribute("viewBox")!); svg.setAttribute("fill", "currentColor"); svg.setAttribute("aria-hidden", "true"); svg.setAttribute("focusable", "false"); const use = document.createElementNS(SVG_NS, "use"); use.setAttribute("href", `#nib-icon-${name}`); svg.append(use); return svg; } /** * An icon from nibling's codicon-backed sprite (see `names.ts`). Decorative by default * (`aria-hidden`); set `label` to make it an informative `role="img"` with that accessible name. * Sized by `1em` and colored by `currentColor`, so it follows the surrounding text. * * Carries its own sprite in its own shadow root (there's no other tree scope its `` could share * one with). Fine for the handful of standalone icons a page has at once — a header button, an * activity bar entry — but a list that repeats the same icon per row (a tree, a tab bar, a menu) * should call `icon()` directly into the list item's own existing shadow root instead, so every row * shares that one root's sprite rather than each row carrying its own. */ export class IconElement extends HTMLElement { readonly #internals = this.attachInternals(); readonly #root = this.attachShadow({ mode: "open" }); #name: IconName | undefined; #svg: SVGSVGElement | undefined; static get observedAttributes(): string[] { return ["name", "label"]; } constructor() { super(); adopt(this.#root, iconSheet); this.#internals.role = "none"; this.#internals.ariaHidden = "true"; } attributeChangedCallback(attr: string, _old: string | null, value: string | null): void { if (attr === "name") this.#setName(value as IconName | null); else if (attr === "label") this.#setLabel(value); } get name(): IconName | undefined { return this.#name; } set name(value: IconName | undefined) { if (value === undefined) this.removeAttribute("name"); else this.setAttribute("name", value); } get label(): string { return this.getAttribute("label") ?? ""; } set label(value: string) { if (value) this.setAttribute("label", value); else this.removeAttribute("label"); } #setName(value: IconName | null) { this.#name = value ?? undefined; this.#render(); } #setLabel(value: string | null) { this.#internals.role = value ? "img" : "none"; this.#internals.ariaLabel = value; this.#internals.ariaHidden = value ? null : "true"; } #render() { // Only the -wrapping is replaced: the sprite `icon()` injected lives in this same // shadow root as its sibling, and must survive a name change so later icons keep reusing it. this.#svg?.remove(); this.#svg = this.#name ? icon(this.#root, this.#name) : undefined; if (this.#svg) this.#root.append(this.#svg); } } declare global { interface HTMLElementTagNameMap { "nib-icon": IconElement; } } define("nib-icon", IconElement);