optimise doc icon loading

This commit is contained in:
swithek 2026-09-06 20:42:41 +02:00
parent 3d07487e76
commit 4e7e2595d4
5 changed files with 169 additions and 36 deletions

View File

@ -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 `<Icon>`: [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 `<p>`, 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

View File

@ -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: {

View File

@ -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<string | null>(null)
// the one tooltip the grid shares, anchored to the hovered cell
const tooltip = ref<{ name: string; cell: HTMLElement } | null>(null)
let tooltipTimer: ReturnType<typeof setTimeout> | 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<HTMLElement>("[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"
>
<ShadcnUiTooltip
:delay-duration="1000"
:open="tooltipOpen === item.id"
@update:open="(v) => (tooltipOpen = v ? item.id : null)"
<!--the scroller patches each recycled cell on every scroll, so
a cell has to be cheap to patch: plain elements, no components-->
<button
type="button"
:class="cn(buttonVariants({ variant: 'ghost', size: 'icon' }))"
:style="{
width: `${ITEM_SIZE}px`,
height: `${ITEM_SIZE}px`,
}"
:aria-label="item.name"
:data-name="item.name"
@click="selectIcon(item.id)"
>
<ShadcnUiTooltipTrigger as-child>
<ShadcnUiButton
:style="{
width: `${ITEM_SIZE}px`,
height: `${ITEM_SIZE}px`,
}"
size="icon"
variant="ghost"
@click="selectIcon(item.id)"
>
<Icon class="size-[20px]" :name="item.id" />
</ShadcnUiButton>
</ShadcnUiTooltipTrigger>
<ShadcnUiTooltipContent
align="center"
side="bottom"
hide-when-detached
position-strategy="absolute"
sticky="always"
class="px-1.5 py-1"
>
<span>{{ item.name }}</span>
</ShadcnUiTooltipContent>
</ShadcnUiTooltip>
<span
class="iconify size-[20px]"
:class="cssSelectorPrefix + item.id"
/>
</button>
</RecycleScroller>
<ShadcnUiTooltip :open="tooltip !== null" :delay-duration="0">
<!--the trigger registers the anchor; the hovered cell, not the
trigger itself, is what the content positions against-->
<ShadcnUiTooltipTrigger
as="span"
class="hidden"
:reference="tooltip?.cell"
/>
<ShadcnUiTooltipContent
align="center"
side="bottom"
hide-when-detached
position-strategy="absolute"
sticky="always"
class="px-1.5 py-1"
>
<span>{{ tooltip?.name }}</span>
</ShadcnUiTooltipContent>
</ShadcnUiTooltip>
</template>
</div>
</div>

View File

@ -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"],

View File

@ -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 <Icon>, 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<IconOptions> = 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<string, IconifyJSON>()
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
}