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; }