From 652224123f81052f4542c5850c00a961538fab1c Mon Sep 17 00:00:00 2001 From: Josh Wooding <12938082+joshwooding@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:53:13 +0100 Subject: [PATCH] Interactive Avatar --- .changeset/olive-eagles-invite.md | 5 ++ .../__tests__/__e2e__/avatar/Avatar.cy.tsx | 51 +++++++++++++++++++ packages/core/src/avatar/Avatar.css | 45 ++++++++++++++++ packages/core/src/avatar/Avatar.tsx | 41 ++++++++++++--- .../core/stories/avatar/avatar.stories.tsx | 20 +++++++- site/docs/components/avatar/examples.mdx | 10 ++++ site/src/examples/avatar/Interactive.tsx | 40 +++++++++++++++ site/src/examples/avatar/index.ts | 1 + 8 files changed, 204 insertions(+), 9 deletions(-) create mode 100644 .changeset/olive-eagles-invite.md create mode 100644 site/src/examples/avatar/Interactive.tsx diff --git a/.changeset/olive-eagles-invite.md b/.changeset/olive-eagles-invite.md new file mode 100644 index 00000000000..c35848bbd04 --- /dev/null +++ b/.changeset/olive-eagles-invite.md @@ -0,0 +1,5 @@ +--- +"@salt-ds/core": patch +--- + +Added a `render` prop to `Avatar` so teams can render the avatar root as a custom interactive element such as a button or link. diff --git a/packages/core/src/__tests__/__e2e__/avatar/Avatar.cy.tsx b/packages/core/src/__tests__/__e2e__/avatar/Avatar.cy.tsx index d3e35eec0f8..12cd58d31a4 100644 --- a/packages/core/src/__tests__/__e2e__/avatar/Avatar.cy.tsx +++ b/packages/core/src/__tests__/__e2e__/avatar/Avatar.cy.tsx @@ -59,6 +59,57 @@ describe("Given an Avatar", () => { cy.findByTestId("UserGroupSolidIcon").should("exist"); }); + it("should preserve native button semantics when rendered as a button", () => { + cy.mount( + } />, + ); + + cy.findByRole("button", { name: "Juanito Jones" }).should( + "have.class", + "saltAvatar", + ); + cy.findByRole("img").should("not.exist"); + }); + + it("WHEN `render` is passed a render function, THEN should call `render` to create the element", () => { + const testId = "avatar-testid"; + const mockRender = cy + .stub() + .as("render") + .returns( + , + ); + + cy.mount(); + + cy.findByTestId(testId).should("exist"); + cy.get("@render").should("have.been.calledWithMatch", { + className: Cypress.sinon.match.string, + children: Cypress.sinon.match.any, + style: Cypress.sinon.match.object, + "aria-label": "Juanito Jones", + }); + }); + + it("WHEN `render` is given a JSX element, THEN should merge the props and render the JSX element", () => { + const testId = "avatar-testid"; + + cy.mount( + } + />, + ); + + cy.findByRole("button", { name: "Juanito Jones" }).should( + "have.attr", + "data-testid", + testId, + ); + }); + it("should not have a role or aria-label if name is not provided", () => { cy.mount(); cy.findByRole("img").should("not.exist"); diff --git a/packages/core/src/avatar/Avatar.css b/packages/core/src/avatar/Avatar.css index 84149319676..d8fb0f0d843 100644 --- a/packages/core/src/avatar/Avatar.css +++ b/packages/core/src/avatar/Avatar.css @@ -22,6 +22,12 @@ height: var(--avatar-container-size); min-height: var(--avatar-container-size); border-radius: var(--saltAvatar-borderRadius, var(--salt-palette-corner-strongest, 50%)); + border: none; + padding: 0; + appearance: none; + -webkit-appearance: none; + text-decoration: none; + position: relative; display: flex; justify-content: center; @@ -114,3 +120,42 @@ width: 100%; height: 100%; } + +.saltAvatar:focus-visible { + outline-offset: var(--salt-spacing-fixed-100); +} + +button.saltAvatar, +a.saltAvatar { + cursor: var(--salt-cursor-hover); +} + +button.saltAvatar:disabled { + cursor: var(--salt-cursor-disabled); +} + +button.saltAvatar::after, +a.saltAvatar::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + background: transparent; +} + +.saltAvatar[aria-expanded="true"][aria-haspopup="menu"]::after, +.saltAvatar[aria-expanded="true"][aria-haspopup="dialog"]::after { + background: var(--salt-overlayable-background-hover); +} + +@media (hover: hover) { + button.saltAvatar:hover::after, + a.saltAvatar:hover::after { + background: var(--salt-overlayable-background-hover); + } + + button.saltAvatar:disabled:hover::after { + background: transparent; + } +} diff --git a/packages/core/src/avatar/Avatar.tsx b/packages/core/src/avatar/Avatar.tsx index 4943d09c1ab..dc5e821454d 100644 --- a/packages/core/src/avatar/Avatar.tsx +++ b/packages/core/src/avatar/Avatar.tsx @@ -1,9 +1,14 @@ import { useComponentCssInjection } from "@salt-ds/styles"; import { useWindow } from "@salt-ds/window"; import { clsx } from "clsx"; -import { forwardRef, type HTMLAttributes, type ReactNode } from "react"; +import { + type ComponentPropsWithoutRef, + forwardRef, + type HTMLAttributes, + type ReactNode, +} from "react"; import { useIcon } from "../semantic-icon-provider"; -import { makePrefixer } from "../utils"; +import { makePrefixer, type RenderPropsType, renderProps } from "../utils"; import avatarCss from "./Avatar.css"; import { useAvatarImage } from "./useAvatarImage"; @@ -56,11 +61,25 @@ export interface AvatarProps extends HTMLAttributes { | "category-18" | "category-19" | "category-20"; + /** + * Render prop to enable customization of the avatar root element. + */ + render?: RenderPropsType["render"]; } const withBaseName = makePrefixer("saltAvatar"); const DEFAULT_AVATAR_SIZE = 2; // medium +interface AvatarActionProps extends ComponentPropsWithoutRef<"div"> { + render?: RenderPropsType["render"]; +} + +const AvatarAction = forwardRef( + function AvatarAction(props, ref) { + return renderProps("div", { ...props, ref }); + }, +); + const defaultNameToInitials = (name?: string) => name ?.split(" ") @@ -80,6 +99,7 @@ export const Avatar = forwardRef(function Avatar( size = DEFAULT_AVATAR_SIZE, style: styleProp, fallbackIcon: fallbackIconProp, + render, ...rest }, ref, @@ -118,15 +138,20 @@ export const Avatar = forwardRef(function Avatar( const avatarInitials = nameToInitials(name); const ariaProps = name - ? { - role: "img", - "aria-label": name, - } + ? render + ? { + "aria-label": name, + } + : { + role: "img", + "aria-label": name, + } : {}; return ( -
(function Avatar( {...rest} > {children || avatarInitials || fallbackIcon} -
+ ); }); diff --git a/packages/core/stories/avatar/avatar.stories.tsx b/packages/core/stories/avatar/avatar.stories.tsx index 4399fffd0f4..c212369bf71 100644 --- a/packages/core/stories/avatar/avatar.stories.tsx +++ b/packages/core/stories/avatar/avatar.stories.tsx @@ -7,7 +7,7 @@ import { } from "@salt-ds/core"; import { UserGroupSolidIcon } from "@salt-ds/icons"; import type { Meta, StoryFn } from "@storybook/react-vite"; -import type { ReactNode } from "react"; +import type { ComponentProps, ReactNode } from "react"; import persona1 from "../assets/avatar.png"; export default { @@ -20,6 +20,10 @@ const Template: StoryFn = (args) => { return ; }; +const CustomAvatarButton = (props: ComponentProps<"button">) => ( +