From 539cf2690a8dc63376a16d6f966ce0e48eeda31d Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Fri, 24 Oct 2025 17:53:00 +0200 Subject: [PATCH] feat(ui): migrate Avatar from Base UI with updated size scale Removed Base UI dependency from Avatar component and reimplemented with native HTML elements. Updated size scale with new x-small and x-large options. Breaking changes: - Base UI-specific props (render, etc.) are no longer supported - Component now uses native div/img elements instead of Base UI primitives - Size scale updated: large changed from 3rem to 2.5rem - Added x-small (1.25rem) and x-large (3rem) sizes Migration: - + - + New features: - Added purpose prop with 'informative' (default) and 'decoration' options - Informative avatars announce name to screen readers - Decorative avatars hidden from screen readers (use when name appears adjacent) - Five size options: x-small, small, medium, large, x-large Documentation updates: - Updated size examples to show all five sizes - Added Purpose story and documentation - Updated prop definitions and usage examples - Updated changeset with migration guide for size changes Signed-off-by: Johan Persson --- .changeset/cruel-items-dig.md | 27 +++++++ docs-ui/src/content/components/avatar.mdx | 12 ++++ .../src/content/components/avatar.props.ts | 45 +++++++++++- packages/ui/report.api.md | 12 ++-- .../src/components/Avatar/Avatar.module.css | 11 +++ .../src/components/Avatar/Avatar.stories.tsx | 35 ++++++++- packages/ui/src/components/Avatar/Avatar.tsx | 72 ++++++++++++------- packages/ui/src/components/Avatar/types.ts | 27 +++++-- 8 files changed, 202 insertions(+), 39 deletions(-) create mode 100644 .changeset/cruel-items-dig.md diff --git a/.changeset/cruel-items-dig.md b/.changeset/cruel-items-dig.md new file mode 100644 index 0000000000..65f07956ac --- /dev/null +++ b/.changeset/cruel-items-dig.md @@ -0,0 +1,27 @@ +--- +'@backstage/ui': minor +--- + +**BREAKING**: Migrated Avatar component from Base UI to custom implementation with size changes: + +- Base UI-specific props are no longer supported +- Size values have been updated: + - New `x-small` size added (1.25rem / 20px) + - `small` size unchanged (1.5rem / 24px) + - `medium` size unchanged (2rem / 32px, default) + - `large` size **changed from 3rem to 2.5rem** (40px) + - New `x-large` size added (3rem / 48px) + +Migration: + +```diff +# Remove Base UI-specific props +- ++ + +# Update large size usage to x-large for same visual size +- ++ +``` + +Added `purpose` prop for accessibility control (`'informative'` or `'decoration'`). diff --git a/docs-ui/src/content/components/avatar.mdx b/docs-ui/src/content/components/avatar.mdx index be1fb070d1..6e0b550101 100644 --- a/docs-ui/src/content/components/avatar.mdx +++ b/docs-ui/src/content/components/avatar.mdx @@ -7,6 +7,7 @@ import { snippetUsage, snippetSizes, snippetFallback, + snippetPurpose, } from './avatar.props'; import { PageTitle } from '@/components/PageTitle'; import { Theming } from '@/components/Theming'; @@ -58,6 +59,17 @@ If the image is not available, the avatar will show the initials of the name. code={snippetFallback} /> +### The `purpose` prop + +Control how the avatar is announced to screen readers using the `purpose` prop. + +} + code={snippetPurpose} +/> + diff --git a/docs-ui/src/content/components/avatar.props.ts b/docs-ui/src/content/components/avatar.props.ts index 8cc53eeff3..80621cdb89 100644 --- a/docs-ui/src/content/components/avatar.props.ts +++ b/docs-ui/src/content/components/avatar.props.ts @@ -10,10 +10,15 @@ export const avatarPropDefs: Record = { }, size: { type: 'enum', - values: ['small', 'medium', 'large'], + values: ['x-small', 'small', 'medium', 'large', 'x-large'], default: 'medium', responsive: true, }, + purpose: { + type: 'enum', + values: ['informative', 'decoration'], + default: 'informative', + }, ...classNamePropDefs, ...stylePropDefs, }; @@ -26,6 +31,10 @@ export const snippetUsage = `import { Avatar } from '@backstage/ui'; />`; export const snippetSizes = ` + src="https://avatars.githubusercontent.com/u/1540635?v=4" name="Charles de Dreuille" size="large" /> + `; export const snippetFallback = ``; + +export const snippetPurpose = ` + + Informative (default) + + Use when avatar appears alone. Announced as "Charles de Dreuille" to screen readers: + + + + + + + Decoration + + Use when name appears adjacent to avatar. Hidden from screen readers to avoid redundancy: + + + + Charles de Dreuille + + +`; diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 91b6def80a..05d2336f45 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -3,7 +3,6 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { Avatar as Avatar_2 } from '@base-ui-components/react/avatar'; import { ButtonProps as ButtonProps_2 } from 'react-aria-components'; import { CellProps as CellProps_2 } from 'react-aria-components'; import { CheckboxProps as CheckboxProps_2 } from 'react-aria-components'; @@ -57,17 +56,14 @@ export type AlignItems = 'stretch' | 'start' | 'center' | 'end'; // @public (undocumented) export const Avatar: ForwardRefExoticComponent< - AvatarProps & RefAttributes + AvatarProps & RefAttributes >; // @public (undocumented) -export interface AvatarProps - extends React.ComponentPropsWithoutRef { - // (undocumented) +export interface AvatarProps extends React.ComponentPropsWithoutRef<'div'> { name: string; - // (undocumented) - size?: 'small' | 'medium' | 'large'; - // (undocumented) + purpose?: 'decoration' | 'informative'; + size?: 'x-small' | 'small' | 'medium' | 'large' | 'x-large'; src: string; } diff --git a/packages/ui/src/components/Avatar/Avatar.module.css b/packages/ui/src/components/Avatar/Avatar.module.css index f1517db3c2..c1fec3df9b 100644 --- a/packages/ui/src/components/Avatar/Avatar.module.css +++ b/packages/ui/src/components/Avatar/Avatar.module.css @@ -34,6 +34,11 @@ width: 2rem; } + .bui-AvatarRoot[data-size='x-small'] { + height: 1.25rem; + width: 1.25rem; + } + .bui-AvatarRoot[data-size='small'] { height: 1.5rem; width: 1.5rem; @@ -45,6 +50,11 @@ } .bui-AvatarRoot[data-size='large'] { + height: 2.5rem; + width: 2.5rem; + } + + .bui-AvatarRoot[data-size='x-large'] { height: 3rem; width: 3rem; } @@ -53,6 +63,7 @@ object-fit: cover; height: 100%; width: 100%; + display: block; } .bui-AvatarFallback { diff --git a/packages/ui/src/components/Avatar/Avatar.stories.tsx b/packages/ui/src/components/Avatar/Avatar.stories.tsx index c91fe736db..2ad51e4a5d 100644 --- a/packages/ui/src/components/Avatar/Avatar.stories.tsx +++ b/packages/ui/src/components/Avatar/Avatar.stories.tsx @@ -16,7 +16,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { Avatar } from './index'; -import { Flex } from '../..'; +import { Flex, Text } from '../..'; const meta = { title: 'Backstage UI/Avatar', @@ -46,9 +46,42 @@ export const Sizes: Story = { }, render: args => ( + + + + ), +}; + +export const Purpose: Story = { + args: { + ...Default.args, + }, + render: args => ( + + + Informative (default) + + Use when avatar appears alone. Announced as "{args.name}" to screen + readers: + + + + + + + Decoration + + Use when name appears adjacent to avatar. Hidden from screen readers + to avoid redundancy: + + + + {args.name} + + ), }; diff --git a/packages/ui/src/components/Avatar/Avatar.tsx b/packages/ui/src/components/Avatar/Avatar.tsx index 000087d451..1490d86098 100644 --- a/packages/ui/src/components/Avatar/Avatar.tsx +++ b/packages/ui/src/components/Avatar/Avatar.tsx @@ -14,48 +14,72 @@ * limitations under the License. */ -import { forwardRef, ElementRef } from 'react'; -import { Avatar as AvatarPrimitive } from '@base-ui-components/react/avatar'; +import { forwardRef, useState, useEffect } from 'react'; import clsx from 'clsx'; import { AvatarProps } from './types'; import { useStyles } from '../../hooks/useStyles'; import styles from './Avatar.module.css'; /** @public */ -export const Avatar = forwardRef< - ElementRef, - AvatarProps ->((props, ref) => { +export const Avatar = forwardRef((props, ref) => { const { classNames, dataAttributes, cleanedProps } = useStyles('Avatar', { size: 'medium', + purpose: 'informative', ...props, }); - const { className, src, name, ...rest } = cleanedProps; + const { className, src, name, purpose, ...rest } = cleanedProps; + + const [imageStatus, setImageStatus] = useState< + 'loading' | 'loaded' | 'error' + >('loading'); + + useEffect(() => { + setImageStatus('loading'); + const img = new Image(); + img.onload = () => setImageStatus('loaded'); + img.onerror = () => setImageStatus('error'); + img.src = src; + + return () => { + img.onload = null; + img.onerror = null; + }; + }, [src]); + + const initials = name + .split(' ') + .map(word => word[0]) + .join('') + .toLocaleUpperCase('en-US') + .slice(0, 2); return ( - - - - {(name || '') - .split(' ') - .map(word => word[0]) - .join('') - .toLocaleUpperCase('en-US') - .slice(0, 2)} - - + {imageStatus === 'loaded' ? ( + + ) : ( + + )} + ); }); -Avatar.displayName = AvatarPrimitive.Root.displayName; +Avatar.displayName = 'Avatar'; diff --git a/packages/ui/src/components/Avatar/types.ts b/packages/ui/src/components/Avatar/types.ts index b2e6cf39cf..a252861ea4 100644 --- a/packages/ui/src/components/Avatar/types.ts +++ b/packages/ui/src/components/Avatar/types.ts @@ -14,12 +14,29 @@ * limitations under the License. */ -import { Avatar } from '@base-ui-components/react/avatar'; - /** @public */ -export interface AvatarProps - extends React.ComponentPropsWithoutRef { +export interface AvatarProps extends React.ComponentPropsWithoutRef<'div'> { + /** + * URL of the image to display + */ src: string; + + /** + * Name of the person - used for generating initials and accessibility labels + */ name: string; - size?: 'small' | 'medium' | 'large'; + + /** + * Size of the avatar + * @defaultValue 'medium' + */ + size?: 'x-small' | 'small' | 'medium' | 'large' | 'x-large'; + + /** + * Determines how the avatar is presented to assistive technologies. + * - 'informative': Avatar is announced as "\{name\}" to screen readers + * - 'decoration': Avatar is hidden from screen readers (use when name appears in adjacent text) + * @defaultValue 'informative' + */ + purpose?: 'decoration' | 'informative'; }