Skip to content
Open
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
13 changes: 13 additions & 0 deletions apps/docs/src/demos/avatar-group/with-tooltips.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Avatar } from "@makeplane/propel/components/avatar";
import { AvatarGroup } from "@makeplane/propel/components/avatar-group";

export default function WithTooltipsDemo() {
return (
<AvatarGroup size="sm">
<Avatar alt="Ada Lovelace" tooltip fallback="AL" src="https://i.pravatar.cc/64?img=47" />
<Avatar alt="Grace Hopper" tooltip fallback="GH" src="https://i.pravatar.cc/64?img=32" />
<Avatar alt="Linus Torvalds" tooltip fallback="LT" />
<Avatar alt="4 more members" tooltip fallback="+4" />
</AvatarGroup>
);
}
13 changes: 13 additions & 0 deletions apps/docs/src/demos/avatar/with-tooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Avatar } from "@makeplane/propel/components/avatar";

export default function WithTooltipDemo() {
return (
<Avatar
size="md"
alt="Ada Lovelace"
tooltip
fallback="AL"
src="https://i.pravatar.cc/128?img=47"
/>
);
}
13 changes: 13 additions & 0 deletions apps/docs/src/demos/workspace-avatar/with-tooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { WorkspaceAvatar } from "@makeplane/propel/components/workspace-avatar";

export default function WithTooltipDemo() {
return (
<WorkspaceAvatar
size="md"
alt="Plane workspace"
tooltip
fallback="PV"
src="https://avatars.githubusercontent.com/u/73642778?s=128"
/>
);
}
10 changes: 10 additions & 0 deletions apps/docs/src/pages/components/avatar-group.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import SizesDemo from "~/demos/avatar-group/sizes.tsx";
import sizesSource from "~/demos/avatar-group/sizes.tsx?raw";
import variantsSource from "~/demos/avatar-group/variants.tsx?raw";
import VariantsDemo from "~/demos/avatar-group/variants.tsx";
import WithTooltipsDemo from "~/demos/avatar-group/with-tooltips.tsx";
import withTooltipsSource from "~/demos/avatar-group/with-tooltips.tsx?raw";

{/* Demo */}

Expand Down Expand Up @@ -79,6 +81,14 @@ Each initials avatar's background variant is derived stably from the member's na
<VariantsDemo client:visible />
</ComponentExample>

### With tooltips

Each member can show its name on hover via `tooltip`. The group shares tooltip timing, so sweeping across faces swaps names without restarting the open delay.

<ComponentExample code={withTooltipsSource}>
<WithTooltipsDemo client:visible />
</ComponentExample>

</ComponentSection>

{/* API Reference */}
Expand Down
10 changes: 10 additions & 0 deletions apps/docs/src/pages/components/avatar.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import StatesDemo from "~/demos/avatar/states.tsx";
import statesSource from "~/demos/avatar/states.tsx?raw";
import variantsSource from "~/demos/avatar/variants.tsx?raw";
import VariantsDemo from "~/demos/avatar/variants.tsx";
import WithTooltipDemo from "~/demos/avatar/with-tooltip.tsx";
import withTooltipSource from "~/demos/avatar/with-tooltip.tsx?raw";

{/* Demo */}

Expand Down Expand Up @@ -81,6 +83,14 @@ The three fallback states side by side: photo, initials, and the anonymous perso
<StatesDemo client:visible />
</ComponentExample>

### With tooltip

Pass `tooltip` to show the person's name on hover. `true` reuses `alt`; a string is the label and works without `alt`.

<ComponentExample code={withTooltipSource}>
<WithTooltipDemo client:visible />
</ComponentExample>

</ComponentSection>

{/* API Reference */}
Expand Down
10 changes: 10 additions & 0 deletions apps/docs/src/pages/components/workspace-avatar.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import StatesDemo from "~/demos/workspace-avatar/states.tsx";
import statesSource from "~/demos/workspace-avatar/states.tsx?raw";
import variantsSource from "~/demos/workspace-avatar/variants.tsx?raw";
import VariantsDemo from "~/demos/workspace-avatar/variants.tsx";
import WithTooltipDemo from "~/demos/workspace-avatar/with-tooltip.tsx";
import withTooltipSource from "~/demos/workspace-avatar/with-tooltip.tsx?raw";

{/* Demo */}

Expand Down Expand Up @@ -80,6 +82,14 @@ The three fallback states side by side: logo, initials, and the anonymous worksp
<StatesDemo client:visible />
</ComponentExample>

### With tooltip

Pass `tooltip` to show the workspace name on hover. `true` reuses `alt`; a string is the label and works without `alt`.

<ComponentExample code={withTooltipSource}>
<WithTooltipDemo client:visible />
</ComponentExample>

</ComponentSection>

{/* API Reference */}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect } from "storybook/test";
import { expect, waitFor, within } from "storybook/test";

import { Avatar } from "../avatar/index";
import { AvatarGroup, type AvatarGroupSize } from "./index";
Expand Down Expand Up @@ -95,3 +95,40 @@ export const OverflowCount: Story = {
</AvatarGroup>
),
};

/**
* Each member can show its name on hover via `tooltip`. The group wraps a `TooltipProvider`, so
* sweeping across faces swaps names without restarting the open delay.
*/
export const WithTooltips: Story = {
render: (args) => (
<AvatarGroup {...args}>
<Avatar alt="Ada Lovelace" tooltip fallback="AL" src="https://i.pravatar.cc/64?img=47" />
<Avatar alt="Grace Hopper" tooltip fallback="GH" src="https://i.pravatar.cc/64?img=32" />
<Avatar alt="Linus Torvalds" tooltip fallback="LT" />
<Avatar alt="4 more members" tooltip fallback="+4" />
</AvatarGroup>
),
};

/**
* Behavior twin of `WithTooltips`: hovering a member opens its name tooltip, and moving to the next
* member swaps the popup. Tagged out of the sidebar/docs/manifest while still running under the
* default `test` tag.
*/
export const WithTooltipsInteraction: Story = {
...WithTooltips,
tags: ["!dev", "!autodocs", "!manifest"],
play: async ({ canvas, userEvent }) => {
const body = within(document.body);
await userEvent.hover(canvas.getByRole("img", { name: "Ada Lovelace" }));
await expect(await body.findByRole("tooltip", {}, { timeout: 3000 })).toHaveTextContent(
"Ada Lovelace",
);

await userEvent.hover(canvas.getByRole("img", { name: "Grace Hopper" }));
await waitFor(() => expect(body.getByRole("tooltip")).toHaveTextContent("Grace Hopper"), {
timeout: 3000,
});
},
};
8 changes: 6 additions & 2 deletions packages/propel/src/components/avatar-group/avatar-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type AvatarGroupProps as AvatarGroupElementProps,
} from "../../elements/avatar-group";
import { AvatarGroupContext } from "../avatar/avatar-group-context";
import { TooltipProvider } from "../tooltip/tooltip-provider";

// Figma's "Avatar Groups" component only defines three sizes (Small/Base/Large = 16/20/24px), so
// groups are limited to the matching sizes — narrower than a standalone Avatar's full scale.
Expand All @@ -20,12 +21,15 @@ export type AvatarGroupProps = AvatarGroupElementProps & {
/**
* The ready-made overlapping avatar stack: shares `size` with every `Avatar` inside via context (an
* avatar's own `size` still wins), composed around the styled `elements/avatar-group` container.
* Each `Avatar`'s own `border-subtle` is the single ring that separates them.
* Each `Avatar`'s own `border-subtle` is the single ring that separates them. A `TooltipProvider`
* wraps the stack so member `tooltip`s share open/close timing when sweeping across faces.
*/
export function AvatarGroup({ size, ...props }: AvatarGroupProps) {
return (
<AvatarGroupContext.Provider value={size}>
<AvatarGroupElement {...props} />
<TooltipProvider>
<AvatarGroupElement {...props} />
</TooltipProvider>
</AvatarGroupContext.Provider>
);
}
54 changes: 53 additions & 1 deletion packages/propel/src/components/avatar/avatar.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import * as React from "react";
import { expect, waitFor } from "storybook/test";
import { expect, waitFor, within } from "storybook/test";

import { TooltipProvider } from "../tooltip";
import { type AvatarSize, Avatar } from "./index";

const SIZES: AvatarSize[] = ["2xs", "xs", "sm", "md", "lg", "xl", "2xl", "3xl"];
Expand Down Expand Up @@ -176,6 +177,57 @@ export const BrokenImageInteraction: Story = {
},
};

/** Hover shows the person's name. `tooltip` reuses `alt`; pass a string to show different copy. */
export const WithTooltip: Story = {
args: { tooltip: true },
};

/**
* Behavior twin of `WithTooltip`: hovering the avatar opens a `role="tooltip"` popup with the
* person's name. Tagged out of the sidebar/docs/manifest while still running under the default
* `test` tag. A 0ms provider delay keeps the hover open deterministic (focus is not used — the
* avatar is not a tab stop).
*/
export const WithTooltipInteraction: Story = {
...WithTooltip,
tags: ["!dev", "!autodocs", "!manifest"],
decorators: [
(Story) => (
<TooltipProvider delay={0}>
<Story />
</TooltipProvider>
),
],
play: async ({ canvas, userEvent }) => {
const body = within(document.body);
await userEvent.hover(canvas.getByRole("img", { name: "Ada Lovelace" }));
const tooltip = await body.findByRole("tooltip");
await expect(tooltip).toHaveTextContent("Ada Lovelace");
},
};

/**
* A string `tooltip` without `alt` still names the avatar and opens the popup — the string is both
* the hover label and the accessible name. Tagged out of the sidebar/docs/manifest.
*/
export const TooltipStringWithoutAltInteraction: Story = {
tags: ["!dev", "!autodocs", "!manifest"],
args: { alt: undefined, tooltip: "Ada Lovelace" },
decorators: [
(Story) => (
<TooltipProvider delay={0}>
<Story />
</TooltipProvider>
),
],
play: async ({ canvas, userEvent }) => {
const body = within(document.body);
const avatar = canvas.getByRole("img", { name: "Ada Lovelace" });
await userEvent.hover(avatar);
await expect(await body.findByRole("tooltip")).toHaveTextContent("Ada Lovelace");
},
};

/**
* The single project-wide CSS check: an `md` avatar is `size-7` (28px) and the variant utility
* resolves to a real color. Concrete computed values prove the shared preview actually compiled
Expand Down
48 changes: 40 additions & 8 deletions packages/propel/src/components/avatar/avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
getAvatarVariantSeed,
} from "../../elements/avatar";
import { Icon } from "../../internal/icon";
import { Tooltip } from "../tooltip/tooltip";
import { AvatarGroupContext } from "./avatar-group-context";

export type AvatarProps = Omit<AvatarElementProps, "size"> & {
Expand All @@ -29,6 +30,11 @@ export type AvatarProps = Omit<AvatarElementProps, "size"> & {
fallback?: React.ReactNode;
/** Milliseconds before the fallback shows, to avoid a flash while `src` loads quickly. */
delay?: number;
/**
* Name shown in a tooltip on hover. Pass `true` to reuse `alt`, or a string for the label (works
* without `alt`). Omit for no tooltip.
*/
tooltip?: boolean | string;
};

/**
Expand All @@ -37,7 +43,16 @@ export type AvatarProps = Omit<AvatarElementProps, "size"> & {
* Pass `src` for the photo and `fallback` for initials; the initials color is chosen automatically
* and is not a consumer prop.
*/
export function Avatar({ size, src, alt, fallback, delay, ...props }: AvatarProps) {
export function Avatar({
size,
src,
alt,
fallback,
delay,
tooltip,
tabIndex,
...props
}: AvatarProps) {
// Base UI shows the fallback whenever the image is absent, loading, or failed, so the
// colored-initials styling lives on the Fallback element itself. The anonymous person icon
// renders in the icon slot over the root's neutral backdrop when there are no initials.
Expand All @@ -49,16 +64,32 @@ export function Avatar({ size, src, alt, fallback, delay, ...props }: AvatarProp
// Variant is always system-chosen (never a consumer prop): seed a stable color from the name, else
// the initials text, so unnamed avatars vary by initials instead of collapsing onto one color.
const resolvedVariant = getAvatarVariant(getAvatarVariantSeed(alt, fallback));
// Named vs decorative: with an `alt`, expose one accessible name for every state
// (image / initials / icon) via `role="img"`; without one, mark the avatar `aria-hidden` so it is
// skipped rather than announced as a nameless image (the name lives in adjacent text). This is the
// only correct pair — a `role="img"` with no name is an axe violation.
const a11y = alt != null ? { role: "img", "aria-label": alt } : { "aria-hidden": true };
return (
// Named vs decorative: with an accessible name (`alt`, else a string `tooltip`), expose it for
// every state (image / initials / icon) via `role="img"`; without one, mark the avatar
// `aria-hidden` so it is skipped rather than announced as a nameless image (the name lives in
// adjacent text). This is the only correct pair — a `role="img"` with no name is an axe violation.
// `true` reuses `alt`; a non-empty string is the label even when `alt` is omitted. Empty string /
// `true` with no `alt` → no tip. A string tooltip without `alt` also becomes the accessible name
// so the trigger is not `aria-hidden` while showing a hover-only name.
const tooltipLabel =
tooltip === true ? alt : typeof tooltip === "string" ? tooltip || undefined : undefined;
const accessibleName = alt ?? tooltipLabel;
const a11y =
accessibleName != null
? { role: "img", "aria-label": accessibleName }
: { "aria-hidden": true };
const avatar = (
// Base UI `Avatar` behavior/context grafts onto the styled `elements/avatar` parts via `render`
// (behavior part outer). `{...props}` spreads before the hardcoded a11y attrs so a stray
// same-named prop can never silently override them (matches `components/workspace-avatar`).
<BaseAvatar.Root {...props} render={<AvatarElement size={effectiveSize} />} {...a11y}>
// Force `tabIndex={0}` when a tooltip is attached so the trigger is keyboard-focusable
// (Tooltip's documented focus-opens contract). Otherwise forward a consumer `tabIndex`.
<BaseAvatar.Root
{...props}
render={<AvatarElement size={effectiveSize} />}
{...a11y}
tabIndex={tooltipLabel ? 0 : tabIndex}
>
{src ? <BaseAvatar.Image render={<AvatarImage />} src={src} alt="" /> : null}
{hasInitials ? (
<BaseAvatar.Fallback delay={delay} render={<AvatarFallback variant={resolvedVariant} />}>
Expand All @@ -71,4 +102,5 @@ export function Avatar({ size, src, alt, fallback, delay, ...props }: AvatarProp
)}
</BaseAvatar.Root>
);
return tooltipLabel ? <Tooltip label={tooltipLabel}>{avatar}</Tooltip> : avatar;
}
Loading
Loading