Time to make these icons re-<use>-able

pull/1296/head
Lim Chee Aun 2025-09-27 20:07:22 +08:00
rodzic 27d5945c18
commit 808ae3b6b1
3 zmienionych plików z 152 dodań i 70 usunięć

Wyświetl plik

@ -0,0 +1,110 @@
import { createContext } from 'preact';
import { memo } from 'preact/compat';
import { useCallback, useContext, useState } from 'preact/hooks';
const IconSpriteContext = createContext();
export const ICON_NAMESPACE = 'sprite-icon';
export function IconSpriteProvider({ children }) {
const [loadedIcons, setLoadedIcons] = useState(new Set());
const [iconData, setIconData] = useState({});
const loadIcon = useCallback(
async (iconName) => {
if (loadedIcons.has(iconName)) {
return;
}
try {
const { ICONS } = await import('./ICONS');
const iconBlock = ICONS[iconName];
if (!iconBlock) {
console.warn(`Icon ${iconName} not found`);
return;
}
let iconModule;
if (Array.isArray(iconBlock)) {
iconModule = iconBlock[0];
} else if (typeof iconBlock === 'object') {
iconModule = iconBlock.module;
} else {
iconModule = iconBlock;
}
const iconResult = await iconModule();
const iconDataResult = iconResult.default;
setIconData((prev) => ({ ...prev, [iconName]: iconDataResult }));
setLoadedIcons((prev) => new Set([...prev, iconName]));
} catch (error) {
console.warn(`Failed to load icon ${iconName}:`, error);
}
},
[loadedIcons],
);
const isIconLoaded = useCallback(
(iconName) => loadedIcons.has(iconName),
[loadedIcons],
);
const contextValue = {
loadIcon,
isIconLoaded,
loadedIcons,
iconData,
};
return (
<IconSpriteContext.Provider value={contextValue}>
{children}
<IconSprite />
</IconSpriteContext.Provider>
);
}
function IconSprite() {
const { loadedIcons, iconData } = useIconSprite();
if (loadedIcons.size === 0) {
return null;
}
return (
<svg style={{ display: 'none' }} aria-hidden="true">
<defs>
{Array.from(loadedIcons).map((iconName) => {
const data = iconData[iconName];
if (!data) return null;
return <Symbol key={iconName} iconName={iconName} data={data} />;
})}
</defs>
</svg>
);
}
const Symbol = memo(
function ({ iconName, data }) {
return (
<symbol
id={`${ICON_NAMESPACE}-${iconName}`}
viewBox={`0 0 ${data.width} ${data.height}`}
dangerouslySetInnerHTML={{ __html: data.body }}
/>
);
},
(prevProps, nextProps) => {
return prevProps.iconName === nextProps.iconName;
},
);
export function useIconSprite() {
const context = useContext(IconSpriteContext);
if (!context) {
throw new Error('useIconSprite must be used within IconSpriteProvider');
}
return context;
}

Wyświetl plik

@ -1,9 +1,8 @@
import moize from 'moize';
import { useEffect, useRef, useState } from 'preact/hooks';
import escapeHTML from '../utils/escape-html';
import { memo } from 'preact/compat';
import { useEffect } from 'preact/hooks';
import { ICONS } from './ICONS';
import { ICON_NAMESPACE, useIconSprite } from './icon-sprite-manager';
const SIZES = {
xs: 8,
@ -14,40 +13,7 @@ const SIZES = {
xxl: 32,
};
const ICONDATA = {};
// Memoize the dangerouslySetInnerHTML of the SVGs
const INVALID_ID_CHARS_REGEX = /[^a-zA-Z0-9]/g;
const SVGICon = moize(
function ({ icon, title, width, height, body, rotate, flip }) {
const titleID = title?.replace(INVALID_ID_CHARS_REGEX, '-') || '';
const id = `icon-${icon}-${titleID}`;
const html = title
? `<title id="${id}">${escapeHTML(title)}</title>${body}`
: body;
return (
<svg
role={title ? 'img' : 'presentation'}
aria-labelledby={id}
viewBox={`0 0 ${width} ${height}`}
dangerouslySetInnerHTML={{ __html: html }}
style={{
transform: `${rotate ? `rotate(${rotate})` : ''} ${
flip ? `scaleX(-1)` : ''
}`,
}}
/>
);
},
{
isShallowEqual: true,
maxSize: Object.keys(ICONS).length,
matchesArg: (cacheKeyArg, keyArg) =>
cacheKeyArg.icon === keyArg.icon &&
cacheKeyArg.title === keyArg.title &&
cacheKeyArg.body === keyArg.body,
},
);
function Icon({
icon,
@ -57,6 +23,9 @@ function Icon({
class: className = '',
style = {},
}) {
title = title || alt;
const { loadIcon, isIconLoaded } = useIconSprite();
if (!icon) return null;
const iconSize = SIZES[size];
@ -76,17 +45,16 @@ function Icon({
iconBlock = iconBlock.module;
}
const [iconData, setIconData] = useState(ICONDATA[icon]);
const currentIcon = useRef(icon);
const sanitizedTitle = title?.replace(INVALID_ID_CHARS_REGEX, '-');
const titleID = `${ICON_NAMESPACE}-title-${icon}-${sanitizedTitle}`;
useEffect(() => {
if (iconData && currentIcon.current === icon) return;
(async () => {
const iconB = await iconBlock();
setIconData(iconB.default);
ICONDATA[icon] = iconB.default;
})();
currentIcon.current = icon;
}, [icon]);
if (!isIconLoaded(icon)) {
loadIcon(icon);
}
}, [icon, isIconLoaded, loadIcon]);
const loaded = isIconLoaded(icon);
return (
<span
@ -97,31 +65,32 @@ function Icon({
...style,
}}
data-icon={icon}
title={loaded ? undefined : title || undefined}
>
{iconData && (
// <svg
// width={iconSize}
// height={iconSize}
// viewBox={`0 0 ${iconData.width} ${iconData.height}`}
// dangerouslySetInnerHTML={{ __html: iconData.body }}
// style={{
// transform: `${rotate ? `rotate(${rotate})` : ''} ${
// flip ? `scaleX(-1)` : ''
// }`,
// }}
// />
<SVGICon
icon={icon}
title={title || alt}
width={iconData.width}
height={iconData.height}
body={iconData.body}
rotate={rotate}
flip={flip}
/>
{loaded && (
<svg
width={iconSize}
height={iconSize}
role={title ? 'img' : 'presentation'}
aria-labelledby={titleID}
style={{
transform: `${rotate ? `rotate(${rotate})` : ''} ${
flip ? `scaleX(-1)` : ''
}`,
}}
>
{title ? <title id={titleID}>{title}</title> : null}
<use href={`#${ICON_NAMESPACE}-${icon}`} />
</svg>
)}
</span>
);
}
export default Icon;
export default memo(Icon, (prevProps, nextProps) => {
return (
prevProps.icon === nextProps.icon &&
prevProps.title === nextProps.title &&
prevProps.alt === nextProps.alt
);
});

Wyświetl plik

@ -11,6 +11,7 @@ import { render } from 'preact';
import { HashRouter } from 'react-router-dom';
import { App } from './app';
import { IconSpriteProvider } from './components/icon-sprite-manager';
import { initActivateLang } from './utils/lang';
initActivateLang();
@ -22,7 +23,9 @@ if (import.meta.env.DEV) {
render(
<I18nProvider i18n={i18n}>
<HashRouter>
<App />
<IconSpriteProvider>
<App />
</IconSpriteProvider>
</HashRouter>
</I18nProvider>,
document.getElementById('app'),