Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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 overrides it.

<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 overrides it.

<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>
);
}
32 changes: 31 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,35 @@ 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");
},
};

/**
* 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
23 changes: 20 additions & 3 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 to override it. Omit
* for no tooltip.
*/
tooltip?: boolean | string;
};

/**
Expand All @@ -37,7 +43,7 @@ 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, ...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 @@ -54,11 +60,21 @@ export function Avatar({ size, src, alt, fallback, delay, ...props }: AvatarProp
// 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 (
// `true` reuses `alt`; a non-empty string overrides it. Empty string / missing `alt` → no tip —
// an avatar with no `alt` is `aria-hidden`, so it must not show a hover-only tooltip either.
const tooltipLabel = alt == null ? undefined : tooltip === true ? alt : tooltip || undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A string tooltip is ignored when alt is missing, contrary to the documented behavior.

suggestion: Support the string independently or document that alt is required.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8c14a05 — a string tooltip works without alt and becomes the accessible name.

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}>
// `tabIndex` only when a tooltip is actually attached — a Tooltip's trigger must be focusable
// for its documented focus-opens contract to hold; a plain avatar stays out of tab order.
<BaseAvatar.Root
{...props}
render={<AvatarElement size={effectiveSize} />}
{...a11y}
tabIndex={tooltipLabel ? 0 : undefined}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This overrides a consumer-provided tabIndex, even when no tooltip is shown.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in df5147d — consumer tabIndex is forwarded when there is no tooltip; tooltip still forces 0.

>
{src ? <BaseAvatar.Image render={<AvatarImage />} src={src} alt="" /> : null}
{hasInitials ? (
<BaseAvatar.Fallback delay={delay} render={<AvatarFallback variant={resolvedVariant} />}>
Expand All @@ -71,4 +87,5 @@ export function Avatar({ size, src, alt, fallback, delay, ...props }: AvatarProp
)}
</BaseAvatar.Root>
);
return tooltipLabel ? <Tooltip label={tooltipLabel}>{avatar}</Tooltip> : avatar;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, waitFor } from "storybook/test";
import { expect, waitFor, within } from "storybook/test";

import { TooltipProvider } from "../tooltip";
import { WorkspaceAvatar, type WorkspaceAvatarSize } from "./index";

const SIZES: WorkspaceAvatarSize[] = ["2xs", "xs", "sm", "md", "lg", "xl", "2xl", "3xl"];
Expand Down Expand Up @@ -156,3 +157,32 @@ export const BrokenImageInteraction: Story = {
await waitFor(() => expect(canvasElement.querySelector("img")).not.toBeInTheDocument());
},
};

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

/**
* Behavior twin of `WithTooltip`: hovering the workspace avatar opens a `role="tooltip"` popup with
* the workspace 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: "Plane workspace" }));
const tooltip = await body.findByRole("tooltip");
await expect(tooltip).toHaveTextContent("Plane workspace");
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type WorkspaceAvatarProps as WorkspaceAvatarElementProps,
} from "../../elements/workspace-avatar";
import { Icon } from "../../internal/icon";
import { Tooltip } from "../tooltip/tooltip";

export type WorkspaceAvatarProps = WorkspaceAvatarElementProps & {
/** Workspace logo URL. Falls back to the initial when absent/loading/failed. */
Expand All @@ -23,6 +24,11 @@ export type WorkspaceAvatarProps = WorkspaceAvatarElementProps & {
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 to override it. Omit
* for no tooltip.
*/
tooltip?: boolean | string;
};

/**
Expand All @@ -38,6 +44,7 @@ export function WorkspaceAvatar({
alt,
fallback,
delay,
tooltip,
...props
}: WorkspaceAvatarProps) {
// Base UI shows the fallback whenever the logo is absent, loading, or failed, so the
Expand All @@ -53,11 +60,21 @@ export function WorkspaceAvatar({
// 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. (Matches `components/avatar`.)
const a11y = alt != null ? { role: "img", "aria-label": alt } : { "aria-hidden": true };
return (
// `true` reuses `alt`; a non-empty string overrides it. Empty string / missing `alt` → no tip —
// a workspace avatar with no `alt` is `aria-hidden`, so it must not show a hover-only tooltip either.
const tooltipLabel = alt == null ? undefined : tooltip === true ? alt : tooltip || undefined;
const avatar = (
// Base UI's `Avatar` behavior grafts onto the styled `elements` parts via `render` (behavior part
// outer, styled part as the render target). `{...props}` spreads before the hardcoded a11y attrs
// so a stray same-named prop can never silently override them.
<BaseAvatar.Root {...props} render={<WorkspaceAvatarElement size={size} />} {...a11y}>
// `tabIndex` only when a tooltip is actually attached — a Tooltip's trigger must be focusable
// for its documented focus-opens contract to hold; a plain avatar stays out of tab order.
<BaseAvatar.Root
{...props}
render={<WorkspaceAvatarElement size={size} />}
{...a11y}
tabIndex={tooltipLabel ? 0 : undefined}
>
{src ? <BaseAvatar.Image render={<WorkspaceAvatarImage />} src={src} alt="" /> : null}
{hasInitials ? (
<BaseAvatar.Fallback
Expand All @@ -73,4 +90,5 @@ export function WorkspaceAvatar({
)}
</BaseAvatar.Root>
);
return tooltipLabel ? <Tooltip label={tooltipLabel}>{avatar}</Tooltip> : avatar;
}
Loading