diff --git a/apps/docs/src/demos/avatar-group/with-tooltips.tsx b/apps/docs/src/demos/avatar-group/with-tooltips.tsx
new file mode 100644
index 0000000..1168d1d
--- /dev/null
+++ b/apps/docs/src/demos/avatar-group/with-tooltips.tsx
@@ -0,0 +1,13 @@
+import { Avatar } from "@makeplane/propel/components/avatar";
+import { AvatarGroup } from "@makeplane/propel/components/avatar-group";
+
+export default function WithTooltipsDemo() {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/apps/docs/src/demos/avatar/with-tooltip.tsx b/apps/docs/src/demos/avatar/with-tooltip.tsx
new file mode 100644
index 0000000..ea46319
--- /dev/null
+++ b/apps/docs/src/demos/avatar/with-tooltip.tsx
@@ -0,0 +1,13 @@
+import { Avatar } from "@makeplane/propel/components/avatar";
+
+export default function WithTooltipDemo() {
+ return (
+
+ );
+}
diff --git a/apps/docs/src/demos/workspace-avatar/with-tooltip.tsx b/apps/docs/src/demos/workspace-avatar/with-tooltip.tsx
new file mode 100644
index 0000000..4f477bb
--- /dev/null
+++ b/apps/docs/src/demos/workspace-avatar/with-tooltip.tsx
@@ -0,0 +1,13 @@
+import { WorkspaceAvatar } from "@makeplane/propel/components/workspace-avatar";
+
+export default function WithTooltipDemo() {
+ return (
+
+ );
+}
diff --git a/apps/docs/src/pages/components/avatar-group.mdx b/apps/docs/src/pages/components/avatar-group.mdx
index e2248b9..22837b1 100644
--- a/apps/docs/src/pages/components/avatar-group.mdx
+++ b/apps/docs/src/pages/components/avatar-group.mdx
@@ -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 */}
@@ -79,6 +81,14 @@ Each initials avatar's background variant is derived stably from the member's na
+### 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.
+
+
+
+
+
{/* API Reference */}
diff --git a/apps/docs/src/pages/components/avatar.mdx b/apps/docs/src/pages/components/avatar.mdx
index cc80c06..7b7244b 100644
--- a/apps/docs/src/pages/components/avatar.mdx
+++ b/apps/docs/src/pages/components/avatar.mdx
@@ -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 */}
@@ -81,6 +83,14 @@ The three fallback states side by side: photo, initials, and the anonymous perso
+### With tooltip
+
+Pass `tooltip` to show the person's name on hover. `true` reuses `alt`; a string is the label and works without `alt`.
+
+
+
+
+
{/* API Reference */}
diff --git a/apps/docs/src/pages/components/workspace-avatar.mdx b/apps/docs/src/pages/components/workspace-avatar.mdx
index 69e6f81..d801ad8 100644
--- a/apps/docs/src/pages/components/workspace-avatar.mdx
+++ b/apps/docs/src/pages/components/workspace-avatar.mdx
@@ -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 */}
@@ -80,6 +82,14 @@ The three fallback states side by side: logo, initials, and the anonymous worksp
+### With tooltip
+
+Pass `tooltip` to show the workspace name on hover. `true` reuses `alt`; a string is the label and works without `alt`.
+
+
+
+
+
{/* API Reference */}
diff --git a/packages/propel/src/components/avatar-group/avatar-group.stories.tsx b/packages/propel/src/components/avatar-group/avatar-group.stories.tsx
index 10c6191..a19e47d 100644
--- a/packages/propel/src/components/avatar-group/avatar-group.stories.tsx
+++ b/packages/propel/src/components/avatar-group/avatar-group.stories.tsx
@@ -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";
@@ -95,3 +95,40 @@ export const OverflowCount: Story = {
),
};
+
+/**
+ * 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) => (
+
+
+
+
+
+
+ ),
+};
+
+/**
+ * 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,
+ });
+ },
+};
diff --git a/packages/propel/src/components/avatar-group/avatar-group.tsx b/packages/propel/src/components/avatar-group/avatar-group.tsx
index befdd9f..a9fc129 100644
--- a/packages/propel/src/components/avatar-group/avatar-group.tsx
+++ b/packages/propel/src/components/avatar-group/avatar-group.tsx
@@ -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.
@@ -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 (
-
+
+
+
);
}
diff --git a/packages/propel/src/components/avatar/avatar.stories.tsx b/packages/propel/src/components/avatar/avatar.stories.tsx
index d09dbcb..b1b282d 100644
--- a/packages/propel/src/components/avatar/avatar.stories.tsx
+++ b/packages/propel/src/components/avatar/avatar.stories.tsx
@@ -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"];
@@ -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) => (
+
+
+
+ ),
+ ],
+ 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) => (
+
+
+
+ ),
+ ],
+ 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
diff --git a/packages/propel/src/components/avatar/avatar.tsx b/packages/propel/src/components/avatar/avatar.tsx
index 0659006..0ce1f79 100644
--- a/packages/propel/src/components/avatar/avatar.tsx
+++ b/packages/propel/src/components/avatar/avatar.tsx
@@ -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 & {
@@ -29,6 +30,11 @@ export type AvatarProps = Omit & {
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;
};
/**
@@ -37,7 +43,16 @@ export type AvatarProps = Omit & {
* 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.
@@ -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`).
- } {...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`.
+ }
+ {...a11y}
+ tabIndex={tooltipLabel ? 0 : tabIndex}
+ >
{src ? } src={src} alt="" /> : null}
{hasInitials ? (
}>
@@ -71,4 +102,5 @@ export function Avatar({ size, src, alt, fallback, delay, ...props }: AvatarProp
)}
);
+ return tooltipLabel ? {avatar} : avatar;
}
diff --git a/packages/propel/src/components/workspace-avatar/workspace-avatar.stories.tsx b/packages/propel/src/components/workspace-avatar/workspace-avatar.stories.tsx
index 9e61bc1..38b2c7e 100644
--- a/packages/propel/src/components/workspace-avatar/workspace-avatar.stories.tsx
+++ b/packages/propel/src/components/workspace-avatar/workspace-avatar.stories.tsx
@@ -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"];
@@ -156,3 +157,54 @@ 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) => (
+
+
+
+ ),
+ ],
+ 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");
+ },
+};
+
+/**
+ * A string `tooltip` without `alt` still names the workspace 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: "Plane workspace" },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+ play: async ({ canvas, userEvent }) => {
+ const body = within(document.body);
+ const avatar = canvas.getByRole("img", { name: "Plane workspace" });
+ await userEvent.hover(avatar);
+ await expect(await body.findByRole("tooltip")).toHaveTextContent("Plane workspace");
+ },
+};
diff --git a/packages/propel/src/components/workspace-avatar/workspace-avatar.tsx b/packages/propel/src/components/workspace-avatar/workspace-avatar.tsx
index 7122728..34f2097 100644
--- a/packages/propel/src/components/workspace-avatar/workspace-avatar.tsx
+++ b/packages/propel/src/components/workspace-avatar/workspace-avatar.tsx
@@ -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. */
@@ -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 for the label (works
+ * without `alt`). Omit for no tooltip.
+ */
+ tooltip?: boolean | string;
};
/**
@@ -38,6 +44,8 @@ export function WorkspaceAvatar({
alt,
fallback,
delay,
+ tooltip,
+ tabIndex,
...props
}: WorkspaceAvatarProps) {
// Base UI shows the fallback whenever the logo is absent, loading, or failed, so the
@@ -48,16 +56,31 @@ export function WorkspaceAvatar({
// Variant is always system-chosen (never a consumer prop): seed a stable color from the name, else
// the initials text, so unnamed workspaces 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 (logo / initials)
- // 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. (Matches `components/avatar`.)
- 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 (logo / initials) 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. (Matches
+ // `components/avatar`.) 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'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.
- } {...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`.
+ }
+ {...a11y}
+ tabIndex={tooltipLabel ? 0 : tabIndex}
+ >
{src ? } src={src} alt="" /> : null}
{hasInitials ? (
);
+ return tooltipLabel ? {avatar} : avatar;
}