diff --git a/src/lib/components/icon/Icon.component.tsx b/src/lib/components/icon/Icon.component.tsx index 9371f1b776..a2dcfdc584 100644 --- a/src/lib/components/icon/Icon.component.tsx +++ b/src/lib/components/icon/Icon.component.tsx @@ -9,7 +9,7 @@ import { } from 'react'; import styled, { css } from 'styled-components'; import type { CoreUITheme } from '../../style/theme'; -import { Loader } from '../loader/Loader.component'; +import { LoaderIcon } from '../../icons/scality-loading'; import { Bucket, Buckets, RemoteGroup, RemoteUser } from './CustomsIcons'; import { iconTable } from './iconTable'; @@ -76,10 +76,27 @@ type Props = { title?: string; }; +// Mirrors the default spinner color (see Loader.component.tsx) so the +// fallback looks identical to a real Loader while the icon resolves. +const LOADER_SPINNER_COLOR = '#A14FBF'; + +// The spinner shown while a cold icon loads. Sized in em so it scales with the +// fa-${size} font-size of the fallback box, matching the glyph it stands in for — +// unlike , whose container forces a fixed px svg and resets font-size. +const FallbackSpinner = styled.span` + display: inline-flex; + svg { + width: 1em; + height: 1em; + fill: ${LOADER_SPINNER_COLOR}; + } +`; + const DelayedFallback = ({ + size, children, ...rest -}: PropsWithChildren>) => { +}: PropsWithChildren, 'size'> & { size?: SizeProp }>) => { const [show, setShow] = useState(false); useEffect(() => { let timeout = setTimeout(() => setShow(true), 300); @@ -88,7 +105,25 @@ const DelayedFallback = ({ }; }, []); - return {show && children}; + // Reserve the icon's box so a cold-loading icon doesn't paint zero-width and shift + // neighbouring content. svg-inline--fa gives height:1em and the icon baseline, and + // fa-${size} scales font-size so 1em tracks the requested size. Width has no intrinsic + // SVG to derive from, so set a 1em square — matches the glyph height exactly and + // approximates its (variable) width without altering how loaded icons render. + return ( + + {show && + (children ?? ( + + + + ))} + + ); }; export const IconWrapper = styled.div<{ size: SizeProp }>` @@ -137,23 +172,38 @@ function NonWrappedIcon({ const iconInfo = iconTable[name] || customIcons[name]; if (!iconInfo) throw new Error(`${name}: is not a valid icon.`); - // Loaded fortawesome icon if not a custom icon - const [icon, setIcon] = useState(); + const [fontAwesomeType, iconClass] = customIcons[name] + ? [] + : (() => { + const [type, cls] = iconInfo.split(' '); + return [ + type === 'far' ? 'free-regular-svg-icons' : 'free-solid-svg-icons', + cls, + ]; + })(); + const cacheKey = iconClass ? `${fontAwesomeType}/${iconClass}` : undefined; + + // Seed from the module-level cache synchronously so an already-loaded icon paints on the + // first render. Reading the cache only inside the effect (which runs after paint) makes a + // freshly-mounted icon — e.g. a virtualized table row scrolling into view — render the empty + // zero-width fallback for a frame, shifting neighbouring content before the real glyph appears. + const [icon, setIcon] = useState(() => + cacheKey ? iconCache[cacheKey] : undefined, + ); useEffect(() => { - if (customIcons[name]) { + if (!cacheKey || !iconClass) { return; } - - const [iconType, iconClass] = iconInfo.split(' '); - const fontAwesomeType = - iconType === 'far' ? 'free-regular-svg-icons' : 'free-solid-svg-icons'; - const cacheKey = `${fontAwesomeType}/${iconClass}`; if (iconCache[cacheKey]) { setIcon(iconCache[cacheKey]); - return () => setIcon(undefined); + return; } + // The name changed to an icon we haven't loaded yet: drop the previous glyph so the + // reserved fallback shows instead of a stale icon while the dynamic import resolves. + setIcon(undefined); + // Handle FontAwesome icons with dynamic import import( /* webpackExclude: /import\.macro\.js$/ */ @@ -164,8 +214,7 @@ function NonWrappedIcon({ }).catch((err) => { console.warn(`Icon ${iconClass} could not be loaded:`, err.message); }); - return () => setIcon(undefined); - }, [name, iconInfo]); + }, [cacheKey, fontAwesomeType, iconClass]); // Icons are decorative by default (aria-hidden: true) // If ariaLabel is provided, the icon is meaningful (aria-hidden: false) @@ -175,9 +224,7 @@ function NonWrappedIcon({ if (!icon && !customIcons[name]) { return ( - - - + ); }