Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 64 additions & 17 deletions src/lib/components/icon/Icon.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -76,10 +76,27 @@ type Props = {
title?: string;
};

// Mirrors the <Loader> 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 <Loader>, 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<HTMLProps<HTMLElement>>) => {
}: PropsWithChildren<Omit<HTMLProps<HTMLElement>, 'size'> & { size?: SizeProp }>) => {
const [show, setShow] = useState(false);
useEffect(() => {
let timeout = setTimeout(() => setShow(true), 300);
Expand All @@ -88,7 +105,25 @@ const DelayedFallback = ({
};
}, []);

return <i {...rest}>{show && children}</i>;
// 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 (
<i
{...rest}
className={`svg-inline--fa${size ? ` fa-${size}` : ''}`}
style={{ width: '1em' }}
>
{show &&
(children ?? (
<FallbackSpinner>
<LoaderIcon />
</FallbackSpinner>
))}
</i>
);
};

export const IconWrapper = styled.div<{ size: SizeProp }>`
Expand Down Expand Up @@ -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$/ */
Expand All @@ -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)
Expand All @@ -175,9 +224,7 @@ function NonWrappedIcon({

if (!icon && !customIcons[name]) {
return (
<DelayedFallback {...accessibilityProps}>
<Loader size="base" />
</DelayedFallback>
<DelayedFallback size={size} {...accessibilityProps} />
);
}

Expand Down
Loading