From a54a77fe01b05cb931e04a59bb4e06124de955ce Mon Sep 17 00:00:00 2001 From: Alexandru Comanescu Date: Sat, 21 Dec 2024 12:37:54 +0200 Subject: [PATCH 01/17] [Avatar] Add avatar component --- docs/reference/generated/avatar-fallback.json | 20 ++++ docs/reference/generated/avatar-image.json | 16 ++++ docs/reference/generated/avatar-root.json | 16 ++++ .../demos/hero/css-modules/index.module.css | 26 ++++++ .../avatar/demos/hero/css-modules/index.tsx | 14 +++ .../components/avatar/demos/hero/index.ts | 3 + .../avatar/demos/hero/tailwind/index.tsx | 13 +++ .../react/components/avatar/page.mdx | 26 ++++++ docs/src/nav.ts | 4 + package.json | 2 +- packages/react/package.json | 1 + .../avatar/fallback/AvatarFallback.test.tsx | 18 ++++ .../src/avatar/fallback/AvatarFallback.tsx | 83 +++++++++++++++++ .../src/avatar/image/AvatarImage.test.tsx | 18 ++++ .../react/src/avatar/image/AvatarImage.tsx | 92 +++++++++++++++++++ .../src/avatar/image/useImageLoadingStatus.ts | 40 ++++++++ packages/react/src/avatar/index.parts.ts | 3 + packages/react/src/avatar/index.ts | 1 + .../react/src/avatar/root/AvatarRoot.test.tsx | 12 +++ packages/react/src/avatar/root/AvatarRoot.tsx | 88 ++++++++++++++++++ .../src/avatar/root/AvatarRootContext.ts | 24 +++++ .../react/src/avatar/root/useAvatarRoot.ts | 33 +++++++ .../src/utils/defaultRenderFunctions.tsx | 3 + 23 files changed, 555 insertions(+), 1 deletion(-) create mode 100644 docs/reference/generated/avatar-fallback.json create mode 100644 docs/reference/generated/avatar-image.json create mode 100644 docs/reference/generated/avatar-root.json create mode 100644 docs/src/app/(public)/(content)/react/components/avatar/demos/hero/css-modules/index.module.css create mode 100644 docs/src/app/(public)/(content)/react/components/avatar/demos/hero/css-modules/index.tsx create mode 100644 docs/src/app/(public)/(content)/react/components/avatar/demos/hero/index.ts create mode 100644 docs/src/app/(public)/(content)/react/components/avatar/demos/hero/tailwind/index.tsx create mode 100644 docs/src/app/(public)/(content)/react/components/avatar/page.mdx create mode 100644 packages/react/src/avatar/fallback/AvatarFallback.test.tsx create mode 100644 packages/react/src/avatar/fallback/AvatarFallback.tsx create mode 100644 packages/react/src/avatar/image/AvatarImage.test.tsx create mode 100644 packages/react/src/avatar/image/AvatarImage.tsx create mode 100644 packages/react/src/avatar/image/useImageLoadingStatus.ts create mode 100644 packages/react/src/avatar/index.parts.ts create mode 100644 packages/react/src/avatar/index.ts create mode 100644 packages/react/src/avatar/root/AvatarRoot.test.tsx create mode 100644 packages/react/src/avatar/root/AvatarRoot.tsx create mode 100644 packages/react/src/avatar/root/AvatarRootContext.ts create mode 100644 packages/react/src/avatar/root/useAvatarRoot.ts diff --git a/docs/reference/generated/avatar-fallback.json b/docs/reference/generated/avatar-fallback.json new file mode 100644 index 0000000000..d58ebfd428 --- /dev/null +++ b/docs/reference/generated/avatar-fallback.json @@ -0,0 +1,20 @@ +{ + "name": "AvatarFallback", + "description": "Rendered when the image fails to load or when no image is provided.\nRenders a `` element.", + "props": { + "delayMs": { + "type": "number", + "description": "Time in milliseconds to wait before showing the fallback." + }, + "className": { + "type": "string | (state) => string", + "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." + }, + "render": { + "type": "React.ReactElement | (props, state) => React.ReactElement", + "description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render." + } + }, + "dataAttributes": {}, + "cssVariables": {} +} diff --git a/docs/reference/generated/avatar-image.json b/docs/reference/generated/avatar-image.json new file mode 100644 index 0000000000..f45c8d6c83 --- /dev/null +++ b/docs/reference/generated/avatar-image.json @@ -0,0 +1,16 @@ +{ + "name": "AvatarImage", + "description": "The image to be displayed in the avatar.\nRenders an `` element.", + "props": { + "className": { + "type": "string | (state) => string", + "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." + }, + "render": { + "type": "React.ReactElement | (props, state) => React.ReactElement", + "description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render." + } + }, + "dataAttributes": {}, + "cssVariables": {} +} diff --git a/docs/reference/generated/avatar-root.json b/docs/reference/generated/avatar-root.json new file mode 100644 index 0000000000..c80f61638a --- /dev/null +++ b/docs/reference/generated/avatar-root.json @@ -0,0 +1,16 @@ +{ + "name": "AvatarRoot", + "description": "Displays a user's profile picture, initials, or fallback icon.\nRenders a `` element.", + "props": { + "className": { + "type": "string | (state) => string", + "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." + }, + "render": { + "type": "React.ReactElement | (props, state) => React.ReactElement", + "description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render." + } + }, + "dataAttributes": {}, + "cssVariables": {} +} diff --git a/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/css-modules/index.module.css b/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/css-modules/index.module.css new file mode 100644 index 0000000000..9b4ebf09fd --- /dev/null +++ b/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/css-modules/index.module.css @@ -0,0 +1,26 @@ +.Root { + align-items: center; + background-color: #f0f0f0; + border-radius: 100%; + display: flex; + height: 64px; + justify-content: center; + overflow: hidden; + width: 64px; +} + +.Image { + height: 100%; + object-fit: cover; + width: 100%; +} + +.Fallback { + align-items: center; + color: #000; + display: flex; + font-weight: 500; + height: 100%; + justify-content: center; + width: 100%; +} \ No newline at end of file diff --git a/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/css-modules/index.tsx b/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/css-modules/index.tsx new file mode 100644 index 0000000000..0ef36b38ff --- /dev/null +++ b/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/css-modules/index.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { Avatar } from '@base-ui-components/react/avatar'; +import styles from './index.module.css'; + +export default function ExampleAvatar() { + return ( + + + + LT + + + ); +} diff --git a/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/index.ts b/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/index.ts new file mode 100644 index 0000000000..80097d6015 --- /dev/null +++ b/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/index.ts @@ -0,0 +1,3 @@ +'use client'; +export { default as CssModules } from './css-modules'; +export { default as Tailwind } from './tailwind'; diff --git a/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/tailwind/index.tsx b/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/tailwind/index.tsx new file mode 100644 index 0000000000..f3f07f68f5 --- /dev/null +++ b/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/tailwind/index.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { Avatar } from '@base-ui-components/react/avatar'; + +export default function ExampleAvatar() { + return ( + + + + LT + + + ); +} diff --git a/docs/src/app/(public)/(content)/react/components/avatar/page.mdx b/docs/src/app/(public)/(content)/react/components/avatar/page.mdx new file mode 100644 index 0000000000..a6a57cc3d5 --- /dev/null +++ b/docs/src/app/(public)/(content)/react/components/avatar/page.mdx @@ -0,0 +1,26 @@ +# Avatar + +An easily stylable avatar component. + + + + +## API reference + +Import the component and assemble its parts: + +```jsx title="Anatomy" +import { Avatar } from '@base-ui-components/react/avatar'; + + + + + LT + +; +``` + + diff --git a/docs/src/nav.ts b/docs/src/nav.ts index a90dec4fe1..32cdcf1ab0 100644 --- a/docs/src/nav.ts +++ b/docs/src/nav.ts @@ -48,6 +48,10 @@ export const nav = [ label: 'Alert Dialog', href: '/react/components/alert-dialog', }, + { + label: 'Avatar', + href: '/react/components/avatar', + }, { label: 'Checkbox', href: '/react/components/checkbox', diff --git a/package.json b/package.json index 32308d3389..e0bc484c02 100644 --- a/package.json +++ b/package.json @@ -175,7 +175,7 @@ "webpack-cli": "^5.1.4", "yargs": "^17.7.2" }, - "packageManager": "pnpm@9.14.4", + "packageManager": "pnpm@9.14.4+sha512.c8180b3fbe4e4bca02c94234717896b5529740a6cbadf19fa78254270403ea2f27d4e1d46a08a0f56c89b63dc8ebfd3ee53326da720273794e6200fcf0d184ab", "engines": { "pnpm": "9.14.4" }, diff --git a/packages/react/package.json b/packages/react/package.json index a8fb06db16..32842be63c 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -29,6 +29,7 @@ ".": "./src/index.ts", "./accordion": "./src/accordion/index.ts", "./alert-dialog": "./src/alert-dialog/index.ts", + "./avatar": "./src/avatar/index.ts", "./checkbox": "./src/checkbox/index.ts", "./checkbox-group": "./src/checkbox-group/index.ts", "./collapsible": "./src/collapsible/index.ts", diff --git a/packages/react/src/avatar/fallback/AvatarFallback.test.tsx b/packages/react/src/avatar/fallback/AvatarFallback.test.tsx new file mode 100644 index 0000000000..797476c9c7 --- /dev/null +++ b/packages/react/src/avatar/fallback/AvatarFallback.test.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { Avatar } from '@base-ui-components/react/avatar'; +import { describeConformance, createRenderer } from '#test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + render: (node) => { + return render( + + {node} + + ) + }, + refInstanceof: window.HTMLSpanElement, + })); +}); diff --git a/packages/react/src/avatar/fallback/AvatarFallback.tsx b/packages/react/src/avatar/fallback/AvatarFallback.tsx new file mode 100644 index 0000000000..cb86ce17ef --- /dev/null +++ b/packages/react/src/avatar/fallback/AvatarFallback.tsx @@ -0,0 +1,83 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { BaseUIComponentProps } from '../../utils/types'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { useAvatarRootContext } from '../root/AvatarRootContext'; + +/** + * Rendered when the image fails to load or when no image is provided. + * Renders a `` element. + * + * Documentation: [Base UI Avatar](https://base-ui.com/react/components/avatar) + */ +const AvatarFallback = React.forwardRef( + function AvatarFallback(props: AvatarFallback.Props, forwardedRef) { + const { className, render, delayMs, ...otherProps } = props; + + const context = useAvatarRootContext(); + const [canRender, setCanRender] = React.useState(delayMs === undefined); + + React.useEffect(() => { + let timerId: number | undefined; + + if (delayMs !== undefined) { + timerId = window.setTimeout(() => setCanRender(true), delayMs); + } + + return () => { + window.clearTimeout(timerId); + }; + }, [delayMs]); + + const { renderElement } = useComponentRenderer({ + render: render ?? 'span', + state: context.state, + className, + ref: forwardedRef, + extraProps: otherProps, + }); + + return canRender && context.imageLoadingStatus !== 'loaded' ? renderElement() : null; + }, +); + +AvatarFallback.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * CSS class applied to the element, or a function that + * returns a class based on the component’s state. + */ + className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + /** + * Time in milliseconds to wait before showing the fallback. + */ + delayMs: PropTypes.number, + /** + * Allows you to replace the component’s HTML element + * with a different tag, or compose it with another component. + * + * Accepts a `ReactElement` or a function that returns the element to render. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), +} as any; + +export namespace AvatarFallback { + export interface Props extends BaseUIComponentProps<'span', State> { + /** + * Time in milliseconds to wait before showing the fallback. + */ + delayMs?: number; + } + + export interface State { } +} + +export { AvatarFallback }; diff --git a/packages/react/src/avatar/image/AvatarImage.test.tsx b/packages/react/src/avatar/image/AvatarImage.test.tsx new file mode 100644 index 0000000000..0165c52e9d --- /dev/null +++ b/packages/react/src/avatar/image/AvatarImage.test.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { Avatar } from '@base-ui-components/react/avatar'; +import { describeConformance, createRenderer } from '#test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + render: (node) => { + return render( + + {node} + + ) + }, + refInstanceof: window.HTMLSpanElement, + })); +}); diff --git a/packages/react/src/avatar/image/AvatarImage.tsx b/packages/react/src/avatar/image/AvatarImage.tsx new file mode 100644 index 0000000000..9f2f9c3d2f --- /dev/null +++ b/packages/react/src/avatar/image/AvatarImage.tsx @@ -0,0 +1,92 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { BaseUIComponentProps } from '../../utils/types'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { useEventCallback } from '../../utils/useEventCallback'; +import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; +import { useAvatarRootContext } from '../root/AvatarRootContext'; +import { useImageLoadingStatus } from './useImageLoadingStatus'; + +/** + * The image to be displayed in the avatar. + * Renders an `` element. + * + * Documentation: [Base UI Avatar](https://base-ui.com/react/components/avatar) + */ +const AvatarImage = React.forwardRef(function AvatarImage( + props: AvatarImage.Props, + forwardedRef, +) { + const { className, render, src, onLoadingStatusChange = () => {}, ...otherProps } = props; + + const context = useAvatarRootContext(); + const imageLoadingStatus = useImageLoadingStatus(src); + + const handleLoadingStatusChange = useEventCallback( + (status: 'idle' | 'loading' | 'loaded' | 'error') => { + onLoadingStatusChange(status); + context.onImageLoadingStatusChange(status); + }, + ); + + useEnhancedEffect(() => { + if (imageLoadingStatus !== 'idle') { + handleLoadingStatusChange(imageLoadingStatus); + } + }, [imageLoadingStatus, handleLoadingStatusChange]); + + const { renderElement } = useComponentRenderer({ + render: render ?? 'img', + state: context.state, + className, + ref: forwardedRef, + extraProps: { + ...otherProps, + src, + }, + }); + + return imageLoadingStatus === 'loaded' ? renderElement() : null; +}); + +AvatarImage.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * CSS class applied to the element, or a function that + * returns a class based on the component’s state. + */ + className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + /** + * @ignore + */ + onLoadingStatusChange: PropTypes.func, + /** + * Allows you to replace the component’s HTML element + * with a different tag, or compose it with another component. + * + * Accepts a `ReactElement` or a function that returns the element to render. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), + /** + * @ignore + */ + src: PropTypes.string, +} as any; + +export namespace AvatarImage { + export interface Props extends BaseUIComponentProps<'img', State> { + onLoadingStatusChange?: (status: 'idle' | 'loading' | 'loaded' | 'error') => void; + } + + export interface State {} +} + +export { AvatarImage }; diff --git a/packages/react/src/avatar/image/useImageLoadingStatus.ts b/packages/react/src/avatar/image/useImageLoadingStatus.ts new file mode 100644 index 0000000000..28ade7703c --- /dev/null +++ b/packages/react/src/avatar/image/useImageLoadingStatus.ts @@ -0,0 +1,40 @@ +'use client'; +import * as React from 'react'; + +type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'; + +export function useImageLoadingStatus(src?: string, referrerPolicy?: React.HTMLAttributeReferrerPolicy): ImageLoadingStatus { + const [loadingStatus, setLoadingStatus] = React.useState('idle'); + + React.useLayoutEffect(() => { + if (!src) { + setLoadingStatus('error'); + return () => { }; + } + + let isMounted = true; + const image = new window.Image(); + + const updateStatus = (status: ImageLoadingStatus) => () => { + if (!isMounted) { + return; + } + + setLoadingStatus(status); + }; + + setLoadingStatus('loading'); + image.onload = updateStatus('loaded'); + image.onerror = updateStatus('error'); + image.src = src; + if (referrerPolicy) { + image.referrerPolicy = referrerPolicy; + } + + return () => { + isMounted = false; + }; + }, [src, referrerPolicy]); + + return loadingStatus; +} \ No newline at end of file diff --git a/packages/react/src/avatar/index.parts.ts b/packages/react/src/avatar/index.parts.ts new file mode 100644 index 0000000000..9fca12b223 --- /dev/null +++ b/packages/react/src/avatar/index.parts.ts @@ -0,0 +1,3 @@ +export { AvatarRoot as Root } from './root/AvatarRoot'; +export { AvatarImage as Image } from './image/AvatarImage'; +export { AvatarFallback as Fallback } from './fallback/AvatarFallback'; diff --git a/packages/react/src/avatar/index.ts b/packages/react/src/avatar/index.ts new file mode 100644 index 0000000000..af3ac6cfc9 --- /dev/null +++ b/packages/react/src/avatar/index.ts @@ -0,0 +1 @@ +export * as Avatar from './index.parts'; diff --git a/packages/react/src/avatar/root/AvatarRoot.test.tsx b/packages/react/src/avatar/root/AvatarRoot.test.tsx new file mode 100644 index 0000000000..262b509f33 --- /dev/null +++ b/packages/react/src/avatar/root/AvatarRoot.test.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { Avatar } from '@base-ui-components/react/avatar'; +import { describeConformance, createRenderer } from '#test-utils'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + render, + refInstanceof: window.HTMLSpanElement, + })); +}); diff --git a/packages/react/src/avatar/root/AvatarRoot.tsx b/packages/react/src/avatar/root/AvatarRoot.tsx new file mode 100644 index 0000000000..acb52f6334 --- /dev/null +++ b/packages/react/src/avatar/root/AvatarRoot.tsx @@ -0,0 +1,88 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { BaseUIComponentProps } from '../../utils/types'; +import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { useAvatarRoot } from './useAvatarRoot'; +import { AvatarRootContext } from './AvatarRootContext'; + +const rootStyleHookMapping = { + imageLoadingStatus: () => null, +}; + +/** + * Displays a user's profile picture, initials, or fallback icon. + * Renders a `` element. + * + * Documentation: [Base UI Avatar](https://base-ui.com/react/components/avatar) + */ +const AvatarRoot = React.forwardRef(function AvatarRoot( + props: AvatarRoot.Props, + forwardedRef, +) { + const { className, render, ...otherProps } = props; + + const { getRootProps, ...avatar } = useAvatarRoot(); + + const state: AvatarRoot.State = React.useMemo( + () => ({ + imageLoadingStatus: avatar.imageLoadingStatus, + }), + [avatar.imageLoadingStatus], + ); + + const contextValue = React.useMemo( + () => ({ + ...avatar, + state, + }), + [avatar, state], + ); + + const { renderElement } = useComponentRenderer({ + propGetter: getRootProps, + render: render ?? 'span', + state, + className, + ref: forwardedRef, + extraProps: otherProps, + customStyleHookMapping: rootStyleHookMapping, + }); + + return ( + {renderElement()} + ); +}); + +AvatarRoot.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * CSS class applied to the element, or a function that + * returns a class based on the component’s state. + */ + className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + /** + * Allows you to replace the component’s HTML element + * with a different tag, or compose it with another component. + * + * Accepts a `ReactElement` or a function that returns the element to render. + */ + render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), +} as any; + +export namespace AvatarRoot { + export interface Props extends BaseUIComponentProps<'span', {}> {} + + export interface State { + imageLoadingStatus: 'idle' | 'loading' | 'loaded' | 'error'; + } +} + +export { AvatarRoot }; diff --git a/packages/react/src/avatar/root/AvatarRootContext.ts b/packages/react/src/avatar/root/AvatarRootContext.ts new file mode 100644 index 0000000000..27ef344ab1 --- /dev/null +++ b/packages/react/src/avatar/root/AvatarRootContext.ts @@ -0,0 +1,24 @@ +'use client'; +import * as React from 'react'; +import type { AvatarRoot } from './AvatarRoot'; +import type { useAvatarRoot } from './useAvatarRoot'; + +export interface AvatarRootContext extends Omit { + state: AvatarRoot.State; +} + +export const AvatarRootContext = React.createContext(undefined); + +if (process.env.NODE_ENV !== 'production') { + AvatarRootContext.displayName = 'AvatarRootContext'; +} + +export function useAvatarRootContext() { + const context = React.useContext(AvatarRootContext); + if (context === undefined) { + throw new Error( + 'Base UI: AvatarRootContext is missing. Avatar parts must be placed within .', + ); + } + return context; +} diff --git a/packages/react/src/avatar/root/useAvatarRoot.ts b/packages/react/src/avatar/root/useAvatarRoot.ts new file mode 100644 index 0000000000..6d91fd86ad --- /dev/null +++ b/packages/react/src/avatar/root/useAvatarRoot.ts @@ -0,0 +1,33 @@ +'use client'; +import * as React from 'react'; +import { mergeReactProps } from '../../utils/mergeReactProps'; + +export function useAvatarRoot(): useAvatarRoot.ReturnValue { + const [imageLoadingStatus, setImageLoadingStatus] = React.useState<'idle' | 'loading' | 'loaded' | 'error'>('idle'); + + const getRootProps = React.useCallback( + (externalProps = {}) => { + return mergeReactProps(externalProps, {}); + }, + [], + ); + + return React.useMemo( + () => ({ + getRootProps, + imageLoadingStatus, + onImageLoadingStatusChange: setImageLoadingStatus, + }), + [getRootProps, imageLoadingStatus], + ); +} + +export namespace useAvatarRoot { + export interface ReturnValue { + getRootProps: ( + externalProps?: React.ComponentPropsWithRef<'span'>, + ) => React.ComponentPropsWithRef<'span'>; + imageLoadingStatus: 'idle' | 'loading' | 'loaded' | 'error'; + onImageLoadingStatusChange: (status: 'idle' | 'loading' | 'loaded' | 'error') => void; + } +} diff --git a/packages/react/src/utils/defaultRenderFunctions.tsx b/packages/react/src/utils/defaultRenderFunctions.tsx index 416e96a123..149e9dc043 100644 --- a/packages/react/src/utils/defaultRenderFunctions.tsx +++ b/packages/react/src/utils/defaultRenderFunctions.tsx @@ -37,4 +37,7 @@ export const defaultRenderFunctions = { form: (props: React.ComponentPropsWithRef<'form'>) => { return
; }, + img: (props: React.ComponentPropsWithRef<'img'>) => { + return ; + }, }; From e2dd2543c17e253f73ae5efb507ca4bbdd17a4d5 Mon Sep 17 00:00:00 2001 From: Alexandru Comanescu Date: Sat, 21 Dec 2024 12:47:45 +0200 Subject: [PATCH 02/17] [Avatar] Remove image src value from usage example --- .../src/app/(public)/(content)/react/components/avatar/page.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/app/(public)/(content)/react/components/avatar/page.mdx b/docs/src/app/(public)/(content)/react/components/avatar/page.mdx index a6a57cc3d5..60ce7b0b1f 100644 --- a/docs/src/app/(public)/(content)/react/components/avatar/page.mdx +++ b/docs/src/app/(public)/(content)/react/components/avatar/page.mdx @@ -16,7 +16,7 @@ Import the component and assemble its parts: import { Avatar } from '@base-ui-components/react/avatar'; - + LT From edb95785e3e1e385d8c63079d1243a8af7231fd6 Mon Sep 17 00:00:00 2001 From: mnajdova Date: Mon, 27 Jan 2025 10:15:16 +0100 Subject: [PATCH 03/17] review comments --- .../demos/hero/css-modules/index.module.css | 24 +++++++++++-------- .../avatar/demos/hero/css-modules/index.tsx | 16 ++++++++----- .../avatar/demos/hero/tailwind/index.tsx | 18 ++++++++++---- .../react/components/avatar/page.mdx | 4 +--- .../avatar/fallback/AvatarFallback.test.tsx | 21 ++++++++++++---- .../src/avatar/fallback/AvatarFallback.tsx | 22 +++++++++-------- .../src/avatar/image/AvatarImage.test.tsx | 11 ++++----- .../react/src/avatar/image/AvatarImage.tsx | 14 +++++------ .../src/avatar/image/useImageLoadingStatus.ts | 11 +++++---- .../react/src/avatar/root/useAvatarRoot.ts | 13 +++++----- 10 files changed, 90 insertions(+), 64 deletions(-) diff --git a/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/css-modules/index.module.css b/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/css-modules/index.module.css index 9b4ebf09fd..e958e2f7e7 100644 --- a/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/css-modules/index.module.css +++ b/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/css-modules/index.module.css @@ -1,26 +1,30 @@ .Root { + display: inline-flex; + justify-content: center; align-items: center; - background-color: #f0f0f0; + vertical-align: middle; border-radius: 100%; - display: flex; - height: 64px; - justify-content: center; + user-select: none; + font-weight: 500; + color: var(--color-gray-900); + background-color: var(--color-gray-100); + font-size: 1rem; + line-height: 1; overflow: hidden; - width: 64px; + height: 48px; + width: 48px; } .Image { - height: 100%; object-fit: cover; + height: 100%; width: 100%; } .Fallback { align-items: center; - color: #000; display: flex; - font-weight: 500; - height: 100%; justify-content: center; + height: 100%; width: 100%; -} \ No newline at end of file +} diff --git a/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/css-modules/index.tsx b/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/css-modules/index.tsx index 0ef36b38ff..093af9f1f8 100644 --- a/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/css-modules/index.tsx +++ b/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/css-modules/index.tsx @@ -4,11 +4,15 @@ import styles from './index.module.css'; export default function ExampleAvatar() { return ( - - - - LT - - +
+ + + LT + + LT +
); } diff --git a/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/tailwind/index.tsx b/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/tailwind/index.tsx index f3f07f68f5..93a4d8f075 100644 --- a/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/tailwind/index.tsx +++ b/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/tailwind/index.tsx @@ -3,11 +3,19 @@ import { Avatar } from '@base-ui-components/react/avatar'; export default function ExampleAvatar() { return ( - - - +
+ + + + LT + + + LT - - + +
); } diff --git a/docs/src/app/(public)/(content)/react/components/avatar/page.mdx b/docs/src/app/(public)/(content)/react/components/avatar/page.mdx index 60ce7b0b1f..7abcce08bb 100644 --- a/docs/src/app/(public)/(content)/react/components/avatar/page.mdx +++ b/docs/src/app/(public)/(content)/react/components/avatar/page.mdx @@ -17,9 +17,7 @@ import { Avatar } from '@base-ui-components/react/avatar'; - - LT - + LT ; ``` diff --git a/packages/react/src/avatar/fallback/AvatarFallback.test.tsx b/packages/react/src/avatar/fallback/AvatarFallback.test.tsx index 797476c9c7..7ed3ebf63b 100644 --- a/packages/react/src/avatar/fallback/AvatarFallback.test.tsx +++ b/packages/react/src/avatar/fallback/AvatarFallback.test.tsx @@ -7,12 +7,23 @@ describe('', () => { describeConformance(, () => ({ render: (node) => { - return render( - - {node} - - ) + return render({node}); }, refInstanceof: window.HTMLSpanElement, })); + + it('should not render the children if the image loaded', async () => { + vi.mock('../image/useImageLoadingStatus', () => ({ + useImageLoadingStatus: () => 'loaded', + })); + + const { queryAllByTestId } = await render( + + + + , + ); + + expect(queryAllByTestId('fallback').length).toEqual(0); + }); }); diff --git a/packages/react/src/avatar/fallback/AvatarFallback.tsx b/packages/react/src/avatar/fallback/AvatarFallback.tsx index cb86ce17ef..0c4bfe836a 100644 --- a/packages/react/src/avatar/fallback/AvatarFallback.tsx +++ b/packages/react/src/avatar/fallback/AvatarFallback.tsx @@ -13,22 +13,22 @@ import { useAvatarRootContext } from '../root/AvatarRootContext'; */ const AvatarFallback = React.forwardRef( function AvatarFallback(props: AvatarFallback.Props, forwardedRef) { - const { className, render, delayMs, ...otherProps } = props; + const { className, render, delay, ...otherProps } = props; const context = useAvatarRootContext(); - const [canRender, setCanRender] = React.useState(delayMs === undefined); + const [delayPassed, setDelayPassed] = React.useState(delay === undefined); React.useEffect(() => { let timerId: number | undefined; - if (delayMs !== undefined) { - timerId = window.setTimeout(() => setCanRender(true), delayMs); + if (delay !== undefined) { + timerId = window.setTimeout(() => setDelayPassed(true), delay); } return () => { window.clearTimeout(timerId); }; - }, [delayMs]); + }, [delay]); const { renderElement } = useComponentRenderer({ render: render ?? 'span', @@ -38,7 +38,9 @@ const AvatarFallback = React.forwardRef( extraProps: otherProps, }); - return canRender && context.imageLoadingStatus !== 'loaded' ? renderElement() : null; + const shouldRender = context.imageLoadingStatus !== 'loaded' && delayPassed; + + return shouldRender ? renderElement() : null; }, ); @@ -59,7 +61,7 @@ AvatarFallback.propTypes /* remove-proptypes */ = { /** * Time in milliseconds to wait before showing the fallback. */ - delayMs: PropTypes.number, + delay: PropTypes.number, /** * Allows you to replace the component’s HTML element * with a different tag, or compose it with another component. @@ -72,12 +74,12 @@ AvatarFallback.propTypes /* remove-proptypes */ = { export namespace AvatarFallback { export interface Props extends BaseUIComponentProps<'span', State> { /** - * Time in milliseconds to wait before showing the fallback. + * How long to wait before showing the fallback. Specified in milliseconds. */ - delayMs?: number; + delay?: number; } - export interface State { } + export interface State {} } export { AvatarFallback }; diff --git a/packages/react/src/avatar/image/AvatarImage.test.tsx b/packages/react/src/avatar/image/AvatarImage.test.tsx index 0165c52e9d..3e1319bda7 100644 --- a/packages/react/src/avatar/image/AvatarImage.test.tsx +++ b/packages/react/src/avatar/image/AvatarImage.test.tsx @@ -4,15 +4,14 @@ import { describeConformance, createRenderer } from '#test-utils'; describe('', () => { const { render } = createRenderer(); + vi.mock('./useImageLoadingStatus', () => ({ + useImageLoadingStatus: () => 'loaded', + })); describeConformance(, () => ({ render: (node) => { - return render( - - {node} - - ) + return render({node}); }, - refInstanceof: window.HTMLSpanElement, + refInstanceof: window.HTMLImageElement, })); }); diff --git a/packages/react/src/avatar/image/AvatarImage.tsx b/packages/react/src/avatar/image/AvatarImage.tsx index 9f2f9c3d2f..4540c4de26 100644 --- a/packages/react/src/avatar/image/AvatarImage.tsx +++ b/packages/react/src/avatar/image/AvatarImage.tsx @@ -6,7 +6,7 @@ import { useComponentRenderer } from '../../utils/useComponentRenderer'; import { useEventCallback } from '../../utils/useEventCallback'; import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; import { useAvatarRootContext } from '../root/AvatarRootContext'; -import { useImageLoadingStatus } from './useImageLoadingStatus'; +import { useImageLoadingStatus, ImageLoadingStatus } from './useImageLoadingStatus'; /** * The image to be displayed in the avatar. @@ -23,12 +23,10 @@ const AvatarImage = React.forwardRef(functi const context = useAvatarRootContext(); const imageLoadingStatus = useImageLoadingStatus(src); - const handleLoadingStatusChange = useEventCallback( - (status: 'idle' | 'loading' | 'loaded' | 'error') => { - onLoadingStatusChange(status); - context.onImageLoadingStatusChange(status); - }, - ); + const handleLoadingStatusChange = useEventCallback((status: ImageLoadingStatus) => { + onLoadingStatusChange(status); + context.onImageLoadingStatusChange(status); + }); useEnhancedEffect(() => { if (imageLoadingStatus !== 'idle') { @@ -83,7 +81,7 @@ AvatarImage.propTypes /* remove-proptypes */ = { export namespace AvatarImage { export interface Props extends BaseUIComponentProps<'img', State> { - onLoadingStatusChange?: (status: 'idle' | 'loading' | 'loaded' | 'error') => void; + onLoadingStatusChange?: (status: ImageLoadingStatus) => void; } export interface State {} diff --git a/packages/react/src/avatar/image/useImageLoadingStatus.ts b/packages/react/src/avatar/image/useImageLoadingStatus.ts index 28ade7703c..9f11696afc 100644 --- a/packages/react/src/avatar/image/useImageLoadingStatus.ts +++ b/packages/react/src/avatar/image/useImageLoadingStatus.ts @@ -1,15 +1,18 @@ 'use client'; import * as React from 'react'; -type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'; +export type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'; -export function useImageLoadingStatus(src?: string, referrerPolicy?: React.HTMLAttributeReferrerPolicy): ImageLoadingStatus { +export function useImageLoadingStatus( + src?: string, + referrerPolicy?: React.HTMLAttributeReferrerPolicy, +): ImageLoadingStatus { const [loadingStatus, setLoadingStatus] = React.useState('idle'); React.useLayoutEffect(() => { if (!src) { setLoadingStatus('error'); - return () => { }; + return () => {}; } let isMounted = true; @@ -37,4 +40,4 @@ export function useImageLoadingStatus(src?: string, referrerPolicy?: React.HTMLA }, [src, referrerPolicy]); return loadingStatus; -} \ No newline at end of file +} diff --git a/packages/react/src/avatar/root/useAvatarRoot.ts b/packages/react/src/avatar/root/useAvatarRoot.ts index 6d91fd86ad..c7f03421a5 100644 --- a/packages/react/src/avatar/root/useAvatarRoot.ts +++ b/packages/react/src/avatar/root/useAvatarRoot.ts @@ -3,14 +3,13 @@ import * as React from 'react'; import { mergeReactProps } from '../../utils/mergeReactProps'; export function useAvatarRoot(): useAvatarRoot.ReturnValue { - const [imageLoadingStatus, setImageLoadingStatus] = React.useState<'idle' | 'loading' | 'loaded' | 'error'>('idle'); + const [imageLoadingStatus, setImageLoadingStatus] = React.useState< + 'idle' | 'loading' | 'loaded' | 'error' + >('idle'); - const getRootProps = React.useCallback( - (externalProps = {}) => { - return mergeReactProps(externalProps, {}); - }, - [], - ); + const getRootProps = React.useCallback((externalProps = {}) => { + return mergeReactProps(externalProps, {}); + }, []); return React.useMemo( () => ({ From 34dad43c992d9e0f82be62046d5376b40db9fd05 Mon Sep 17 00:00:00 2001 From: mnajdova Date: Mon, 27 Jan 2025 10:19:00 +0100 Subject: [PATCH 04/17] convert to rem --- .../components/avatar/demos/hero/css-modules/index.module.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/css-modules/index.module.css b/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/css-modules/index.module.css index e958e2f7e7..95291f406f 100644 --- a/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/css-modules/index.module.css +++ b/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/css-modules/index.module.css @@ -11,8 +11,8 @@ font-size: 1rem; line-height: 1; overflow: hidden; - height: 48px; - width: 48px; + height: 3rem; + width: 3rem; } .Image { From c8a6a510d88bbe9fd088fdb985f75e68daf9d255 Mon Sep 17 00:00:00 2001 From: mnajdova Date: Mon, 27 Jan 2025 10:32:53 +0100 Subject: [PATCH 05/17] proptypes & docs:api --- docs/reference/generated/avatar-fallback.json | 4 ++-- packages/react/src/avatar/fallback/AvatarFallback.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/reference/generated/avatar-fallback.json b/docs/reference/generated/avatar-fallback.json index d58ebfd428..1b061c816a 100644 --- a/docs/reference/generated/avatar-fallback.json +++ b/docs/reference/generated/avatar-fallback.json @@ -2,9 +2,9 @@ "name": "AvatarFallback", "description": "Rendered when the image fails to load or when no image is provided.\nRenders a `` element.", "props": { - "delayMs": { + "delay": { "type": "number", - "description": "Time in milliseconds to wait before showing the fallback." + "description": "How long to wait before showing the fallback. Specified in milliseconds." }, "className": { "type": "string | (state) => string", diff --git a/packages/react/src/avatar/fallback/AvatarFallback.tsx b/packages/react/src/avatar/fallback/AvatarFallback.tsx index 0c4bfe836a..706ae65edb 100644 --- a/packages/react/src/avatar/fallback/AvatarFallback.tsx +++ b/packages/react/src/avatar/fallback/AvatarFallback.tsx @@ -59,7 +59,7 @@ AvatarFallback.propTypes /* remove-proptypes */ = { */ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), /** - * Time in milliseconds to wait before showing the fallback. + * How long to wait before showing the fallback. Specified in milliseconds. */ delay: PropTypes.number, /** From ad48d1e32eee6bece5b72575143b88d95977d061 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Fri, 31 Jan 2025 14:37:47 +0800 Subject: [PATCH 06/17] Add a test --- .../avatar/fallback/AvatarFallback.test.tsx | 28 +++++++++++++++---- .../src/avatar/fallback/AvatarFallback.tsx | 26 ++++++++--------- .../react/src/avatar/image/AvatarImage.tsx | 20 ++++++------- .../src/avatar/image/useImageLoadingStatus.ts | 3 +- packages/react/src/avatar/root/AvatarRoot.tsx | 20 ++++++------- 5 files changed, 58 insertions(+), 39 deletions(-) diff --git a/packages/react/src/avatar/fallback/AvatarFallback.test.tsx b/packages/react/src/avatar/fallback/AvatarFallback.test.tsx index 7ed3ebf63b..8c5f8db942 100644 --- a/packages/react/src/avatar/fallback/AvatarFallback.test.tsx +++ b/packages/react/src/avatar/fallback/AvatarFallback.test.tsx @@ -1,10 +1,17 @@ import * as React from 'react'; import { Avatar } from '@base-ui-components/react/avatar'; import { describeConformance, createRenderer } from '#test-utils'; +import { useImageLoadingStatus } from '../image/useImageLoadingStatus'; + +vi.mock('../image/useImageLoadingStatus'); describe('', () => { const { render } = createRenderer(); + afterEach(() => { + vi.clearAllMocks(); + }); + describeConformance(, () => ({ render: (node) => { return render({node}); @@ -13,17 +20,28 @@ describe('', () => { })); it('should not render the children if the image loaded', async () => { - vi.mock('../image/useImageLoadingStatus', () => ({ - useImageLoadingStatus: () => 'loaded', - })); + useImageLoadingStatus.mockReturnValue('loaded'); - const { queryAllByTestId } = await render( + const { queryByTestId } = await render( , ); - expect(queryAllByTestId('fallback').length).toEqual(0); + expect(queryByTestId('fallback')).to.equal(null); + }); + + it('should render the fallback if the image fails to load', async () => { + useImageLoadingStatus.mockReturnValue('error'); + + const { queryByText } = await render( + + + AC + , + ); + + expect(queryByText('AC')).to.not.equal(null); }); }); diff --git a/packages/react/src/avatar/fallback/AvatarFallback.tsx b/packages/react/src/avatar/fallback/AvatarFallback.tsx index 706ae65edb..041d9c306d 100644 --- a/packages/react/src/avatar/fallback/AvatarFallback.tsx +++ b/packages/react/src/avatar/fallback/AvatarFallback.tsx @@ -44,6 +44,19 @@ const AvatarFallback = React.forwardRef( }, ); +export namespace AvatarFallback { + export interface Props extends BaseUIComponentProps<'span', State> { + /** + * How long to wait before showing the fallback. Specified in milliseconds. + */ + delay?: number; + } + + export interface State {} +} + +export { AvatarFallback }; + AvatarFallback.propTypes /* remove-proptypes */ = { // ┌────────────────────────────── Warning ──────────────────────────────┐ // │ These PropTypes are generated from the TypeScript type definitions. │ @@ -70,16 +83,3 @@ AvatarFallback.propTypes /* remove-proptypes */ = { */ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), } as any; - -export namespace AvatarFallback { - export interface Props extends BaseUIComponentProps<'span', State> { - /** - * How long to wait before showing the fallback. Specified in milliseconds. - */ - delay?: number; - } - - export interface State {} -} - -export { AvatarFallback }; diff --git a/packages/react/src/avatar/image/AvatarImage.tsx b/packages/react/src/avatar/image/AvatarImage.tsx index 4540c4de26..4c1528f52c 100644 --- a/packages/react/src/avatar/image/AvatarImage.tsx +++ b/packages/react/src/avatar/image/AvatarImage.tsx @@ -48,6 +48,16 @@ const AvatarImage = React.forwardRef(functi return imageLoadingStatus === 'loaded' ? renderElement() : null; }); +export namespace AvatarImage { + export interface Props extends BaseUIComponentProps<'img', State> { + onLoadingStatusChange?: (status: ImageLoadingStatus) => void; + } + + export interface State {} +} + +export { AvatarImage }; + AvatarImage.propTypes /* remove-proptypes */ = { // ┌────────────────────────────── Warning ──────────────────────────────┐ // │ These PropTypes are generated from the TypeScript type definitions. │ @@ -78,13 +88,3 @@ AvatarImage.propTypes /* remove-proptypes */ = { */ src: PropTypes.string, } as any; - -export namespace AvatarImage { - export interface Props extends BaseUIComponentProps<'img', State> { - onLoadingStatusChange?: (status: ImageLoadingStatus) => void; - } - - export interface State {} -} - -export { AvatarImage }; diff --git a/packages/react/src/avatar/image/useImageLoadingStatus.ts b/packages/react/src/avatar/image/useImageLoadingStatus.ts index 9f11696afc..38f75cfdf4 100644 --- a/packages/react/src/avatar/image/useImageLoadingStatus.ts +++ b/packages/react/src/avatar/image/useImageLoadingStatus.ts @@ -1,5 +1,6 @@ 'use client'; import * as React from 'react'; +import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; export type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'; @@ -9,7 +10,7 @@ export function useImageLoadingStatus( ): ImageLoadingStatus { const [loadingStatus, setLoadingStatus] = React.useState('idle'); - React.useLayoutEffect(() => { + useEnhancedEffect(() => { if (!src) { setLoadingStatus('error'); return () => {}; diff --git a/packages/react/src/avatar/root/AvatarRoot.tsx b/packages/react/src/avatar/root/AvatarRoot.tsx index acb52f6334..cd083bdb51 100644 --- a/packages/react/src/avatar/root/AvatarRoot.tsx +++ b/packages/react/src/avatar/root/AvatarRoot.tsx @@ -54,6 +54,16 @@ const AvatarRoot = React.forwardRef(function ); }); +export namespace AvatarRoot { + export interface Props extends BaseUIComponentProps<'span', {}> {} + + export interface State { + imageLoadingStatus: 'idle' | 'loading' | 'loaded' | 'error'; + } +} + +export { AvatarRoot }; + AvatarRoot.propTypes /* remove-proptypes */ = { // ┌────────────────────────────── Warning ──────────────────────────────┐ // │ These PropTypes are generated from the TypeScript type definitions. │ @@ -76,13 +86,3 @@ AvatarRoot.propTypes /* remove-proptypes */ = { */ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), } as any; - -export namespace AvatarRoot { - export interface Props extends BaseUIComponentProps<'span', {}> {} - - export interface State { - imageLoadingStatus: 'idle' | 'loading' | 'loaded' | 'error'; - } -} - -export { AvatarRoot }; From 98457eb78cb9b4cd7ff86ac35cc3516de7d16597 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Fri, 31 Jan 2025 14:48:37 +0800 Subject: [PATCH 07/17] Test the delay prop --- .../avatar/fallback/AvatarFallback.test.tsx | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/react/src/avatar/fallback/AvatarFallback.test.tsx b/packages/react/src/avatar/fallback/AvatarFallback.test.tsx index 8c5f8db942..d0c1421a98 100644 --- a/packages/react/src/avatar/fallback/AvatarFallback.test.tsx +++ b/packages/react/src/avatar/fallback/AvatarFallback.test.tsx @@ -44,4 +44,25 @@ describe('', () => { expect(queryByText('AC')).to.not.equal(null); }); + + describe('prop: delay', () => { + const { clock, render: renderFakeTimers } = createRenderer(); + + clock.withFakeTimers(); + + it('shows the fallback when the delay has elapsed', async () => { + const { queryByText } = await renderFakeTimers( + + + AC + , + ); + + expect(queryByText('AC')).to.equal(null); + + clock.tick(100); + + expect(queryByText('AC')).to.not.equal(null); + }); + }); }); From 7a06444c7d8a502e75ab8ead7b385e2857d42383 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Fri, 31 Jan 2025 14:59:20 +0800 Subject: [PATCH 08/17] Fix TS --- packages/react/src/avatar/fallback/AvatarFallback.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react/src/avatar/fallback/AvatarFallback.test.tsx b/packages/react/src/avatar/fallback/AvatarFallback.test.tsx index d0c1421a98..04138d990d 100644 --- a/packages/react/src/avatar/fallback/AvatarFallback.test.tsx +++ b/packages/react/src/avatar/fallback/AvatarFallback.test.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { Mock } from 'vitest'; import { Avatar } from '@base-ui-components/react/avatar'; import { describeConformance, createRenderer } from '#test-utils'; import { useImageLoadingStatus } from '../image/useImageLoadingStatus'; @@ -20,7 +21,7 @@ describe('', () => { })); it('should not render the children if the image loaded', async () => { - useImageLoadingStatus.mockReturnValue('loaded'); + (useImageLoadingStatus as Mock).mockReturnValue('loaded'); const { queryByTestId } = await render( @@ -33,7 +34,7 @@ describe('', () => { }); it('should render the fallback if the image fails to load', async () => { - useImageLoadingStatus.mockReturnValue('error'); + (useImageLoadingStatus as Mock).mockReturnValue('error'); const { queryByText } = await render( From 416d69b7bd9bfddad7e345e94fdffece36efc79b Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Fri, 31 Jan 2025 15:04:38 +0800 Subject: [PATCH 09/17] Use noop util --- packages/react/src/avatar/image/AvatarImage.tsx | 3 ++- packages/react/src/avatar/image/useImageLoadingStatus.ts | 3 ++- packages/react/src/avatar/root/AvatarRoot.tsx | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/react/src/avatar/image/AvatarImage.tsx b/packages/react/src/avatar/image/AvatarImage.tsx index 4c1528f52c..0b1a1e4565 100644 --- a/packages/react/src/avatar/image/AvatarImage.tsx +++ b/packages/react/src/avatar/image/AvatarImage.tsx @@ -1,6 +1,7 @@ 'use client'; import * as React from 'react'; import PropTypes from 'prop-types'; +import { NOOP } from '../../utils/noop'; import { BaseUIComponentProps } from '../../utils/types'; import { useComponentRenderer } from '../../utils/useComponentRenderer'; import { useEventCallback } from '../../utils/useEventCallback'; @@ -18,7 +19,7 @@ const AvatarImage = React.forwardRef(functi props: AvatarImage.Props, forwardedRef, ) { - const { className, render, src, onLoadingStatusChange = () => {}, ...otherProps } = props; + const { className, render, src, onLoadingStatusChange = NOOP, ...otherProps } = props; const context = useAvatarRootContext(); const imageLoadingStatus = useImageLoadingStatus(src); diff --git a/packages/react/src/avatar/image/useImageLoadingStatus.ts b/packages/react/src/avatar/image/useImageLoadingStatus.ts index 38f75cfdf4..345a6397f3 100644 --- a/packages/react/src/avatar/image/useImageLoadingStatus.ts +++ b/packages/react/src/avatar/image/useImageLoadingStatus.ts @@ -1,5 +1,6 @@ 'use client'; import * as React from 'react'; +import { NOOP } from '../../utils/noop'; import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; export type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'; @@ -13,7 +14,7 @@ export function useImageLoadingStatus( useEnhancedEffect(() => { if (!src) { setLoadingStatus('error'); - return () => {}; + return NOOP; } let isMounted = true; diff --git a/packages/react/src/avatar/root/AvatarRoot.tsx b/packages/react/src/avatar/root/AvatarRoot.tsx index cd083bdb51..4be7bd5bc2 100644 --- a/packages/react/src/avatar/root/AvatarRoot.tsx +++ b/packages/react/src/avatar/root/AvatarRoot.tsx @@ -55,7 +55,7 @@ const AvatarRoot = React.forwardRef(function }); export namespace AvatarRoot { - export interface Props extends BaseUIComponentProps<'span', {}> {} + export interface Props extends BaseUIComponentProps<'span', State> {} export interface State { imageLoadingStatus: 'idle' | 'loading' | 'loaded' | 'error'; From cc939fc6a445c93eb3f7b8f275ee7554b2e17e0c Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Fri, 31 Jan 2025 15:07:23 +0800 Subject: [PATCH 10/17] Fix referrerPolicy --- .../react/src/avatar/image/AvatarImage.tsx | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/react/src/avatar/image/AvatarImage.tsx b/packages/react/src/avatar/image/AvatarImage.tsx index 0b1a1e4565..2a22869594 100644 --- a/packages/react/src/avatar/image/AvatarImage.tsx +++ b/packages/react/src/avatar/image/AvatarImage.tsx @@ -19,10 +19,17 @@ const AvatarImage = React.forwardRef(functi props: AvatarImage.Props, forwardedRef, ) { - const { className, render, src, onLoadingStatusChange = NOOP, ...otherProps } = props; + const { + className, + render, + src, + onLoadingStatusChange = NOOP, + referrerPolicy, + ...otherProps + } = props; const context = useAvatarRootContext(); - const imageLoadingStatus = useImageLoadingStatus(src); + const imageLoadingStatus = useImageLoadingStatus(src, referrerPolicy); const handleLoadingStatusChange = useEventCallback((status: ImageLoadingStatus) => { onLoadingStatusChange(status); @@ -77,6 +84,20 @@ AvatarImage.propTypes /* remove-proptypes */ = { * @ignore */ onLoadingStatusChange: PropTypes.func, + /** + * @ignore + */ + referrerPolicy: PropTypes.oneOf([ + '', + 'no-referrer-when-downgrade', + 'no-referrer', + 'origin-when-cross-origin', + 'origin', + 'same-origin', + 'strict-origin-when-cross-origin', + 'strict-origin', + 'unsafe-url', + ]), /** * Allows you to replace the component’s HTML element * with a different tag, or compose it with another component. From 8c0e720fa60366ddcd14dc76abf2f02b5007a349 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Fri, 31 Jan 2025 15:51:12 +0800 Subject: [PATCH 11/17] Simplify root and context --- .../src/avatar/fallback/AvatarFallback.tsx | 18 +++++++---- .../react/src/avatar/image/AvatarImage.tsx | 32 ++++++++----------- packages/react/src/avatar/root/AvatarRoot.tsx | 18 +++++------ .../src/avatar/root/AvatarRootContext.ts | 8 ++--- .../react/src/avatar/root/useAvatarRoot.ts | 32 ------------------- 5 files changed, 39 insertions(+), 69 deletions(-) delete mode 100644 packages/react/src/avatar/root/useAvatarRoot.ts diff --git a/packages/react/src/avatar/fallback/AvatarFallback.tsx b/packages/react/src/avatar/fallback/AvatarFallback.tsx index 041d9c306d..666ad4f1f3 100644 --- a/packages/react/src/avatar/fallback/AvatarFallback.tsx +++ b/packages/react/src/avatar/fallback/AvatarFallback.tsx @@ -4,6 +4,7 @@ import PropTypes from 'prop-types'; import { BaseUIComponentProps } from '../../utils/types'; import { useComponentRenderer } from '../../utils/useComponentRenderer'; import { useAvatarRootContext } from '../root/AvatarRootContext'; +import type { AvatarRoot } from '../root/AvatarRoot'; /** * Rendered when the image fails to load or when no image is provided. @@ -15,7 +16,7 @@ const AvatarFallback = React.forwardRef( function AvatarFallback(props: AvatarFallback.Props, forwardedRef) { const { className, render, delay, ...otherProps } = props; - const context = useAvatarRootContext(); + const { imageLoadingStatus } = useAvatarRootContext(); const [delayPassed, setDelayPassed] = React.useState(delay === undefined); React.useEffect(() => { @@ -30,29 +31,34 @@ const AvatarFallback = React.forwardRef( }; }, [delay]); + const state: AvatarRoot.State = React.useMemo( + () => ({ + imageLoadingStatus, + }), + [imageLoadingStatus], + ); + const { renderElement } = useComponentRenderer({ render: render ?? 'span', - state: context.state, + state, className, ref: forwardedRef, extraProps: otherProps, }); - const shouldRender = context.imageLoadingStatus !== 'loaded' && delayPassed; + const shouldRender = imageLoadingStatus !== 'loaded' && delayPassed; return shouldRender ? renderElement() : null; }, ); export namespace AvatarFallback { - export interface Props extends BaseUIComponentProps<'span', State> { + export interface Props extends BaseUIComponentProps<'span', AvatarRoot.State> { /** * How long to wait before showing the fallback. Specified in milliseconds. */ delay?: number; } - - export interface State {} } export { AvatarFallback }; diff --git a/packages/react/src/avatar/image/AvatarImage.tsx b/packages/react/src/avatar/image/AvatarImage.tsx index 2a22869594..7b91d0f5b7 100644 --- a/packages/react/src/avatar/image/AvatarImage.tsx +++ b/packages/react/src/avatar/image/AvatarImage.tsx @@ -7,6 +7,7 @@ import { useComponentRenderer } from '../../utils/useComponentRenderer'; import { useEventCallback } from '../../utils/useEventCallback'; import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; import { useAvatarRootContext } from '../root/AvatarRootContext'; +import type { AvatarRoot } from '../root/AvatarRoot'; import { useImageLoadingStatus, ImageLoadingStatus } from './useImageLoadingStatus'; /** @@ -19,21 +20,14 @@ const AvatarImage = React.forwardRef(functi props: AvatarImage.Props, forwardedRef, ) { - const { - className, - render, - src, - onLoadingStatusChange = NOOP, - referrerPolicy, - ...otherProps - } = props; + const { className, render, onLoadingStatusChange = NOOP, referrerPolicy, ...otherProps } = props; const context = useAvatarRootContext(); - const imageLoadingStatus = useImageLoadingStatus(src, referrerPolicy); + const imageLoadingStatus = useImageLoadingStatus(props.src, referrerPolicy); const handleLoadingStatusChange = useEventCallback((status: ImageLoadingStatus) => { onLoadingStatusChange(status); - context.onImageLoadingStatusChange(status); + context.setImageLoadingStatus(status); }); useEnhancedEffect(() => { @@ -42,26 +36,28 @@ const AvatarImage = React.forwardRef(functi } }, [imageLoadingStatus, handleLoadingStatusChange]); + const state: AvatarRoot.State = React.useMemo( + () => ({ + imageLoadingStatus, + }), + [imageLoadingStatus], + ); + const { renderElement } = useComponentRenderer({ render: render ?? 'img', - state: context.state, + state, className, ref: forwardedRef, - extraProps: { - ...otherProps, - src, - }, + extraProps: otherProps, }); return imageLoadingStatus === 'loaded' ? renderElement() : null; }); export namespace AvatarImage { - export interface Props extends BaseUIComponentProps<'img', State> { + export interface Props extends BaseUIComponentProps<'img', AvatarRoot.State> { onLoadingStatusChange?: (status: ImageLoadingStatus) => void; } - - export interface State {} } export { AvatarImage }; diff --git a/packages/react/src/avatar/root/AvatarRoot.tsx b/packages/react/src/avatar/root/AvatarRoot.tsx index 4be7bd5bc2..89964360df 100644 --- a/packages/react/src/avatar/root/AvatarRoot.tsx +++ b/packages/react/src/avatar/root/AvatarRoot.tsx @@ -3,7 +3,6 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import { BaseUIComponentProps } from '../../utils/types'; import { useComponentRenderer } from '../../utils/useComponentRenderer'; -import { useAvatarRoot } from './useAvatarRoot'; import { AvatarRootContext } from './AvatarRootContext'; const rootStyleHookMapping = { @@ -22,25 +21,24 @@ const AvatarRoot = React.forwardRef(function ) { const { className, render, ...otherProps } = props; - const { getRootProps, ...avatar } = useAvatarRoot(); + const [imageLoadingStatus, setImageLoadingStatus] = React.useState('idle'); const state: AvatarRoot.State = React.useMemo( () => ({ - imageLoadingStatus: avatar.imageLoadingStatus, + imageLoadingStatus, }), - [avatar.imageLoadingStatus], + [imageLoadingStatus], ); const contextValue = React.useMemo( () => ({ - ...avatar, - state, + imageLoadingStatus, + setImageLoadingStatus, }), - [avatar, state], + [imageLoadingStatus, setImageLoadingStatus], ); const { renderElement } = useComponentRenderer({ - propGetter: getRootProps, render: render ?? 'span', state, className, @@ -54,11 +52,13 @@ const AvatarRoot = React.forwardRef(function ); }); +export type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'; + export namespace AvatarRoot { export interface Props extends BaseUIComponentProps<'span', State> {} export interface State { - imageLoadingStatus: 'idle' | 'loading' | 'loaded' | 'error'; + imageLoadingStatus: ImageLoadingStatus; } } diff --git a/packages/react/src/avatar/root/AvatarRootContext.ts b/packages/react/src/avatar/root/AvatarRootContext.ts index 27ef344ab1..96d615f7b7 100644 --- a/packages/react/src/avatar/root/AvatarRootContext.ts +++ b/packages/react/src/avatar/root/AvatarRootContext.ts @@ -1,10 +1,10 @@ 'use client'; import * as React from 'react'; -import type { AvatarRoot } from './AvatarRoot'; -import type { useAvatarRoot } from './useAvatarRoot'; +import type { ImageLoadingStatus } from './AvatarRoot'; -export interface AvatarRootContext extends Omit { - state: AvatarRoot.State; +export interface AvatarRootContext { + imageLoadingStatus: ImageLoadingStatus; + setImageLoadingStatus: React.Dispatch>; } export const AvatarRootContext = React.createContext(undefined); diff --git a/packages/react/src/avatar/root/useAvatarRoot.ts b/packages/react/src/avatar/root/useAvatarRoot.ts deleted file mode 100644 index c7f03421a5..0000000000 --- a/packages/react/src/avatar/root/useAvatarRoot.ts +++ /dev/null @@ -1,32 +0,0 @@ -'use client'; -import * as React from 'react'; -import { mergeReactProps } from '../../utils/mergeReactProps'; - -export function useAvatarRoot(): useAvatarRoot.ReturnValue { - const [imageLoadingStatus, setImageLoadingStatus] = React.useState< - 'idle' | 'loading' | 'loaded' | 'error' - >('idle'); - - const getRootProps = React.useCallback((externalProps = {}) => { - return mergeReactProps(externalProps, {}); - }, []); - - return React.useMemo( - () => ({ - getRootProps, - imageLoadingStatus, - onImageLoadingStatusChange: setImageLoadingStatus, - }), - [getRootProps, imageLoadingStatus], - ); -} - -export namespace useAvatarRoot { - export interface ReturnValue { - getRootProps: ( - externalProps?: React.ComponentPropsWithRef<'span'>, - ) => React.ComponentPropsWithRef<'span'>; - imageLoadingStatus: 'idle' | 'loading' | 'loaded' | 'error'; - onImageLoadingStatusChange: (status: 'idle' | 'loading' | 'loaded' | 'error') => void; - } -} From df65f1959c39dea4d3f692359757d80aaace80d9 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Fri, 31 Jan 2025 15:58:43 +0800 Subject: [PATCH 12/17] Fix onLoadingStatusChange missing from docs --- docs/reference/generated/avatar-image.json | 4 ++++ packages/react/src/avatar/image/AvatarImage.tsx | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/docs/reference/generated/avatar-image.json b/docs/reference/generated/avatar-image.json index f45c8d6c83..8b81da412b 100644 --- a/docs/reference/generated/avatar-image.json +++ b/docs/reference/generated/avatar-image.json @@ -2,6 +2,10 @@ "name": "AvatarImage", "description": "The image to be displayed in the avatar.\nRenders an `` element.", "props": { + "onLoadingStatusChange": { + "type": "function", + "description": "Callback fired when the loading status changes." + }, "className": { "type": "string | (state) => string", "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." diff --git a/packages/react/src/avatar/image/AvatarImage.tsx b/packages/react/src/avatar/image/AvatarImage.tsx index 7b91d0f5b7..58414b8f7f 100644 --- a/packages/react/src/avatar/image/AvatarImage.tsx +++ b/packages/react/src/avatar/image/AvatarImage.tsx @@ -20,13 +20,19 @@ const AvatarImage = React.forwardRef(functi props: AvatarImage.Props, forwardedRef, ) { - const { className, render, onLoadingStatusChange = NOOP, referrerPolicy, ...otherProps } = props; + const { + className, + render, + onLoadingStatusChange: onLoadingStatusChangeProp, + referrerPolicy, + ...otherProps + } = props; const context = useAvatarRootContext(); const imageLoadingStatus = useImageLoadingStatus(props.src, referrerPolicy); const handleLoadingStatusChange = useEventCallback((status: ImageLoadingStatus) => { - onLoadingStatusChange(status); + onLoadingStatusChangeProp?.(status); context.setImageLoadingStatus(status); }); @@ -56,6 +62,9 @@ const AvatarImage = React.forwardRef(functi export namespace AvatarImage { export interface Props extends BaseUIComponentProps<'img', AvatarRoot.State> { + /** + * Callback fired when the loading status changes. + */ onLoadingStatusChange?: (status: ImageLoadingStatus) => void; } } @@ -77,7 +86,7 @@ AvatarImage.propTypes /* remove-proptypes */ = { */ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), /** - * @ignore + * Callback fired when the loading status changes. */ onLoadingStatusChange: PropTypes.func, /** From 33a3ab04dd9a7b19629cc80ed0af42270591ac6d Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Fri, 31 Jan 2025 16:05:18 +0800 Subject: [PATCH 13/17] Set font-size on avatar fallback demo --- .../components/avatar/demos/hero/css-modules/index.module.css | 1 + .../react/components/avatar/demos/hero/tailwind/index.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/css-modules/index.module.css b/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/css-modules/index.module.css index 95291f406f..6d5702d4d9 100644 --- a/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/css-modules/index.module.css +++ b/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/css-modules/index.module.css @@ -27,4 +27,5 @@ justify-content: center; height: 100%; width: 100%; + font-size: 1rem; } diff --git a/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/tailwind/index.tsx b/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/tailwind/index.tsx index 93a4d8f075..c77edc74c4 100644 --- a/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/tailwind/index.tsx +++ b/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/tailwind/index.tsx @@ -9,7 +9,7 @@ export default function ExampleAvatar() { src="https://images.unsplash.com/photo-1543610892-0b1f7e6d8ac1?w=128&h=128&dpr=2&q=80" className="size-full object-cover" /> - + LT From c6eb5e23f928779d2daa0f3f583b9ea1a3499dee Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Fri, 31 Jan 2025 16:11:15 +0800 Subject: [PATCH 14/17] Fix eslint --- packages/react/src/avatar/image/AvatarImage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react/src/avatar/image/AvatarImage.tsx b/packages/react/src/avatar/image/AvatarImage.tsx index 58414b8f7f..11c5dec2d7 100644 --- a/packages/react/src/avatar/image/AvatarImage.tsx +++ b/packages/react/src/avatar/image/AvatarImage.tsx @@ -1,7 +1,6 @@ 'use client'; import * as React from 'react'; import PropTypes from 'prop-types'; -import { NOOP } from '../../utils/noop'; import { BaseUIComponentProps } from '../../utils/types'; import { useComponentRenderer } from '../../utils/useComponentRenderer'; import { useEventCallback } from '../../utils/useEventCallback'; From 83870fef8b88ba9b548601ee9ada80a1273db1a4 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Fri, 31 Jan 2025 16:34:39 +0800 Subject: [PATCH 15/17] docs --- docs/reference/generated/avatar-image.json | 2 +- docs/reference/overrides/avatar-image.json | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 docs/reference/overrides/avatar-image.json diff --git a/docs/reference/generated/avatar-image.json b/docs/reference/generated/avatar-image.json index 8b81da412b..c229cc43e2 100644 --- a/docs/reference/generated/avatar-image.json +++ b/docs/reference/generated/avatar-image.json @@ -3,7 +3,7 @@ "description": "The image to be displayed in the avatar.\nRenders an `` element.", "props": { "onLoadingStatusChange": { - "type": "function", + "type": "(status) => void", "description": "Callback fired when the loading status changes." }, "className": { diff --git a/docs/reference/overrides/avatar-image.json b/docs/reference/overrides/avatar-image.json new file mode 100644 index 0000000000..b6627678ef --- /dev/null +++ b/docs/reference/overrides/avatar-image.json @@ -0,0 +1,8 @@ +{ + "name": "AvatarImage", + "props": { + "onLoadingStatusChange": { + "type": "(status) => void" + } + } +} From 5be8d7f6bea9e6ef68bda3c80b2186f76eb93006 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Mon, 3 Feb 2025 18:35:41 +0800 Subject: [PATCH 16/17] Set width and height attrs in demo --- .../react/components/avatar/demos/hero/css-modules/index.tsx | 2 ++ .../react/components/avatar/demos/hero/tailwind/index.tsx | 2 ++ 2 files changed, 4 insertions(+) diff --git a/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/css-modules/index.tsx b/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/css-modules/index.tsx index 093af9f1f8..8a90c684a6 100644 --- a/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/css-modules/index.tsx +++ b/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/css-modules/index.tsx @@ -8,6 +8,8 @@ export default function ExampleAvatar() { LT diff --git a/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/tailwind/index.tsx b/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/tailwind/index.tsx index c77edc74c4..bcd1064c06 100644 --- a/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/tailwind/index.tsx +++ b/docs/src/app/(public)/(content)/react/components/avatar/demos/hero/tailwind/index.tsx @@ -7,6 +7,8 @@ export default function ExampleAvatar() { From 9bdba316a92b741d6e94e062a473f0a3d4e3a713 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Mon, 3 Feb 2025 22:01:29 +0800 Subject: [PATCH 17/17] Fix style hooks --- packages/react/src/avatar/fallback/AvatarFallback.tsx | 2 ++ packages/react/src/avatar/image/AvatarImage.tsx | 2 ++ packages/react/src/avatar/root/AvatarRoot.tsx | 7 ++----- packages/react/src/avatar/root/styleHooks.ts | 3 +++ 4 files changed, 9 insertions(+), 5 deletions(-) create mode 100644 packages/react/src/avatar/root/styleHooks.ts diff --git a/packages/react/src/avatar/fallback/AvatarFallback.tsx b/packages/react/src/avatar/fallback/AvatarFallback.tsx index 666ad4f1f3..5bad4d3977 100644 --- a/packages/react/src/avatar/fallback/AvatarFallback.tsx +++ b/packages/react/src/avatar/fallback/AvatarFallback.tsx @@ -5,6 +5,7 @@ import { BaseUIComponentProps } from '../../utils/types'; import { useComponentRenderer } from '../../utils/useComponentRenderer'; import { useAvatarRootContext } from '../root/AvatarRootContext'; import type { AvatarRoot } from '../root/AvatarRoot'; +import { avatarStyleHookMapping } from '../root/styleHooks'; /** * Rendered when the image fails to load or when no image is provided. @@ -44,6 +45,7 @@ const AvatarFallback = React.forwardRef( className, ref: forwardedRef, extraProps: otherProps, + customStyleHookMapping: avatarStyleHookMapping, }); const shouldRender = imageLoadingStatus !== 'loaded' && delayPassed; diff --git a/packages/react/src/avatar/image/AvatarImage.tsx b/packages/react/src/avatar/image/AvatarImage.tsx index 11c5dec2d7..512bea90ec 100644 --- a/packages/react/src/avatar/image/AvatarImage.tsx +++ b/packages/react/src/avatar/image/AvatarImage.tsx @@ -7,6 +7,7 @@ import { useEventCallback } from '../../utils/useEventCallback'; import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; import { useAvatarRootContext } from '../root/AvatarRootContext'; import type { AvatarRoot } from '../root/AvatarRoot'; +import { avatarStyleHookMapping } from '../root/styleHooks'; import { useImageLoadingStatus, ImageLoadingStatus } from './useImageLoadingStatus'; /** @@ -54,6 +55,7 @@ const AvatarImage = React.forwardRef(functi className, ref: forwardedRef, extraProps: otherProps, + customStyleHookMapping: avatarStyleHookMapping, }); return imageLoadingStatus === 'loaded' ? renderElement() : null; diff --git a/packages/react/src/avatar/root/AvatarRoot.tsx b/packages/react/src/avatar/root/AvatarRoot.tsx index 89964360df..f48b2bcb4d 100644 --- a/packages/react/src/avatar/root/AvatarRoot.tsx +++ b/packages/react/src/avatar/root/AvatarRoot.tsx @@ -4,10 +4,7 @@ import PropTypes from 'prop-types'; import { BaseUIComponentProps } from '../../utils/types'; import { useComponentRenderer } from '../../utils/useComponentRenderer'; import { AvatarRootContext } from './AvatarRootContext'; - -const rootStyleHookMapping = { - imageLoadingStatus: () => null, -}; +import { avatarStyleHookMapping } from './styleHooks'; /** * Displays a user's profile picture, initials, or fallback icon. @@ -44,7 +41,7 @@ const AvatarRoot = React.forwardRef(function className, ref: forwardedRef, extraProps: otherProps, - customStyleHookMapping: rootStyleHookMapping, + customStyleHookMapping: avatarStyleHookMapping, }); return ( diff --git a/packages/react/src/avatar/root/styleHooks.ts b/packages/react/src/avatar/root/styleHooks.ts new file mode 100644 index 0000000000..1c594d1dae --- /dev/null +++ b/packages/react/src/avatar/root/styleHooks.ts @@ -0,0 +1,3 @@ +export const avatarStyleHookMapping = { + imageLoadingStatus: () => null, +};