From 4e7e2595d4d7d2fee467088606737dbd2b31d40e Mon Sep 17 00:00:00 2001 From: swithek <52840391+swithek@users.noreply.github.com> Date: Sun, 6 Sep 2026 20:42:41 +0200 Subject: [PATCH] optimise doc icon loading --- web/CLAUDE.md | 2 +- .../editor/IconPickerProvider.nuxt.test.ts | 4 +- .../components/editor/IconPickerProvider.vue | 120 +++++++++++++----- web/app/utils/icon.ts | 2 +- web/modules/icon-picker-css.ts | 77 +++++++++++ 5 files changed, 169 insertions(+), 36 deletions(-) create mode 100644 web/modules/icon-picker-css.ts diff --git a/web/CLAUDE.md b/web/CLAUDE.md index d112f236..601078e8 100644 --- a/web/CLAUDE.md +++ b/web/CLAUDE.md @@ -116,7 +116,7 @@ Form validation (vee-validate) uses its own internal messages — see [README.md - **shadcn-vue** components live in [app/components/shadcn/ui/](app/components/shadcn/ui/) with prefix `ShadcnUi`. Configured in [components.json](components.json). - **Tailwind v4** via `@tailwindcss/vite`, theme in [app/assets/css/main.css](app/assets/css/main.css). -- Icons via `@nuxt/icon` (CSS mode). The client bundle holds the icons the scanner finds in app code, plus — on the desktop build only — the document title icons listed by `selectableIconList()` in [app/utils/icon.ts](app/utils/icon.ts). The web build resolves those on demand through the server so they stay off the startup path. Custom SVGs live in [app/assets/custom-icons/](app/assets/custom-icons/) and are reachable under the `custom-icons:` prefix. +- Icons via `@nuxt/icon` (CSS mode). The client bundle holds the icons the scanner finds in app code, plus — on the desktop build only — the document title icons listed by `selectableIconList()` in [app/utils/icon.ts](app/utils/icon.ts). The web build resolves those on demand through the server so they stay off the startup path. The picker itself does not go through ``: [modules/icon-picker-css.ts](modules/icon-picker-css.ts) generates one stylesheet with a rule per selectable icon, served as `virtual:icon-picker.css` and imported by the picker provider once the page's load event has fired. Custom SVGs live in [app/assets/custom-icons/](app/assets/custom-icons/) and are reachable under the `custom-icons:` prefix. - **Open a11y bug: dialogs have no `DialogDescription`.** Every modal renders its description as a plain `

`, so the `aria-describedby` reka-ui puts on `DialogContent` points at nothing — screen readers get no description, and reka-ui warns once per mount. **This is scheduled to be fixed, not accepted.** The warning is suppressed in [vitest.config.ts](vitest.config.ts)'s `onConsoleLog` purely to keep test output readable in the meantime; that branch comes out with the fix, and the warning is still visible in `nuxt dev`. Note that routing the text through `ShadcnUiDialogDescription` is *not* a drop-in fix: the wrapper runs its class through `cn()`, and default tailwind-merge reads the custom `text-2sm` as a colour utility and drops it in favour of the wrapper's `text-sm`. Closing this needs either reka-ui's `DialogDescription` imported directly (no `cn()`) or `extendTailwindMerge` teaching `cn()` the custom text scale. ## Formatting & TS diff --git a/web/app/components/editor/IconPickerProvider.nuxt.test.ts b/web/app/components/editor/IconPickerProvider.nuxt.test.ts index c6426b00..245b9cce 100644 --- a/web/app/components/editor/IconPickerProvider.nuxt.test.ts +++ b/web/app/components/editor/IconPickerProvider.nuxt.test.ts @@ -30,8 +30,8 @@ vi.mock("vue-virtual-scroller", async () => { // the provider installs the icon-picker context that its children reach // through useIconPicker, so an IconPicker inside the slot is what opens -// it. Each icon in the list carries a tooltip, whose context the app -// installs once at page level. +// it. The grid's shared tooltip needs the context the app installs once +// at page level. function mountProvider() { return mountUnderTooltipProvider(IconPickerProvider, { slots: { diff --git a/web/app/components/editor/IconPickerProvider.vue b/web/app/components/editor/IconPickerProvider.vue index 9588aeb5..c308492b 100644 --- a/web/app/components/editor/IconPickerProvider.vue +++ b/web/app/components/editor/IconPickerProvider.vue @@ -6,6 +6,7 @@ import { offset, shift, } from "@floating-ui/dom" +import { buttonVariants } from "~/components/shadcn/ui/button" import { cn } from "~/lib/utils" // With defineAsyncComponent, Vue doesn't load the actual module until it's @@ -21,11 +22,15 @@ const RecycleScroller = defineAsyncComponent(() => const ITEM_SIZE = 28 const GRID_ITEMS_PER_ROW = 12 const GRID_WIDTH = ITEM_SIZE * GRID_ITEMS_PER_ROW +const TOOLTIP_DELAY_MS = 1000 const { open, anchor, closeIconPicker, selectIcon } = useIconPicker() +const { cssSelectorPrefix } = useAppConfig().icon const iconFilter = ref("") -const tooltipOpen = ref(null) +// the one tooltip the grid shares, anchored to the hovered cell +const tooltip = ref<{ name: string; cell: HTMLElement } | null>(null) +let tooltipTimer: ReturnType | null = null const icons = computed< { @@ -43,6 +48,18 @@ const icons = computed< const floatingElem = useTemplateRef("icon-picker") let cleanupAutoUpdate: (() => void) | null = null +// the icons' stylesheet is large and only the picker needs it, so it loads +// once the page's own load has finished rather than competing with it +onMounted(() => { + const load = () => void import("virtual:icon-picker.css") + + if (document.readyState === "complete") { + load() + } else { + window.addEventListener("load", load, { once: true }) + } +}) + onBeforeUnmount(() => { document.removeEventListener("mousedown", handleClickOutside) document.removeEventListener("keydown", handleEscape) @@ -52,7 +69,7 @@ onBeforeUnmount(() => { watch(open, (newV) => { if (newV) { iconFilter.value = "" - tooltipOpen.value = null + hideTooltip() void nextTick(() => { document.addEventListener("mousedown", handleClickOutside) @@ -94,7 +111,35 @@ async function updatePosition() { } function handleScroll() { - tooltipOpen.value = null + hideTooltip() +} + +function hideTooltip() { + if (tooltipTimer) { + clearTimeout(tooltipTimer) + tooltipTimer = null + } + + tooltip.value = null +} + +// one delegated listener for the whole grid, so a cell is a plain button +function handleGridHover(e: MouseEvent) { + const cell = (e.target as HTMLElement).closest("[data-name]") + if (!cell) { + return + } + + const name = cell.dataset.name ?? "" + if (tooltip.value?.name === name) { + return + } + + hideTooltip() + + tooltipTimer = setTimeout(() => { + tooltip.value = { name, cell } + }, TOOLTIP_DELAY_MS) } function handleClickOutside(e: MouseEvent) { @@ -179,40 +224,51 @@ function handleEscape(e: KeyboardEvent) { :item-size="ITEM_SIZE" :item-secondary-size="ITEM_SIZE" :grid-items="GRID_ITEMS_PER_ROW" - :buffer="400" + :buffer="600" key-field="id" + skip-hover @scroll="handleScroll" + @mouseover="handleGridHover" + @mouseleave="hideTooltip" > - + + + + diff --git a/web/app/utils/icon.ts b/web/app/utils/icon.ts index 57c587a5..d2942028 100644 --- a/web/app/utils/icon.ts +++ b/web/app/utils/icon.ts @@ -648,7 +648,7 @@ const icons: (string | [id: string, name: string])[] = [ "simple-icons:julia", "simple-icons:webassembly", "simple-icons:powershell", - "mdi:bash", + ["simple-icons:gnubash", "bash"], // frameworks & tooling ["simple-icons:vuedotjs", "vue.js"], diff --git a/web/modules/icon-picker-css.ts b/web/modules/icon-picker-css.ts new file mode 100644 index 00000000..6150f54d --- /dev/null +++ b/web/modules/icon-picker-css.ts @@ -0,0 +1,77 @@ +import { readFileSync } from "node:fs" +import { createRequire } from "node:module" +import type { IconifyJSON } from "@iconify/types" +import type { ModuleOptions as IconOptions } from "@nuxt/icon" +import { getIconCSS, getIconData } from "@iconify/utils" +import { addVitePlugin, defineNuxtModule } from "@nuxt/kit" +import { selectableIconList } from "../app/utils/icon" + +// the icon picker styles every selectable icon from one stylesheet built +// here out of the installed icon packs, so scrolling it never waits on a +// per-icon fetch or style injection. The selectors are the ones @nuxt/icon +// generates for , so a picked icon and its sidebar rendering share a +// rule. Served as the virtual module "virtual:icon-picker.css" +const VIRTUAL_ID = "virtual:icon-picker.css" + +export default defineNuxtModule({ + meta: { name: "icon-picker-css" }, + setup(_options, nuxt) { + const icon: Partial = nuxt.options.icon || {} + const { cssSelectorPrefix = "i-", cssLayer, cssWherePseudo = true } = icon + + addVitePlugin({ + name: "icon-picker-css", + resolveId(id) { + if (id === VIRTUAL_ID) { + return VIRTUAL_ID + } + }, + load(id) { + if (id === VIRTUAL_ID) { + return buildStylesheet(cssSelectorPrefix, cssLayer, cssWherePseudo) + } + }, + }) + }, +}) + +function buildStylesheet( + selectorPrefix: string, + layer: string | undefined, + wherePseudo: boolean, +) { + const require = createRequire(import.meta.url) + const collections = new Map() + + const rules = selectableIconList().map((id) => { + const [collection, name] = id.split(":") as [string, string] + let set = collections.get(collection) + + if (!set) { + set = JSON.parse( + readFileSync( + require.resolve(`@iconify-json/${collection}/icons.json`), + "utf8", + ), + ) as IconifyJSON + collections.set(collection, set) + } + + const data = getIconData(set, name) + if (!data) { + throw new Error(`icon ${id} is not in its installed collection`) + } + + // the class name escaping @nuxt/icon applies to the same selector + const selector = "." + (selectorPrefix + id).replace(/([^\w-])/g, "\\$1") + + return getIconCSS(data, { + iconSelector: wherePseudo ? `:where(${selector})` : selector, + format: "compressed", + }) + }) + + const css = rules.join("") + + return layer ? `@layer ${layer}{${css}}` : css +}