diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts index 755742f6..7777f95e 100644 --- a/packages/components/src/components/index.ts +++ b/packages/components/src/components/index.ts @@ -9,6 +9,7 @@ export * from "./columns"; export * from "./expandable"; export * from "./frame"; export * from "./icon"; +export * from "./mention"; export * from "./mermaid"; export * from "./panel"; export * from "./property"; diff --git a/packages/components/src/components/mention/index.ts b/packages/components/src/components/mention/index.ts new file mode 100644 index 00000000..8eb10f97 --- /dev/null +++ b/packages/components/src/components/mention/index.ts @@ -0,0 +1,2 @@ +export type { MentionColor, MentionProps } from "./mention"; +export { MENTION_COLORS, Mention, mentionColorVariants } from "./mention"; diff --git a/packages/components/src/components/mention/mention.stories.tsx b/packages/components/src/components/mention/mention.stories.tsx new file mode 100644 index 00000000..26b34272 --- /dev/null +++ b/packages/components/src/components/mention/mention.stories.tsx @@ -0,0 +1,483 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Mention } from "./mention"; + +const meta: Meta = { + title: "Components/Mention", + component: Mention, + parameters: { + layout: "centered", + }, + // Wrap all stories in text-xs so existing story appearances are preserved now + // that the component inherits font-size from context rather than hardcoding it. + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ["autodocs"], + argTypes: { + children: { + control: "text", + description: "The display label shown inside the mention pill.", + }, + path: { + control: "text", + description: + "Page path for a **page mention**. When set, the component renders as an `` link and shows the page icon by default. Use either `path` or `user`, not both.", + }, + user: { + control: "text", + description: + "Username or identifier for a **user mention**. When set, the component shows the person icon by default. Use either `path` or `user`, not both.", + }, + icon: { + control: "text", + description: + "Optional icon override. Accepts a **FontAwesome or Lucide icon name** (e.g. `plane`, `bed`, `circle-check`), an **image URL** rendered as a circular avatar, or any **React node** for fully custom icon content. Omit to use the default page or person icon.", + }, + color: { + control: "select", + options: ["neutral", "info", "success", "warning", "feature", "error"], + description: "Color variant. Defaults to `neutral`.", + }, + iconType: { + control: "select", + options: [ + "regular", + "solid", + "light", + "duotone", + "thin", + "brands", + "sharp-solid", + "sharp-light", + "sharp-regular", + "sharp-thin", + "sharp-duotone-solid", + ], + description: + "FontAwesome icon style. Only applies when `icon` is a string icon name and `iconLibrary` is `fontawesome`. Defaults to `regular`.", + }, + iconLibrary: { + control: "select", + options: ["fontawesome", "lucide"], + description: + "Icon library used to resolve the `icon` string. Defaults to `fontawesome`. Pass a React node to `icon` to bypass this entirely.", + }, + }, +}; + +export default meta; +type Story = StoryObj; + +// ─── Interactive playground ───────────────────────────────────────────────── +// Use the Controls panel (below) to experiment with every prop. +// • Set `path` for a page mention, `user` for a user mention (not both). +// • `icon` accepts a FontAwesome name (e.g. "plane"), a Lucide name with +// iconLibrary="lucide", or a full image URL for an avatar. + +export const Default: Story = { + args: { + children: "Getting Started", + path: "/getting-started", + color: "neutral", + }, +}; + +// ─── Page mentions ───────────────────────────────────────────────────────── + +export const PageMention: Story = { + name: "Page mention (default icon)", + render: () => ( +
+ Getting Started + API Reference + Changelog +
+ ), +}; + +export const PageMentionCustomIcon: Story = { + name: "Page mention (custom icon)", + render: () => ( +
+ + Travel + + + Accommodation & Meals + + + Sustainability + + + Warnings + +
+ ), +}; + +// ─── User mentions ────────────────────────────────────────────────────────── + +export const UserMention: Story = { + name: "User mention (default icon)", + render: () => ( +
+ + James Baduor + + Alex Chen + + Sara Kim + +
+ ), +}; + +export const UserMentionWithAvatar: Story = { + name: "User mention (avatar URL)", + render: () => ( +
+ + James Baduor + + + Alex Chen + +
+ ), +}; + +// ─── Color variants ───────────────────────────────────────────────────────── + +export const Colors: Story = { + render: () => ( +
+ + Neutral + + + Info + + + Success + + + Warning + + + Feature + + + Error + +
+ ), +}; + +export const ColorsWithUser: Story = { + name: "Colors (user mentions)", + render: () => ( +
+ + Neutral + + + Info + + + Success + + + Warning + + + Feature + + + Error + +
+ ), +}; + +// ─── Sizes ────────────────────────────────────────────────────────────────── +// Because every dimension (icon, padding, gap, radius) is expressed in em, +// the component scales proportionally with whatever font-size surrounds it. + +export const Sizes: Story = { + name: "Sizes", + render: () => ( +
+
+
+ extra small text-xs +
+

+ refer to{" "} + + Documentation + {" "} + or ask{" "} + + Alex Chen + +

+
+
+
+ small text-sm +
+

+ refer to{" "} + + Documentation + {" "} + or ask{" "} + + Alex Chen + +

+
+
+
+ base text-base +
+

+ refer to{" "} + + Documentation + {" "} + or ask{" "} + + Alex Chen + +

+
+
+
+ large text-lg +
+

+ refer to{" "} + + Documentation + {" "} + or ask{" "} + + Alex Chen + +

+
+
+
+ extra large text-xl +
+

+ refer to{" "} + + Documentation + {" "} + or ask{" "} + + Alex Chen + +

+
+
+ ), +}; + +// ─── In-context usage ─────────────────────────────────────────────────────── + +export const InlineInProse: Story = { + name: "Inline in prose (checklist context)", + render: () => ( +
+ + + + +
+ ), +}; + +// ─── Icon library ─────────────────────────────────────────────────────────── + +export const LucideIcons: Story = { + name: "Lucide icon library", + render: () => ( +
+ + Travel + + + Eco + + + New + +
+ ), +}; + +// ─── Wrapping ─────────────────────────────────────────────────────────────── + +export const LongLabelWrapping: Story = { + name: "Long label (wrapping)", + render: () => ( +
+
+

+ Natural wrap inside a narrow container +

+
+ + Accommodation & Meals Planning Guide + +
+
+ +
+

+ Forced line break via <br /> +

+
+ + Q4 Product Roadmap +
+ Planning & Review +
+ + Sustainability +
+ Guidelines +
+
+
+ +
+

+ Inline in prose with wrapping label +

+

+ Please review the{" "} + + Compliance & Legal +
+ Documentation +
{" "} + before proceeding. +

+
+
+ ), +}; + +// ─── All badge variants (design reference) ────────────────────────────────── + +export const DesignReference: Story = { + name: "Design reference — all variants", + render: () => ( +
+
+

+ Page mentions +

+
+ + Page + + + Page + + + Page + + + Page + + + Page + + + Page + +
+
+
+

+ Person mentions +

+
+ + Person + + + Person + + + Person + + + Person + + + Person + + + Person + +
+
+
+ ), +}; diff --git a/packages/components/src/components/mention/mention.tsx b/packages/components/src/components/mention/mention.tsx new file mode 100644 index 00000000..bc1bf275 --- /dev/null +++ b/packages/components/src/components/mention/mention.tsx @@ -0,0 +1,182 @@ +import type React from "react"; +import { Icon } from "@/components/icon"; +import { cn } from "@/utils/cn"; +import type { IconLibrary, IconType } from "@/utils/icon-utils"; + +const MENTION_COLORS = [ + "neutral", + "info", + "success", + "warning", + "feature", + "error", +] as const; + +type MentionColor = (typeof MENTION_COLORS)[number]; + +const colorVariants: Record = { + neutral: + "[--mention-bg:#F5F5F4] dark:[--mention-bg:#292524] [--mention-text:#57534E] dark:[--mention-text:#A8A29E]", + info: "[--mention-bg:#EFF6FF] dark:[--mention-bg:#172554] [--mention-text:#1D4ED8] dark:[--mention-text:#93C5FD]", + success: + "[--mention-bg:#F0FDF4] dark:[--mention-bg:#052E16] [--mention-text:#15803D] dark:[--mention-text:#86EFAC]", + warning: + "[--mention-bg:#FFF7ED] dark:[--mention-bg:#431407] [--mention-text:#C2410C] dark:[--mention-text:#FDBA74]", + feature: + "[--mention-bg:#FAF5FF] dark:[--mention-bg:#3B0764] [--mention-text:#7E22CE] dark:[--mention-text:#D8B4FE]", + error: + "[--mention-bg:#FEF2F2] dark:[--mention-bg:#450A0A] [--mention-text:#B91C1C] dark:[--mention-text:#FCA5A5]", +}; + +type MentionProps = { + children: React.ReactNode; + /** + * Page path for a page mention. When provided, the component renders as an + * anchor tag linking to the page and displays the page icon by default. + */ + path?: string; + /** + * Username or identifier for a user mention. When provided, the component + * displays the user icon by default. + */ + user?: string; + /** + * Optional icon override. Accepts: + * - A FontAwesome or Lucide icon name string (e.g. `"plane"`, `"bed"`) + * - An image URL string, rendered as a circular avatar + * - Any React node for fully custom icon content + */ + icon?: React.ReactNode | string; + /** Icon type for FontAwesome icons. */ + iconType?: IconType; + /** Icon library to use. Defaults to `"fontawesome"`. */ + iconLibrary?: IconLibrary; + /** Color variant. Defaults to `"neutral"`. */ + color?: MentionColor; + className?: string; +}; + +const DEFAULT_PAGE_ICON = "file"; +const DEFAULT_USER_ICON = "circle-user"; + +const isUrl = (s: string) => + s.startsWith("http://") || + s.startsWith("https://") || + s.startsWith("/") || + s.startsWith("data:"); + +const ICON_NAME_REGEX = /^[\w-]+$/; +const isIconName = (s: string) => ICON_NAME_REGEX.test(s); + +const Mention = ({ + children, + path, + user, + icon, + iconType, + iconLibrary = "fontawesome", + color = "neutral", + className, +}: MentionProps) => { + const isUser = !!user; + const defaultIconName = isUser ? DEFAULT_USER_ICON : DEFAULT_PAGE_ICON; + // null → explicitly hide the icon. "" → treat as omitted, fall back to default. + const hideIcon = icon === null; + const resolvedIcon = hideIcon + ? null + : ((icon === "" ? undefined : icon) ?? defaultIconName); + + const renderIcon = () => { + if (resolvedIcon === null) { + return null; + } + + if (typeof resolvedIcon !== "string") { + return ( + + ); + } + + if (isUrl(resolvedIcon)) { + return ( + + ); + } + + if (isIconName(resolvedIcon)) { + return ( + + ); + } + + // Emoji or other non-icon text + return ( + + ); + }; + + const isLink = !!path && !user; + + // When an icon is present the left padding is tightened to match the vertical + // gap so the icon appears equally inset on all three sides (left, top, bottom). + // The right padding stays wider to give the label text room to breathe. + // When there is no icon both sides use the wider right padding value. + const sharedClassName = cn( + "mention", + // leading-none keeps line-height = 1em so all em-based dimensions + // scale proportionally with any font-size applied by the consumer. + "inline-flex items-center leading-none", + // Spacing and radius all in em so every dimension scales with font-size. + // py = 1/3em → pill height = 1em (text) + 2*(1/3em) = 5/3em ≈ 1.667× + // pl = 1/3em → icon is equidistant from left, top, and bottom edges + // pr = 2/3em → right side has double the icon-gap for label breathing room + // no-icon: symmetric at 2/3em on both sides + "gap-[0.333em] rounded-[0.5em] py-[0.333em]", + resolvedIcon !== null ? "pr-[0.667em] pl-[0.333em]" : "px-[0.667em]", + "font-medium", + "bg-(--mention-bg) text-(--mention-text)", + '[&_[data-component-part="icon-svg"]]:bg-(--mention-text)', + // Icon sized to 1em so it always matches the current font-size exactly. + '[&_[data-component-part="icon-svg"]]:size-[1em]', + colorVariants[color], + isLink && "cursor-pointer no-underline transition-opacity hover:opacity-80", + className + ); + + if (isLink) { + return ( +
+ {renderIcon()} + {children} + + ); + } + + return ( + + {renderIcon()} + {children} + + ); +}; + +export { Mention, MENTION_COLORS, colorVariants as mentionColorVariants }; +export type { MentionProps, MentionColor }; diff --git a/packages/components/src/constants/selectors.ts b/packages/components/src/constants/selectors.ts index 49989598..7122946a 100644 --- a/packages/components/src/constants/selectors.ts +++ b/packages/components/src/constants/selectors.ts @@ -11,6 +11,7 @@ const _classes = { Field: "field", Frame: "frame", Icon: "icon", + Mention: "mention", Mermaid: "mermaid", Step: "step", Steps: "steps",