Skip to content
Draft
Show file tree
Hide file tree
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
5 changes: 5 additions & 0 deletions .changeset/olive-eagles-invite.md
Original file line number Diff line number Diff line change
@@ -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.
51 changes: 51 additions & 0 deletions packages/core/src/__tests__/__e2e__/avatar/Avatar.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Default name="Juanito Jones" render={<button type="button" />} />,
);

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(
<button type="button" data-testid={testId}>
JJ
</button>,
);

cy.mount(<Default name="Juanito Jones" render={mockRender} />);

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(
<Default
name="Juanito Jones"
render={<button type="button" data-testid={testId} />}
/>,
);

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(<Default />);
cy.findByRole("img").should("not.exist");
Expand Down
45 changes: 45 additions & 0 deletions packages/core/src/avatar/Avatar.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
41 changes: 33 additions & 8 deletions packages/core/src/avatar/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -56,11 +61,25 @@ export interface AvatarProps extends HTMLAttributes<HTMLDivElement> {
| "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<HTMLDivElement, AvatarActionProps>(
function AvatarAction(props, ref) {
return renderProps("div", { ...props, ref });
},
);

const defaultNameToInitials = (name?: string) =>
name
?.split(" ")
Expand All @@ -80,6 +99,7 @@ export const Avatar = forwardRef<HTMLDivElement, AvatarProps>(function Avatar(
size = DEFAULT_AVATAR_SIZE,
style: styleProp,
fallbackIcon: fallbackIconProp,
render,
...rest
},
ref,
Expand Down Expand Up @@ -118,15 +138,20 @@ export const Avatar = forwardRef<HTMLDivElement, AvatarProps>(function Avatar(
const avatarInitials = nameToInitials(name);

const ariaProps = name
? {
role: "img",
"aria-label": name,
}
? render
? {
"aria-label": name,
}
: {
role: "img",
"aria-label": name,
}
: {};

return (
<div
<AvatarAction
ref={ref}
render={render}
style={style}
className={clsx(
withBaseName(),
Expand All @@ -140,6 +165,6 @@ export const Avatar = forwardRef<HTMLDivElement, AvatarProps>(function Avatar(
{...rest}
>
{children || avatarInitials || fallbackIcon}
</div>
</AvatarAction>
);
});
20 changes: 19 additions & 1 deletion packages/core/stories/avatar/avatar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -20,6 +20,10 @@ const Template: StoryFn<typeof Avatar> = (args) => {
return <Avatar {...args} />;
};

const CustomAvatarButton = (props: ComponentProps<"button">) => (
<button type="button" {...props} />
);

export const Default = Template.bind({});

export const Sizes: StoryFn<typeof Avatar> = (args) => {
Expand Down Expand Up @@ -65,6 +69,20 @@ WithCustomSvg.args = {
children: CustomSVG,
};

export const RenderElement = Template.bind({});
RenderElement.args = {
name: "Alex Brailescu",
render: <CustomAvatarButton aria-label="Open Alex Brailescu profile" />,
};

export const RenderProp = Template.bind({});
RenderProp.args = {
name: "Alex Brailescu",
render: (props) => (
<CustomAvatarButton {...props} aria-label="Open Alex Brailescu profile" />
),
};

export const WithCustomImg: StoryFn<typeof Avatar> = () => {
const src = "bad_url";
const status = useAvatarImage({ src });
Expand Down
10 changes: 10 additions & 0 deletions site/docs/components/avatar/examples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ The `color` prop can be used to change Avatar's background to one of the 20 [cat

<LivePreview componentName="avatar" exampleName="Categories" />

## Interactive

Avatar can be made interactive using the `render` prop. Props defined on the JSX element will be merged with props from the Avatar.

### Best practices

- Ensure the Avatar has a meaningful accessible name so screen reader users understand what action will occur.

<LivePreview componentName="avatar" exampleName="Interactive" />

## Sizes

You can use the `size` prop to modify the avatar size. Each avatar variant has a default size across all four densities, equal to the [size foundation](/salt/foundations/size) `size-base`: 20px (HD), 28px (MD), 36px (LD), and 44px (TD). The size property acts as a multiplier of the base size.
Expand Down
40 changes: 40 additions & 0 deletions site/src/examples/avatar/Interactive.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {
Avatar,
Divider,
Menu,
MenuItem,
MenuPanel,
MenuTrigger,
} from "@salt-ds/core";
import { ExportIcon, NotificationIcon, SettingsIcon } from "@salt-ds/icons";
import type { ReactElement } from "react";

export const Interactive = (): ReactElement => {
return (
<Menu placement="bottom-end">
<MenuTrigger>
<Avatar
name="Alex Brailescu"
render={
<button type="button" aria-label="Open Alex Brailescu profile" />
}
/>
</MenuTrigger>
<MenuPanel>
<MenuItem>
<NotificationIcon aria-hidden />
Notifications
</MenuItem>
<MenuItem>
<SettingsIcon aria-hidden />
Settings
</MenuItem>
<Divider />
<MenuItem>
<ExportIcon aria-hidden />
Log out
</MenuItem>
</MenuPanel>
</Menu>
);
};
1 change: 1 addition & 0 deletions site/src/examples/avatar/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export * from "./Categories";
export * from "./CustomFallbackIcon";
export * from "./Image";
export * from "./Initials";
export * from "./Interactive";
export * from "./Sizes";
Loading