From 58c81f2000b8be1871c9c206deead9c1ee807078 Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Fri, 22 May 2026 17:21:24 -0500 Subject: [PATCH] Render plugin icons --- package.json | 2 +- src/css/style.css | 18 + .../{chat-line.svg => chat-dots-line.svg} | 0 .../icons/custom/{chat.svg => chat-dots.svg} | 0 .../custom/{menu.svg => hamburger-menu.svg} | 0 src/js/components/impro-icon.js | 65 -- src/js/components/plugin-icon.js | 844 ++++++++++++++++++ src/js/plugins/pluginRendering.js | 17 +- .../icons/lightningBoltIcon.template.js | 40 - src/js/templates/sidebar.template.js | 9 +- ...impro-icon.test.js => plugin-icon.test.js} | 116 ++- .../specs/plugins/pluginRendering.test.js | 24 + .../specs/templates/sidebar.template.test.js | 19 + 13 files changed, 968 insertions(+), 186 deletions(-) rename src/img/icons/custom/{chat-line.svg => chat-dots-line.svg} (100%) rename src/img/icons/custom/{chat.svg => chat-dots.svg} (100%) rename src/img/icons/custom/{menu.svg => hamburger-menu.svg} (100%) delete mode 100644 src/js/components/impro-icon.js create mode 100644 src/js/components/plugin-icon.js delete mode 100644 src/js/templates/icons/lightningBoltIcon.template.js rename tests/unit/specs/components/{impro-icon.test.js => plugin-icon.test.js} (56%) diff --git a/package.json b/package.json index 45914ef3..14991b5c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.14.87", + "version": "0.14.88", "type": "module", "scripts": { "start": "rm -rf build && NODE_ENV=development eleventy --serve", diff --git a/src/css/style.css b/src/css/style.css index b9cb0b92..9814282a 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -1169,6 +1169,24 @@ header { stroke: var(--text-color); } +plugin-icon { + display: inline-flex; + width: 1em; + height: 1em; + color: inherit; +} + +plugin-icon svg { + width: 100%; + height: 100%; +} + +.sidebar-nav-icon plugin-icon { + width: 25px; + height: 25px; + color: var(--text-color); +} + .sidebar-nav-icon .status-badge { top: 0; left: 12px; diff --git a/src/img/icons/custom/chat-line.svg b/src/img/icons/custom/chat-dots-line.svg similarity index 100% rename from src/img/icons/custom/chat-line.svg rename to src/img/icons/custom/chat-dots-line.svg diff --git a/src/img/icons/custom/chat.svg b/src/img/icons/custom/chat-dots.svg similarity index 100% rename from src/img/icons/custom/chat.svg rename to src/img/icons/custom/chat-dots.svg diff --git a/src/img/icons/custom/menu.svg b/src/img/icons/custom/hamburger-menu.svg similarity index 100% rename from src/img/icons/custom/menu.svg rename to src/img/icons/custom/hamburger-menu.svg diff --git a/src/js/components/impro-icon.js b/src/js/components/impro-icon.js deleted file mode 100644 index 9b01ade7..00000000 --- a/src/js/components/impro-icon.js +++ /dev/null @@ -1,65 +0,0 @@ -import { Component } from "/js/components/component.js"; - -const DEFAULT_SET = "majesticons"; - -const cache = new Map(); - -function fetchIcon(set, name) { - const key = `${set}/${name}`; - if (cache.has(key)) { - return cache.get(key); - } - const url = `/img/icons/${set}/${name}.svg`; - const promise = - typeof globalThis.fetch === "function" - ? globalThis.fetch(url).then((response) => { - if (!response.ok) { - throw new Error(`Failed to load icon "${key}": ${response.status}`); - } - return response.text(); - }) - : Promise.reject(new Error("fetch-unavailable")); - promise.catch(() => {}); - cache.set(key, promise); - return promise; -} - -class ImproIcon extends Component { - static observedAttributes = ["name", "set"]; - - attributeChangedCallback() { - this.render(); - } - - async render() { - const name = this.getAttribute("name"); - const set = this.getAttribute("set") || DEFAULT_SET; - if (!name) { - this.innerHTML = ""; - return; - } - try { - const svg = await fetchIcon(set, name); - if ( - this.getAttribute("name") !== name || - (this.getAttribute("set") || DEFAULT_SET) !== set - ) { - return; - } - this.innerHTML = svg; - } catch (error) { - if ( - this.getAttribute("name") !== name || - (this.getAttribute("set") || DEFAULT_SET) !== set - ) { - return; - } - if (error.message !== "fetch-unavailable") { - console.warn(error.message); - } - this.innerHTML = ""; - } - } -} - -ImproIcon.register(); diff --git a/src/js/components/plugin-icon.js b/src/js/components/plugin-icon.js new file mode 100644 index 00000000..6eb40f30 --- /dev/null +++ b/src/js/components/plugin-icon.js @@ -0,0 +1,844 @@ +import { Component } from "/js/components/component.js"; + +const ICONS = { + custom: new Set([ + "chat-dots", + "chat-dots-line", + "check", + "circle-check", + "hamburger-menu", + "reply", + "repost", + "verified-check", + "verifier-check", + ]), + majesticons: new Set([ + "add-column", + "add-column-line", + "add-row", + "add-row-line", + "airplane", + "airplane-flight-2", + "airplane-flight-2-line", + "airplane-line", + "alert-circle", + "alert-circle-line", + "align-bottom", + "align-bottom-line", + "align-horizontal-center", + "align-horizontal-center-line", + "align-left", + "align-left-line", + "align-right", + "align-right-line", + "align-top", + "align-top-line", + "align-vertical-center", + "align-vertical-center-line", + "analytics", + "analytics-delete", + "analytics-delete-line", + "analytics-line", + "analytics-plus", + "analytics-plus-line", + "analytics-restricted", + "analytics-restricted-line", + "arrow-down", + "arrow-down-circle", + "arrow-down-circle-line", + "arrow-down-line", + "arrow-left", + "arrow-left-circle", + "arrow-left-circle-line", + "arrow-left-line", + "arrow-right", + "arrow-right-circle", + "arrow-right-circle-line", + "arrow-right-line", + "arrow-up", + "arrow-up-circle", + "arrow-up-circle-line", + "arrow-up-line", + "arrows-collapse-full", + "arrows-collapse-full-line", + "arrows-expand-full", + "arrows-expand-full-line", + "article", + "article-line", + "article-search", + "article-search-line", + "atom-2", + "atom-2-line", + "attachment", + "attachment-line", + "award", + "award-line", + "back-circle", + "back-circle-line", + "backward-circle", + "backward-circle-line", + "backward-start-circle", + "backward-start-circle-line", + "band-aids", + "band-aids-line", + "barcode-2", + "barcode-2-line", + "basket-2", + "basket-2-line", + "bath-shower", + "bath-shower-line", + "battery", + "battery-full", + "battery-full-line", + "battery-half", + "battery-half-line", + "battery-line", + "battery-low", + "battery-low-line", + "beach", + "beach-line", + "bell", + "bell-line", + "bitcoin-circle", + "bitcoin-circle-line", + "bluetooth", + "bluetooth-line", + "bold", + "bold-line", + "book", + "book-line", + "book-minus", + "book-minus-line", + "book-open", + "book-open-line", + "book-plus", + "book-plus-line", + "bookmark", + "bookmark-line", + "bookmark-minus", + "bookmark-minus-line", + "bookmark-plus", + "bookmark-plus-line", + "box", + "box-line", + "briefcase", + "briefcase-line", + "browser", + "browser-cookie", + "browser-cookie-line", + "browser-line", + "bug-2", + "bug-2-line", + "burger", + "burger-line", + "bus", + "bus-line", + "cake", + "cake-line", + "calculator", + "calculator-line", + "calendar", + "calendar-line", + "calendar-plus", + "calendar-plus-line", + "camera", + "camera-line", + "camera-off", + "camera-off-line", + "car", + "car-line", + "cent-circle", + "cent-circle-line", + "chat", + "chat-2", + "chat-2-line", + "chat-2-text", + "chat-2-text-line", + "chat-line", + "chat-signal", + "chat-signal-line", + "chat-status", + "chat-status-line", + "chat-text", + "chat-text-line", + "chats", + "chats-2", + "chats-2-line", + "chats-line", + "checkbox-list", + "checkbox-list-detail", + "checkbox-list-detail-line", + "checkbox-list-line", + "cheese", + "cheese-line", + "chevron-down", + "chevron-down-circle", + "chevron-down-circle-line", + "chevron-down-line", + "chevron-left", + "chevron-left-circle", + "chevron-left-circle-line", + "chevron-left-line", + "chevron-right", + "chevron-right-circle", + "chevron-right-circle-line", + "chevron-right-line", + "chevron-up", + "chevron-up-circle", + "chevron-up-circle-line", + "chevron-up-line", + "chromecast", + "chromecast-line", + "church", + "church-line", + "clipboard", + "clipboard-check", + "clipboard-check-line", + "clipboard-line", + "clipboard-minus", + "clipboard-minus-line", + "clipboard-plus", + "clipboard-plus-line", + "clock", + "clock-line", + "clock-plus", + "clock-plus-line", + "close", + "close-line", + "cloud", + "cloud-line", + "code", + "code-block", + "code-block-line", + "code-line", + "coins", + "coins-line", + "comet", + "comet-line", + "comment", + "comment-2", + "comment-2-line", + "comment-2-text", + "comment-2-text-line", + "comment-line", + "comment-text", + "comment-text-line", + "comments", + "comments-2", + "comments-2-line", + "comments-line", + "community", + "community-line", + "compass-2", + "compass-2-line", + "cookie", + "cookie-line", + "covid", + "covid-exclamation", + "covid-exclamation-line", + "covid-line", + "covid-off", + "covid-off-line", + "cpu", + "cpu-line", + "creditcard", + "creditcard-hand", + "creditcard-hand-line", + "creditcard-line", + "creditcard-plus", + "creditcard-plus-line", + "crown", + "crown-line", + "cup", + "cup-line", + "curly-braces", + "curly-braces-line", + "data", + "data-line", + "data-minus", + "data-minus-line", + "data-plus", + "data-plus-line", + "delete-bin", + "delete-bin-line", + "distribute-horizontal", + "distribute-horizontal-line", + "distribute-vertical", + "distribute-vertical-line", + "divide", + "divide-line", + "document", + "document-award", + "document-award-line", + "document-line", + "dollar-circle", + "dollar-circle-line", + "door-enter", + "door-enter-line", + "door-exit", + "door-exit-line", + "earth-sphere", + "earth-sphere-line", + "edit-pen-2", + "edit-pen-2-line", + "edit-pen-4", + "edit-pen-4-line", + "eject", + "eject-line", + "eraser", + "eraser-line", + "etherium-circle", + "etherium-circle-line", + "euro-circle", + "euro-circle-line", + "eye", + "eye-line", + "eye-off", + "eye-off-line", + "ferris-wheel", + "ferris-wheel-line", + "file", + "file-line", + "file-minus", + "file-minus-line", + "file-plus", + "file-plus-line", + "filter", + "filter-line", + "fish", + "fish-line", + "flag", + "flag-line", + "flask", + "flask-line", + "flower-2", + "flower-2-line", + "folder", + "folder-check", + "folder-check-line", + "folder-line", + "folder-minus", + "folder-minus-line", + "folder-plus", + "folder-plus-line", + "font-size", + "font-size-line", + "forward-circle", + "forward-circle-line", + "forward-end-circle", + "forward-end-circle-line", + "git-branch", + "git-branch-line", + "git-commit", + "git-commit-line", + "git-compare", + "git-compare-line", + "git-fork", + "git-fork-line", + "git-merge", + "git-merge-line", + "git-pull", + "git-pull-line", + "glas-water", + "glas-water-line", + "globe-earth", + "globe-earth-2", + "globe-earth-2-line", + "globe-earth-line", + "globe-grid", + "globe-grid-line", + "hand", + "hand-line", + "hand-pointer", + "hand-pointer-2", + "hand-pointer-2-line", + "hand-pointer-event", + "hand-pointer-event-line", + "hand-pointer-line", + "hard-drive", + "hard-drive-line", + "headset", + "headset-line", + "heart", + "heart-line", + "home", + "home-analytics", + "home-analytics-line", + "home-line", + "home-simple", + "home-simple-line", + "image", + "image-circle", + "image-circle-line", + "image-circle-off", + "image-circle-off-line", + "image-circle-plus", + "image-circle-plus-line", + "image-circle-story", + "image-circle-story-line", + "image-frame", + "image-frame-line", + "image-in-picture", + "image-in-picture-line", + "image-line", + "image-multiple", + "image-multiple-line", + "image-off", + "image-off-line", + "image-photography", + "image-photography-line", + "image-plus", + "image-plus-line", + "incognito", + "incognito-line", + "info-circle", + "info-circle-line", + "iphone-old-apps", + "iphone-old-apps-line", + "iphone-x-apps", + "iphone-x-apps-line", + "italic", + "italic-line", + "key", + "key-line", + "keyboard", + "keyboard-line", + "laptop", + "laptop-line", + "leaf-3-angled", + "leaf-3-angled-line", + "library", + "library-line", + "lidquid-drop-waves-2", + "lidquid-drop-waves-2-line", + "lifebuoy", + "lifebuoy-line", + "lightbulb-shine", + "lightbulb-shine-line", + "lightning-bolt", + "lightning-bolt-line", + "line-height", + "line-height-line", + "link", + "link-circle", + "link-circle-line", + "link-line", + "lira-circle", + "lira-circle-line", + "list-box", + "list-box-line", + "lock", + "lock-line", + "lock-off", + "lock-off-line", + "login", + "login-half-circle", + "login-half-circle-line", + "login-line", + "logout", + "logout-half-circle", + "logout-half-circle-line", + "logout-line", + "mail", + "mail-line", + "map-marker", + "map-marker-area", + "map-marker-area-line", + "map-marker-line", + "map-marker-path", + "map-marker-path-line", + "map-marker-plus", + "map-marker-plus-line", + "map-simple", + "map-simple-destination", + "map-simple-destination-line", + "map-simple-line", + "map-simple-marker", + "map-simple-marker-line", + "map-simple-off", + "map-simple-off-line", + "maximize", + "maximize-line", + "megaphone", + "megaphone-line", + "menu", + "menu-expand-left", + "menu-expand-left-line", + "menu-expand-right", + "menu-expand-right-line", + "menu-line", + "microphone", + "microphone-line", + "minimize", + "minimize-line", + "minus", + "minus-five-circle", + "minus-five-circle-line", + "minus-line", + "minus-ten-circle", + "minus-ten-circle-line", + "money", + "money-hand", + "money-hand-line", + "money-line", + "money-minus", + "money-minus-line", + "money-plus", + "money-plus-line", + "monitor", + "monitor-line", + "moon", + "moon-line", + "more-menu", + "more-menu-line", + "more-menu-vertical", + "more-menu-vertical-line", + "mouse", + "mouse-line", + "multiply", + "multiply-line", + "music", + "music-line", + "music-note", + "music-note-line", + "next-circle", + "next-circle-line", + "note-text", + "note-text-line", + "note-text-minus", + "note-text-minus-line", + "note-text-plus", + "note-text-plus-line", + "noteblock", + "noteblock-line", + "noteblock-text", + "noteblock-text-line", + "open", + "open-line", + "paper-fold", + "paper-fold-line", + "paper-fold-text", + "paper-fold-text-line", + "paper-roll-2", + "paper-roll-2-line", + "paragraph", + "paragraph-line", + "pause-circle", + "pause-circle-line", + "percent", + "percent-line", + "phone", + "phone-dial", + "phone-dial-line", + "phone-hangup", + "phone-hangup-line", + "phone-incoming", + "phone-incoming-line", + "phone-line", + "phone-outgoing", + "phone-outgoing-line", + "phone-retro", + "phone-retro-line", + "phone-ring", + "phone-ring-line", + "pill", + "pill-line", + "pin", + "pin-line", + "pinwheel", + "pinwheel-line", + "planet", + "planet-line", + "planet-ring-2", + "planet-ring-2-line", + "planet-rocket", + "planet-rocket-line", + "play-circle", + "play-circle-line", + "playlist", + "playlist-line", + "plus", + "plus-five-circle", + "plus-five-circle-line", + "plus-line", + "plus-minus", + "plus-minus-2", + "plus-minus-2-line", + "plus-minus-line", + "plus-ten-circle", + "plus-ten-circle-line", + "pound-circle", + "pound-circle-line", + "presentation", + "presentation-chart", + "presentation-chart-line", + "presentation-line", + "presentation-play", + "presentation-play-line", + "printer", + "printer-line", + "pulse", + "pulse-line", + "puzzle", + "puzzle-line", + "qr-code", + "qr-code-line", + "question-circle", + "question-circle-line", + "radio-list", + "radio-list-line", + "receipt-text", + "receipt-text-line", + "redo", + "redo-line", + "reload", + "reload-circle", + "reload-circle-line", + "reload-line", + "remove-column", + "remove-column-line", + "remove-format", + "remove-format-line", + "remove-row", + "remove-row-line", + "repeat-circle", + "repeat-circle-line", + "restricted", + "restricted-line", + "robot", + "robot-line", + "rocket-3-start", + "rocket-3-start-line", + "rubel-circle", + "rubel-circle-line", + "ruler-2", + "ruler-2-line", + "rupee-circle", + "rupee-circle-line", + "save", + "save-line", + "scale-light", + "scale-light-line", + "scan-fingerprint", + "scan-fingerprint-line", + "scan-user", + "scan-user-line", + "scanner", + "scanner-line", + "scooter", + "scooter-line", + "script-prescription", + "script-prescription-line", + "scroll", + "scroll-line", + "scroll-text", + "scroll-text-line", + "search", + "search-line", + "search-minus", + "search-minus-line", + "search-plus", + "search-plus-line", + "send", + "send-line", + "server", + "server-line", + "settings-cog", + "settings-cog-check", + "settings-cog-check-line", + "settings-cog-line", + "settings-cog-plus", + "settings-cog-plus-line", + "share", + "share-circle", + "share-circle-line", + "share-line", + "shield", + "shield-line", + "shield-off", + "shield-off-line", + "shield-plus", + "shield-plus-line", + "ship", + "ship-line", + "shooting-star", + "shooting-star-line", + "shopping-cart", + "shopping-cart-line", + "sim-card", + "sim-card-line", + "sitemap", + "sitemap-line", + "skull", + "skull-line", + "smartphone-apps", + "smartphone-apps-line", + "speaker", + "speaker-line", + "stop-circle", + "stop-circle-line", + "strike-through", + "strike-through-line", + "suitcase", + "suitcase-2", + "suitcase-2-line", + "suitcase-3", + "suitcase-3-line", + "suitcase-line", + "t-shirt", + "t-shirt-line", + "table", + "table-heart", + "table-heart-line", + "table-line", + "table-plus", + "table-plus-line", + "tag", + "tag-line", + "tag-off", + "tag-off-line", + "telescope", + "telescope-line", + "test-tube-filled", + "test-tube-filled-line", + "text", + "text-align-center", + "text-align-center-line", + "text-align-justify", + "text-align-justify-line", + "text-align-left", + "text-align-left-line", + "text-align-right", + "text-align-right-line", + "text-line", + "text-wrap", + "text-wrap-line", + "textbox", + "textbox-line", + "textbox-minus", + "textbox-minus-line", + "textbox-plus", + "textbox-plus-line", + "ticket", + "ticket-check", + "ticket-check-line", + "ticket-line", + "ticket-text", + "ticket-text-line", + "tickets", + "tickets-line", + "timer", + "timer-line", + "tooltip", + "tooltip-line", + "tooltip-text", + "tooltip-text-line", + "tooltips", + "tooltips-2", + "tooltips-2-line", + "tooltips-line", + "tv-old", + "tv-old-line", + "umbrella", + "umbrella-line", + "underline", + "underline-2", + "underline-2-line", + "underline-line", + "undo", + "undo-line", + "unlock-open", + "unlock-open-line", + "usb", + "usb-line", + "user", + "user-box", + "user-box-line", + "user-line", + "users", + "users-line", + "ux-circle", + "ux-circle-line", + "video", + "video-line", + "video-minus", + "video-minus-line", + "video-plus", + "video-plus-line", + "view-columns", + "view-columns-line", + "view-rows", + "view-rows-line", + "watch", + "watch-line", + "yen-circle", + "yen-circle-line", + ]), +}; + +const ICON_TO_SET = new Map(); +for (const [iconset, icons] of Object.entries(ICONS)) { + for (const icon of icons) { + ICON_TO_SET.set(icon, iconset); + } +} + +class PluginIcon extends Component { + static observedAttributes = ["icon"]; + static cache = new Map(); + + cache = PluginIcon.cache; + + attributeChangedCallback() { + this.render(); + } + + fetchIcon(iconset, icon) { + const key = `${iconset}/${icon}`; + if (this.cache.has(key)) { + return this.cache.get(key); + } + const url = `/img/icons/${iconset}/${icon}.svg`; + const promise = + typeof globalThis.fetch === "function" + ? globalThis.fetch(url).then((response) => { + if (!response.ok) { + throw new Error( + `Failed to load icon "${key}": ${response.status}`, + ); + } + return response.text(); + }) + : Promise.reject(new Error("fetch-unavailable")); + promise.catch(() => {}); + this.cache.set(key, promise); + return promise; + } + + async render() { + const icon = this.getAttribute("icon"); + if (!icon) { + this.innerHTML = ""; + return; + } + const iconset = ICON_TO_SET.get(icon); + if (!iconset) { + console.warn(`Unknown icon "${icon}"`); + this.innerHTML = ""; + return; + } + try { + const svg = await this.fetchIcon(iconset, icon); + if (this.getAttribute("icon") !== icon) return; + this.innerHTML = svg; + } catch (error) { + if (this.getAttribute("icon") !== icon) return; + if (error.message !== "fetch-unavailable") { + console.warn(error.message); + } + this.innerHTML = ""; + } + } +} + +PluginIcon.register(); diff --git a/src/js/plugins/pluginRendering.js b/src/js/plugins/pluginRendering.js index e809c4e9..fd3e637b 100644 --- a/src/js/plugins/pluginRendering.js +++ b/src/js/plugins/pluginRendering.js @@ -1,7 +1,7 @@ -import { lightningBoltIconTemplate } from "/js/templates/icons/lightningBoltIcon.template.js"; import { showExternalLinkWarningModal } from "/js/modals.js"; import "/js/components/toggle-switch.js"; import "/js/components/plugin-profiles-list.js"; +import "/js/components/plugin-icon.js"; function isExternalHref(href) { try { @@ -38,6 +38,7 @@ const ALLOWED_TAGS = [ "textarea", "a", "profiles-list", + "plugin-icon", ]; const ALLOWED_EVENTS = ["click", "change", "input"]; @@ -63,6 +64,7 @@ const ALLOWED_ATTRS = [ "id", "href", "dids", + "icon", ]; function isSafeHref(value) { @@ -83,10 +85,6 @@ function isAllowedAttr(name) { ); } -const PLUGIN_ICON_TEMPLATES = { - "lightning-bolt": lightningBoltIconTemplate, -}; - function createVirtualEvent(e) { const target = e.target ?? {}; const virtualTarget = {}; @@ -344,12 +342,3 @@ export class PluginRenderer { return true; } } - -export function getPluginIconTemplate(icon) { - const template = PLUGIN_ICON_TEMPLATES[icon]; - if (!template) { - console.warn(`[plugins] requested unknown icon "${icon}"`); - return null; - } - return template; -} diff --git a/src/js/templates/icons/lightningBoltIcon.template.js b/src/js/templates/icons/lightningBoltIcon.template.js deleted file mode 100644 index e13dad77..00000000 --- a/src/js/templates/icons/lightningBoltIcon.template.js +++ /dev/null @@ -1,40 +0,0 @@ -import { html } from "/js/lib/lit-html.js"; -import { classnames } from "/js/utils.js"; - -// Source: https://github.com/halfmage/majesticons/blob/main/line/lightning-bolt-line.svg -export function lightningBoltIconTemplate({ filled = false } = {}) { - return html`
- ${filled - ? html` - - ` - : html` - - `} -
`; -} diff --git a/src/js/templates/sidebar.template.js b/src/js/templates/sidebar.template.js index 5ec7598d..9081674d 100644 --- a/src/js/templates/sidebar.template.js +++ b/src/js/templates/sidebar.template.js @@ -21,11 +21,10 @@ import { linkToLogin, } from "/js/navigation.js"; import "/js/components/animated-sidebar.js"; +import "/js/components/plugin-icon.js"; import { showInfoModal } from "/js/modals.js"; -import { getPluginIconTemplate } from "/js/plugins/pluginRendering.js"; function pluginSidebarItemTemplate({ entry }) { - const iconTemplate = getPluginIconTemplate(entry.icon); return html` `; diff --git a/tests/unit/specs/components/impro-icon.test.js b/tests/unit/specs/components/plugin-icon.test.js similarity index 56% rename from tests/unit/specs/components/impro-icon.test.js rename to tests/unit/specs/components/plugin-icon.test.js index 40af50c3..f8d1220f 100644 --- a/tests/unit/specs/components/impro-icon.test.js +++ b/tests/unit/specs/components/plugin-icon.test.js @@ -1,8 +1,8 @@ import { TestSuite } from "../../testSuite.js"; import { assert, assertEquals, mock, MockFetch } from "../../testHelpers.js"; -import "/js/components/impro-icon.js"; +import "/js/components/plugin-icon.js"; -const t = new TestSuite("ImproIcon"); +const t = new TestSuite("PluginIcon"); const SAMPLE_SVG = ''; @@ -31,51 +31,67 @@ function notFoundResponse() { t.beforeEach(() => { document.body.innerHTML = ""; + customElements.get("plugin-icon").cache = new Map(); }); -t.describe("ImproIcon - set defaulting", (it) => { - it("defaults set to majesticons when not specified", async () => { +t.describe("PluginIcon - iconset resolution", (it) => { + it("resolves an icon from the majesticons set", async () => { const fetch = new MockFetch(); fetch.__intercept("/img/icons/", async () => okResponse(SAMPLE_SVG)); globalThis.fetch = fetch; - const element = document.createElement("impro-icon"); - element.setAttribute("name", "default-set-icon"); + const element = document.createElement("plugin-icon"); + element.setAttribute("icon", "bell"); document.body.appendChild(element); await flush(); - assertEquals( - fetch.calls[0].url, - "/img/icons/majesticons/default-set-icon.svg", - ); + assertEquals(fetch.calls[0].url, "/img/icons/majesticons/bell.svg"); }); - it("uses the provided set", async () => { + it("resolves an icon from the custom set", async () => { const fetch = new MockFetch(); fetch.__intercept("/img/icons/", async () => okResponse(SAMPLE_SVG)); globalThis.fetch = fetch; - const element = document.createElement("impro-icon"); - element.setAttribute("set", "custom-set"); - element.setAttribute("name", "explicit-set-icon"); + const element = document.createElement("plugin-icon"); + element.setAttribute("icon", "verified-check"); document.body.appendChild(element); await flush(); - assertEquals( - fetch.calls[0].url, - "/img/icons/custom-set/explicit-set-icon.svg", - ); + assertEquals(fetch.calls[0].url, "/img/icons/custom/verified-check.svg"); + }); + + it("warns and renders nothing for an unknown icon", async () => { + const fetch = new MockFetch(); + globalThis.fetch = fetch; + + const originalWarn = console.warn; + const warnMock = mock(); + console.warn = warnMock; + + try { + const element = document.createElement("plugin-icon"); + element.setAttribute("icon", "not-a-real-icon"); + document.body.appendChild(element); + await flush(); + + assertEquals(fetch.calls.length, 0); + assertEquals(element.innerHTML, ""); + assertEquals(warnMock.calls.length, 1); + } finally { + console.warn = originalWarn; + } }); }); -t.describe("ImproIcon - rendering", (it) => { +t.describe("PluginIcon - rendering", (it) => { it("injects the fetched SVG markup", async () => { const fetch = new MockFetch(); fetch.__intercept("/img/icons/", async () => okResponse(SAMPLE_SVG)); globalThis.fetch = fetch; - const element = document.createElement("impro-icon"); - element.setAttribute("name", "render-test"); + const element = document.createElement("plugin-icon"); + element.setAttribute("icon", "cake"); document.body.appendChild(element); await flush(); @@ -84,11 +100,11 @@ t.describe("ImproIcon - rendering", (it) => { assertEquals(svg.getAttribute("viewBox"), "0 0 24 24"); }); - it("renders nothing when name is empty", async () => { + it("renders nothing when icon is empty", async () => { const fetch = new MockFetch(); globalThis.fetch = fetch; - const element = document.createElement("impro-icon"); + const element = document.createElement("plugin-icon"); document.body.appendChild(element); await flush(); @@ -96,62 +112,40 @@ t.describe("ImproIcon - rendering", (it) => { assertEquals(element.innerHTML, ""); }); - it("swaps the icon when name changes", async () => { + it("swaps the icon when icon changes", async () => { const fetch = new MockFetch(); - fetch.__intercept("/img/icons/majesticons/swap-first.svg", async () => + fetch.__intercept("/img/icons/majesticons/bus.svg", async () => okResponse(''), ); - fetch.__intercept("/img/icons/majesticons/swap-second.svg", async () => + fetch.__intercept("/img/icons/majesticons/car.svg", async () => okResponse(''), ); globalThis.fetch = fetch; - const element = document.createElement("impro-icon"); - element.setAttribute("name", "swap-first"); + const element = document.createElement("plugin-icon"); + element.setAttribute("icon", "bus"); document.body.appendChild(element); await flush(); assertEquals(element.querySelector("svg").id, "first"); - element.setAttribute("name", "swap-second"); + element.setAttribute("icon", "car"); await flush(); assertEquals(element.querySelector("svg").id, "second"); }); - - it("refetches when set changes", async () => { - const fetch = new MockFetch(); - fetch.__intercept("/img/icons/set-a/set-swap.svg", async () => - okResponse(''), - ); - fetch.__intercept("/img/icons/set-b/set-swap.svg", async () => - okResponse(''), - ); - globalThis.fetch = fetch; - - const element = document.createElement("impro-icon"); - element.setAttribute("set", "set-a"); - element.setAttribute("name", "set-swap"); - document.body.appendChild(element); - await flush(); - assertEquals(element.querySelector("svg").id, "a"); - - element.setAttribute("set", "set-b"); - await flush(); - assertEquals(element.querySelector("svg").id, "b"); - }); }); -t.describe("ImproIcon - caching", (it) => { +t.describe("PluginIcon - caching", (it) => { it("only fetches once when the same icon is rendered twice", async () => { const fetch = new MockFetch(); fetch.__intercept("/img/icons/", async () => okResponse(SAMPLE_SVG)); globalThis.fetch = fetch; - const first = document.createElement("impro-icon"); - first.setAttribute("name", "cache-shared"); + const first = document.createElement("plugin-icon"); + first.setAttribute("icon", "chat"); document.body.appendChild(first); - const second = document.createElement("impro-icon"); - second.setAttribute("name", "cache-shared"); + const second = document.createElement("plugin-icon"); + second.setAttribute("icon", "chat"); document.body.appendChild(second); await flush(); @@ -162,7 +156,7 @@ t.describe("ImproIcon - caching", (it) => { }); }); -t.describe("ImproIcon - error handling", (it) => { +t.describe("PluginIcon - error handling", (it) => { it("warns and renders nothing when the fetch 404s; does not retry", async () => { const fetch = new MockFetch(); fetch.__intercept("/img/icons/", async () => notFoundResponse()); @@ -173,16 +167,16 @@ t.describe("ImproIcon - error handling", (it) => { console.warn = warnMock; try { - const element = document.createElement("impro-icon"); - element.setAttribute("name", "missing-icon"); + const element = document.createElement("plugin-icon"); + element.setAttribute("icon", "moon"); document.body.appendChild(element); await flush(); assertEquals(element.innerHTML, ""); assertEquals(warnMock.calls.length, 1); - const retry = document.createElement("impro-icon"); - retry.setAttribute("name", "missing-icon"); + const retry = document.createElement("plugin-icon"); + retry.setAttribute("icon", "moon"); document.body.appendChild(retry); await flush(); diff --git a/tests/unit/specs/plugins/pluginRendering.test.js b/tests/unit/specs/plugins/pluginRendering.test.js index 402436ee..250db640 100644 --- a/tests/unit/specs/plugins/pluginRendering.test.js +++ b/tests/unit/specs/plugins/pluginRendering.test.js @@ -317,6 +317,30 @@ t.describe("PluginRenderer:root reconciliation", (it) => { }); }); +t.describe("PluginRenderer:plugin-icon", (it) => { + it("renders with the icon attribute passed through", () => { + const { bridge } = makeBridge(); + const renderer = new PluginRenderer(bridge, "demo"); + const element = renderer.createRoot().render({ + tag: "plugin-icon", + attrs: { icon: "bell" }, + }); + assertEquals(element.tagName.toLowerCase(), "plugin-icon"); + assertEquals(element.getAttribute("icon"), "bell"); + }); + + it("drops disallowed attributes from ", () => { + const { bridge } = makeBridge(); + const renderer = new PluginRenderer(bridge, "demo"); + const element = renderer.createRoot().render({ + tag: "plugin-icon", + attrs: { icon: "bell", onclick: "alert(1)" }, + }); + assert(!element.hasAttribute("onclick")); + assertEquals(element.getAttribute("icon"), "bell"); + }); +}); + t.describe("PluginRenderer:anchor tags", (it) => { it("renders with safe https href and forces target/rel", () => { const { bridge } = makeBridge(); diff --git a/tests/unit/specs/templates/sidebar.template.test.js b/tests/unit/specs/templates/sidebar.template.test.js index c6b139a7..37c79806 100644 --- a/tests/unit/specs/templates/sidebar.template.test.js +++ b/tests/unit/specs/templates/sidebar.template.test.js @@ -421,6 +421,25 @@ t.describe("sidebarTemplate - plugin sidebar items", (it) => { assert(invoked); }); + it("should render a with the entry's icon for each plugin item", () => { + const result = sidebarTemplate({ + isAuthenticated: true, + currentUser: mockUser, + pluginSidebarItems: [ + { title: "Plugin One", icon: "lightning-bolt", invoke: () => {} }, + { title: "Plugin Two", icon: "bell", invoke: () => {} }, + ], + }); + const container = document.createElement("div"); + render(result, container); + const icons = container.querySelectorAll( + ".sidebar-plugin-nav-item plugin-icon", + ); + assertEquals(icons.length, 2); + assertEquals(icons[0].getAttribute("icon"), "lightning-bolt"); + assertEquals(icons[1].getAttribute("icon"), "bell"); + }); + it("should not render plugin sidebar items in logged out sidebar", () => { const result = sidebarTemplate({ isAuthenticated: false, -- 2.51.2