Files
stats/apps/start/src/components/facehash/avatar-fallback.tsx
Carl-Gerhard Lindesvärd f1c85c53cf fix: mostly UI imporvements
2026-01-30 08:48:40 +01:00

162 lines
3.5 KiB
TypeScript

import * as React from 'react';
import { useAvatarContext } from './avatar';
import { Facehash, type FacehashProps } from './facehash';
const WHITESPACE_REGEX = /\s+/;
export type AvatarFallbackProps = Omit<
React.HTMLAttributes<HTMLSpanElement>,
'children'
> & {
/**
* The name to derive initials and Facehash from.
*/
name?: string;
/**
* Delay in milliseconds before showing the fallback.
* Useful to prevent flashing when images load quickly.
* @default 0
*/
delayMs?: number;
/**
* Custom children to render instead of initials or Facehash.
*/
children?: React.ReactNode;
/**
* Use the Facehash component as fallback instead of initials.
* @default true
*/
facehash?: boolean;
/**
* Use Tailwind group-hover for hover detection.
* When true, hover effect triggers when a parent with "group" class is hovered.
* @default false
*/
groupHover?: boolean;
/**
* Props to pass to the Facehash component.
*/
facehashProps?: Omit<FacehashProps, 'name'>;
};
/**
* Extracts initials from a name string.
*/
function getInitials(name: string): string {
const parts = name.trim().split(WHITESPACE_REGEX);
if (parts.length === 0) {
return '';
}
if (parts.length === 1) {
return parts[0]?.charAt(0).toUpperCase() || '';
}
const firstInitial = parts[0]?.charAt(0) || '';
const lastInitial = parts.at(-1)?.charAt(0) || '';
return (firstInitial + lastInitial).toUpperCase();
}
/**
* Fallback component that displays when the image fails to load.
* Uses Facehash by default, can show initials or custom content.
*/
export const AvatarFallback = React.forwardRef<
HTMLSpanElement,
AvatarFallbackProps
>(
(
{
name = '',
delayMs = 0,
children,
facehash = true,
groupHover = false,
facehashProps,
className,
style,
...props
},
ref,
) => {
const { imageLoadingStatus } = useAvatarContext();
const [canRender, setCanRender] = React.useState(delayMs === 0);
React.useEffect(() => {
if (delayMs > 0) {
const timerId = window.setTimeout(() => setCanRender(true), delayMs);
return () => window.clearTimeout(timerId);
}
}, [delayMs]);
const initials = React.useMemo(() => getInitials(name), [name]);
const shouldRender =
canRender &&
imageLoadingStatus !== 'loaded' &&
imageLoadingStatus !== 'loading';
if (!shouldRender) {
return null;
}
// Custom children take precedence
if (children) {
return (
<span
ref={ref}
className={className}
style={style}
data-avatar-fallback=""
{...props}
>
{children}
</span>
);
}
// Facehash mode (default)
if (facehash) {
return (
<Facehash
ref={ref as React.Ref<HTMLDivElement>}
name={name || '?'}
size="100%"
groupHover={groupHover}
{...facehashProps}
style={{
...style,
}}
{...props}
/>
);
}
// Initials mode
return (
<span
ref={ref}
className={className}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
...style,
}}
data-avatar-fallback=""
{...props}
>
{initials}
</span>
);
},
);
AvatarFallback.displayName = 'AvatarFallback';