From ac1d412a8fd1bf0e26fac579c9ba1b82c28255ee Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Fri, 2 May 2025 10:59:15 -0400 Subject: [PATCH] Merge `default` and `system` libraries, fixes #901 - Pre-fetched icons for all libraries via the `fetched` property - `addFetched()` to add more prefetched icons to an existing library (needed for extensibility story) - Update docs - Update changelog - Separate unregistered and register library types --- docs/docs/components/icon.md | 54 ++++++++++++++--- docs/docs/resources/changelog.md | 8 +++ docs/docs/resources/contributing.md | 11 ++-- src/components/icon/icon.ts | 19 ++++-- src/components/icon/library.default.ts | 30 +++++++++- src/components/icon/library.system.ts | 58 ------------------ src/components/icon/library.ts | 43 +++++++++---- src/components/icon/types.d.ts | 33 ++++++++++ src/utilities/deep.ts | 83 ++++++++++++++++++++++++++ 9 files changed, 250 insertions(+), 89 deletions(-) delete mode 100644 src/components/icon/library.system.ts create mode 100644 src/components/icon/types.d.ts create mode 100644 src/utilities/deep.ts diff --git a/docs/docs/components/icon.md b/docs/docs/components/icon.md index 3f5c1b82e..c761306f8 100644 --- a/docs/docs/components/icon.md +++ b/docs/docs/components/icon.md @@ -183,7 +183,8 @@ Custom icons can be loaded individually with the `src` attribute. Only SVGs on a You can register additional icons to use with the `` component through icon libraries. Icon files can exist locally or on a CORS-enabled endpoint (e.g. a CDN). There is no limit to how many icon libraries you can register and there is no cost associated with registering them, as individual icons are only requested when they're used. -Web Awesome ships with two built-in icon libraries, `default` and `system`. The [default icon library](#customizing-the-default-library) is provided courtesy of [Font Awesome](https://fontawesome.com/). The [system icon library](#customizing-the-system-library) contains only a small subset of icons that are used internally by Web Awesome components. +Web Awesome ships with one built-in icon library, `default`. +The [default icon library](#customizing-the-default-library) is provided courtesy of [Font Awesome](https://fontawesome.com/). To register an additional icon library, use the `registerIconLibrary()` function that's exported from `dist/webawesome.js`. At a minimum, you must provide a name and a resolver function. The resolver function translates an icon name to a URL where the corresponding SVG file exists. Refer to the examples below to better understand how it works. @@ -627,18 +628,57 @@ For security reasons, browsers may apply the same-origin policy on `` eleme ``` -### Customizing the System Library +### Prefetched icons { #fetched } -The system library contains only the icons used internally by Web Awesome components. Unlike the default icon library, the system library does not rely on physical assets. Instead, its icons are hard-coded as data URIs into the resolver to ensure their availability. +You can use the `fetched` property in the icon library to provide the mapping of `(name, library, variant)` to SVG markup and avoid the HTTP request for the icons. -If you want to change the icons Web Awesome uses internally, you can register an icon library using the `system` name and a custom resolver. If you choose to do this, it's your responsibility to provide all of the icons that are required by components. You can reference `src/components/library.system.ts` for a complete list of system icons used by Web Awesome. +This is used internally in the `default` library to load the icons used internally in Web Awesome’s components really fast, +but you can take advantage of it too. + +If you want to change the default library used by Web Awesome, you are strongly advised to use the `fetched` property to provide prefetched versions of system icons. +You can reference `src/components/library.default.ts` for a complete list of system icons used by Web Awesome. + +Its value is an object literal as deep as the structure of the icon library. +E.g. in icon libraries with no variant or family, it will be a simple shallow object mapping icon names to SVG markup, +whereas in icon libraries with family and variant, it will be a nested object with the structure `{ family: { variant: { name: svg } } }` (see `src/components/library.default.ts` for an example of this). ```html -``` \ No newline at end of file +``` + +If you want to add more prefetched icons to an existing library, you can use the `addFetched` method. +It expects the same value as the `fetched` property. +For example, suppose you wanted to add `circle-info` and `triangle-exclamation` to the `default` library +so they load fast in callouts. +You can do this: + +```js +import {getIconLibrary} from '/dist/webawesome.js'; + +let defaultLibrary = getIconLibrary('default'); +defaultLibrary.addFetched({ + { + classic: { // family + regular: { // variant + 'circle-info': '...', + 'triangle-exclamation': '...' + // ... + } + } + } +}); +``` + +::: warning +Please note that sprite sheets and fetched icons are mutually exclusive. +If you set the `spriteSheet` property to `true`, the `fetched` property will be ignored. +::: diff --git a/docs/docs/resources/changelog.md b/docs/docs/resources/changelog.md index 6e7c81721..661d58bd4 100644 --- a/docs/docs/resources/changelog.md +++ b/docs/docs/resources/changelog.md @@ -14,6 +14,14 @@ During the alpha period, things might break! We take breaking changes very serio ## Next + +- All icon libraries can now declare **pre-fetched** icons and skip the HTTP request. +When these icons are used, the pre-fetched version is automatically used, with no additional opt-in. +- 🚨 BREAKING: No more `system` library, just use `default`. +The improvement above allowed us to fold Web Awesome’s own `system` icon library into the `default` library +so the performance benefits can be automatic and shared across all uses of the `default` library, +rather then requiring a conscious decision to use a different library. +This also makes WA components play better with different icon libraries and different default families and variants. - 🚨 BREAKING: Renamed `` to `` and improved compatibility for non-image content. - Fixed a bug that caused an undesired margin below radio groups diff --git a/docs/docs/resources/contributing.md b/docs/docs/resources/contributing.md index 0f72c1b5a..99605102a 100644 --- a/docs/docs/resources/contributing.md +++ b/docs/docs/resources/contributing.md @@ -352,13 +352,12 @@ Form controls should support submission and validation through the following con ### System Icons -Avoid inlining SVG icons inside of templates. If a component requires an icon, make sure `` is a dependency of the component and use the [system library](/components/icon#customizing-the-system-library): +Avoid inlining SVG icons inside of templates. +If a component requires an icon, make sure `` is a dependency of the component. -```html - -``` - -This will render the icons instantly whereas the default library will fetch them from a remote source. If an icon isn't available in the system library, you will need to add it to `library.system.ts`. Using the system library ensures that all icons load instantly and are customizable by users who wish to provide a custom resolver for the system library. +If it is not one of the [pre-fetched icons](/components/icon#fetched) in the `default` library, +you should add it. +This will render the icons instantly rather than fetching them from a remote source. ### Writing tests diff --git a/src/components/icon/icon.ts b/src/components/icon/icon.ts index d55f1f769..38f74b5a1 100644 --- a/src/components/icon/icon.ts +++ b/src/components/icon/icon.ts @@ -136,16 +136,23 @@ export default class WaIcon extends WebAwesomeElement { return this.svg; } - try { - fileData = await fetch(url, { mode: 'cors' }); - if (!fileData.ok) return fileData.status === 410 ? CACHEABLE_ERROR : RETRYABLE_ERROR; - } catch { - return RETRYABLE_ERROR; + let markup: string; + + if (library?.fetched?.[url]) { + markup = library.fetched[url] as string; + } else { + try { + fileData = await fetch(url, { mode: 'cors' }); + if (!fileData.ok) return fileData.status === 410 ? CACHEABLE_ERROR : RETRYABLE_ERROR; + markup = await fileData.text(); + } catch { + return RETRYABLE_ERROR; + } } try { const div = document.createElement('div'); - div.innerHTML = await fileData.text(); + div.innerHTML = markup; const svg = div.firstElementChild; if (svg?.tagName?.toLowerCase() !== 'svg') return CACHEABLE_ERROR; diff --git a/src/components/icon/library.default.ts b/src/components/icon/library.default.ts index 23d6847d7..81d20b9bc 100644 --- a/src/components/icon/library.default.ts +++ b/src/components/icon/library.default.ts @@ -1,5 +1,5 @@ import { getKitCode } from '../../utilities/base-path.js'; -import type { IconLibrary } from './library.js'; +import type { IconLibrary, IconLibraryCache } from './types.d.ts'; function getIconUrl(name: string, family: string, variant: string) { const kitCode = getKitCode(); @@ -38,11 +38,39 @@ function getIconUrl(name: string, family: string, variant: string) { : `https://ka-f.fontawesome.com/releases/v6.5.2/svgs/${folder}/${name}.svg`; } +// Icons used for internal components, prefetched for speed +export const fetched: IconLibraryCache<1, 3> = { + solid: { + check: ``, + 'chevron-down': ``, + 'chevron-left': ``, + 'chevron-right': ``, + circle: ``, + 'eye-dropper': ``, + 'grip-vertical': ``, + indeterminate: ``, + minus: ``, + pause: ``, + play: ``, + star: ``, + user: ``, + xmark: ``, + }, + regular: { + 'circle-xmark': ``, + copy: ``, + eye: ``, + 'eye-slash': ``, + }, +}; + const library: IconLibrary = { name: 'default', resolver: (name: string, family = 'classic', variant = 'solid') => { return getIconUrl(name, family, variant); }, + // @ts-expect-error + fetched, }; export default library; diff --git a/src/components/icon/library.system.ts b/src/components/icon/library.system.ts deleted file mode 100644 index 291194448..000000000 --- a/src/components/icon/library.system.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { IconLibrary } from './library.js'; - -function dataUri(svg: string) { - return `data:image/svg+xml,${encodeURIComponent(svg)}`; -} - -export const iconsByVariant: { [key: string]: { [key: string]: string } } = { - solid: { - check: ``, - 'chevron-down': ``, - 'chevron-left': ``, - 'chevron-right': ``, - circle: ``, - 'eye-dropper': ``, - 'grip-vertical': ``, - indeterminate: ``, - minus: ``, - pause: ``, - play: ``, - star: ``, - user: ``, - xmark: ``, - }, - regular: { - 'circle-xmark': ``, - copy: ``, - eye: ``, - 'eye-slash': ``, - }, -}; - -/** - * Union of all icons, across variants - */ -export const icons: { [key: string]: string } = Object.assign({}, ...Object.values(iconsByVariant)); - -// -// System icons are a separate library to ensure they're always available, regardless of how the default icon library is -// configured or if its icons resolve properly. All Web Awesome components must use the system library instead of the -// default library. -// -const systemLibrary: IconLibrary = { - name: 'system', - resolver: (name: string, family = 'classic', variant = 'solid') => { - if (family === 'classic') { - // Try given variant first, fall back to any variant - let svg = iconsByVariant[variant]?.[name]; - - if (svg) { - return dataUri(svg); - } - } - - return ''; - }, -}; - -export default systemLibrary; diff --git a/src/components/icon/library.ts b/src/components/icon/library.ts index 09838aff9..8a74ec508 100644 --- a/src/components/icon/library.ts +++ b/src/components/icon/library.ts @@ -1,19 +1,15 @@ +import { deepEntries } from '../../utilities/deep.js'; import type WaIcon from '../icon/icon.js'; import defaultLibrary from './library.default.js'; -import systemLibrary from './library.system.js'; +import type { IconLibrary, IconLibraryCache, IconLibraryResolver, UnregisteredIconLibrary } from './types.d.ts'; -export type IconLibraryResolver = (name: string, family: string, variant: string) => string; -export type IconLibraryMutator = (svg: SVGElement) => void; -export interface IconLibrary { - name: string; - resolver: IconLibraryResolver; - mutator?: IconLibraryMutator; - spriteSheet?: boolean; -} +export type { IconLibrary, IconLibraryCache, UnregisteredIconLibrary } from './types.d.ts'; -let registry: IconLibrary[] = [defaultLibrary, systemLibrary]; +let registry: IconLibrary[] = []; let watchedIcons: WaIcon[] = []; +registerIconLibrary('default', defaultLibrary); + /** Adds an icon to the list of watched icons. */ export function watchIcon(icon: WaIcon) { watchedIcons.push(icon); @@ -30,13 +26,21 @@ export function getIconLibrary(name?: string) { } /** Adds an icon library to the registry, or overrides an existing one. */ -export function registerIconLibrary(name: string, options: Omit) { +export function registerIconLibrary(name: string, options: UnregisteredIconLibrary) { unregisterIconLibrary(name); + registry.push({ name, resolver: options.resolver, mutator: options.mutator, spriteSheet: options.spriteSheet, + fetched: flattenIconLibraryCache(options.resolver, options.fetched), + addFetched(cache: IconLibraryCache<1, 3>) { + // Convert flat URL β†’ markup cache to deep family β†’ variant β†’ icon name β†’ markup cache + let flatCache = flattenIconLibraryCache(this.resolver, cache); + this.fetched ??= {}; + Object.assign(this.fetched, flatCache); + }, }); // Redraw watched icons @@ -47,6 +51,23 @@ export function registerIconLibrary(name: string, options: Omit): IconLibraryCache<1> { + if (!cache) { + return {}; + } + + return Object.fromEntries( + deepEntries(cache, { + transformPath: (path: string[]) => { + let name = path.pop()!; + let [family, variant] = path; + return [resolver(name, family, variant)]; + }, + }), + ); +} + /** Removes an icon library from the registry. */ export function unregisterIconLibrary(name: string) { registry = registry.filter(lib => lib.name !== name); diff --git a/src/components/icon/types.d.ts b/src/components/icon/types.d.ts new file mode 100644 index 000000000..f0c9621ad --- /dev/null +++ b/src/components/icon/types.d.ts @@ -0,0 +1,33 @@ +export type IconLibraryResolver = (name: string, family: string, variant: string) => string; +export type IconLibraryMutator = (svg: SVGElement) => void; + +// This is a utility for decrementing a number up to 3 by one +// e.g., Decrement[3] yields 2 +type Decrement = [never, 0, 1, 2]; + +// Record of string β†’ string or nested Record +export type IconLibraryCache = Min extends 0 + ? string | (Max extends 0 ? never : Record>) + : Record>; + +export interface UnregisteredIconLibrary { + resolver: IconLibraryResolver; + mutator?: IconLibraryMutator; + spriteSheet?: boolean; + + // Max depth: family β†’ variant β†’ icon name β†’ markup + // but may be shallower for libraries that don't use variants or families + fetched?: IconLibraryCache<1, 3>; +} + +// Registered icon library +export interface IconLibrary { + name: string; + resolver: IconLibraryResolver; + mutator?: IconLibraryMutator; + spriteSheet?: boolean; + + // One level only: URL β†’ markup + fetched?: IconLibraryCache<1>; + addFetched: (cache: IconLibraryCache<1, 3>) => void; +} diff --git a/src/utilities/deep.ts b/src/utilities/deep.ts new file mode 100644 index 000000000..cfc9b3b06 --- /dev/null +++ b/src/utilities/deep.ts @@ -0,0 +1,83 @@ +type Property = keyof any; +export type EachCallback = (value: any, key: Property, parent: object, path: Property[]) => any; + +export function isPlainObject(obj: any) { + return isObject(obj, 'Object'); +} + +export function isObject(obj: any, type: string) { + if (!obj || typeof obj !== 'object') { + return false; + } + + let proto = Object.getPrototypeOf(obj); + return proto.constructor?.name === type; +} + +/** + * Iterate over a deep array, recursively for plain objects + * @param obj The object to iterate over. Can be an array or a plain object, or even a primitive value. + * @param callback. value is === parent[key] + * @param parentObj The parent object of the current value Mainly used internally to facilitate recursion. + * @param key The key of the current value. Mainly used internally to facilitate recursion. + * @param path Any existing path (not including the key). Mainly used internally to facilitate recursion. + */ +export function deepEach(obj: any, callback: EachCallback, parentObj?: object, key?: Property, path: Property[] = []) { + if (key !== undefined) { + let ret = callback(obj, key, parentObj!, path); + + if (ret !== undefined) { + if (ret === false) { + // Do not descend further + return; + } + + // Overwrite value + // @ts-expect-error + parentObj[key] = ret; + obj = ret; + } + } + + let newPath = key !== undefined ? [...path, key] : path; + + if (Array.isArray(obj)) { + for (let i = 0; i < obj.length; i++) { + deepEach(obj[i], callback, obj, i, newPath); + } + } else if (isPlainObject(obj)) { + for (let key in obj) { + deepEach(obj[key], callback, obj, key, newPath); + } + } +} + +export type DeepEntriesOptions = { + filter?: EachCallback; + transformPath?: (path: Property[]) => Property[]; +}; + +/** + * Like Object.entries, but for deeply nested objects. + * For shallow objects the output is the same as Object.entries. + * @param obj The object to iterate over. + * @param options.filter - If this returns false, the entry is not added to the result. + * @param options.transformPath - Transform the path, e.g. to serialize it to a single key. + * @returns Array of arrays. In each array the last value is the value, and all values before that are the keys. + */ +export function deepEntries(obj: any, options: DeepEntriesOptions = {}): any[][] { + let { filter, transformPath } = options; + let entries: any[][] = []; + + deepEach(obj, (value, key, parent, path) => { + let included = filter?.(value, key, parent, path) ?? true; + + if (included) { + let fullPath = [...path, key]; + path = transformPath?.(fullPath) ?? fullPath; + entries.push([...fullPath, value]); + } + }); + + return entries; +}