diff --git a/.changeset/shy-turkeys-pump.md b/.changeset/shy-turkeys-pump.md new file mode 100644 index 00000000000..e25ca981d31 --- /dev/null +++ b/.changeset/shy-turkeys-pump.md @@ -0,0 +1,5 @@ +--- +"@hashintel/ds-components": minor +--- + +Badge: new UI + api diff --git a/libs/@hashintel/ds-components/src/components/Avatar/avatar.tsx b/libs/@hashintel/ds-components/src/components/Avatar/avatar.tsx index 1e95e4b9ac6..3c5016a831c 100644 --- a/libs/@hashintel/ds-components/src/components/Avatar/avatar.tsx +++ b/libs/@hashintel/ds-components/src/components/Avatar/avatar.tsx @@ -21,7 +21,7 @@ type AvatarProps = { | { initials: string } | { icon: IconName } | { custom: React.ReactNode }; - shape: "circle" | "square"; + shape?: "circle" | "square"; tone?: Extract; } & ExclusifyUnion< | { onClick?: React.ButtonHTMLAttributes["onClick"] } @@ -40,7 +40,7 @@ const placeholderIconSize: Record = { export const Avatar = ({ className, - shape, + shape = "circle", src, alt, size = "md", diff --git a/libs/@hashintel/ds-components/src/components/Badge/badge.recipe.ts b/libs/@hashintel/ds-components/src/components/Badge/badge.recipe.ts new file mode 100644 index 00000000000..54b9d679884 --- /dev/null +++ b/libs/@hashintel/ds-components/src/components/Badge/badge.recipe.ts @@ -0,0 +1,91 @@ +import { cva } from "@hashintel/ds-helpers/css"; + +// The badge pill itself (e.g. "99+"): a small tinted status chip. `BaseBadge` +// owns where it sits; this recipe owns how it looks. With no `content` it +// collapses to a small dot. +export const badgeRecipe = cva({ + base: { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + gap: "0.5", + boxSizing: "border-box", + flexShrink: "0", + minWidth: "[var(--badge-size)]", + height: "[var(--badge-size)]", + paddingInline: "var(--spacing-1)", + fontFamily: "body", + fontSize: "xxs", + fontWeight: "semibold", + lineHeight: "[1]", + fontVariantNumeric: "tabular-nums", + whiteSpace: "nowrap", + userSelect: "none", + background: "[var(--badge-bg)]", + border: "[1px solid var(--badge-bd)]", + borderRadius: "[var(--badge-radius)]", + "--badge-size": "[16px]", + "--badge-radius": "var(--radii-sm)", + }, + variants: { + color: { + grey: { + "--badge-bg": "var(--colors-neutral-s30)", + "--badge-bd": "var(--colors-neutral-s50)", + color: "neutral.s120", + }, + red: { + "--badge-bg": "var(--colors-red-s30)", + "--badge-bd": "var(--colors-red-s40)", + color: "red.s110", + }, + blue: { + "--badge-bg": "var(--colors-blue-s30)", + "--badge-bd": "var(--colors-blue-s45)", + color: "blue.s110", + }, + green: { + "--badge-bg": "var(--colors-green-s30)", + "--badge-bd": "var(--colors-green-s50)", + color: "green.s110", + }, + orange: { + "--badge-bg": "var(--colors-orange-s30)", + "--badge-bd": "var(--colors-orange-s45)", + color: "orange.s90", + }, + yellow: { + "--badge-bg": "var(--colors-yellow-s30)", + "--badge-bd": "var(--colors-yellow-s40)", + color: "yellow.s110", + }, + purple: { + "--badge-bg": "var(--colors-purple-s30)", + "--badge-bd": "var(--colors-purple-s45)", + color: "purple.s110", + }, + pink: { + "--badge-bg": "var(--colors-pink-s30)", + "--badge-bd": "var(--colors-pink-s40)", + color: "pink.s110", + }, + }, + shape: { + square: {}, + round: { "--badge-radius": "var(--radii-full)" }, + }, + dot: { + true: { + "--badge-size": "[8px]", + "--badge-radius": "var(--radii-full)", + "--badge-bd": "[transparent]", + background: "[currentColor]", + paddingInline: "0", + }, + }, + }, + defaultVariants: { + color: "grey", + shape: "round", + }, +}); diff --git a/libs/@hashintel/ds-components/src/components/Badge/badge.stories.tsx b/libs/@hashintel/ds-components/src/components/Badge/badge.stories.tsx index 6c9e78ae3b1..46c9963fa79 100644 --- a/libs/@hashintel/ds-components/src/components/Badge/badge.stories.tsx +++ b/libs/@hashintel/ds-components/src/components/Badge/badge.stories.tsx @@ -1,252 +1,264 @@ import { css } from "@hashintel/ds-helpers/css"; +import { Avatar } from "../Avatar/avatar"; +import { Button } from "../Button/button"; +import { Icon } from "../Icon/icon"; import { Badge, type BadgeProps } from "./badge"; +import type { ChipColor } from "../Chip/chip"; import type { Story, StoryDefault } from "@ladle/react"; +const colors: ChipColor[] = [ + "grey", + "red", + "blue", + "green", + "orange", + "yellow", + "purple", + "pink", +]; + +const shapes: NonNullable[] = ["square", "round"]; + +const positions: NonNullable[] = [ + "top-left", + "top-right", + "bottom-left", + "bottom-right", +]; + +const noop = () => undefined; + +const row = css({ + display: "flex", + gap: "[20px]", + alignItems: "center", + flexWrap: "wrap", +}); + +// Wider gaps for the position / anchor rows: a long badge overhangs its corner +// by ~half its width, so neighbours need room to not collide. +const wideRow = css({ + display: "flex", + gap: "[72px]", + alignItems: "center", + flexWrap: "wrap", +}); + +const column = css({ + display: "flex", + flexDirection: "column", + gap: "[20px]", +}); + +const sectionTitle = css({ + textStyle: "sm", + fontWeight: "semibold", + color: "fg.heading", + marginTop: "[8px]", +}); + +const bell = css({ color: "fg.muted" }); + +/** The bell icon the badge attaches to in most of these examples. */ +const Bell = () => ; + +const SectionTitle = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); + +// An anchor plus its caption, so the "attaches to anything" rows read clearly. +const anchorCell = css({ + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: "[10px]", +}); + +const caption = css({ textStyle: "xs", color: "fg.subtle" }); + +const smallText = css({ textStyle: "xs", color: "fg.body" }); + +const largeText = css({ + textStyle: "2xl", + fontWeight: "semibold", + color: "fg.heading", + lineHeight: "[1]", +}); + +// A stand-in app tile / thumbnail — the kind of square that carries a count. +const tile = css({ + width: "[40px]", + height: "[40px]", + borderRadius: "lg", + background: "bg.subtle", + border: "1px solid", + borderColor: "bd.subtle", +}); + +const AnchorCell = ({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) => ( +
+ {children} + {label} +
+); + +// The range of elements a badge attaches to, all carrying the same `content`. +const Anchors = ({ + content, + color, + position, +}: Pick) => ( +
+ + + + + + + + + + + + + + + + + + Messages + + + + + Updates + + + + + + + +
+); + export default { - title: "Legacy/Badge", + title: "Components/Badge", parameters: { layout: "centered", }, argTypes: { - colorScheme: { - control: { type: "select" }, - options: [ - "gray", - "brand", - "green", - "orange", - "red", - "purple", - "pink", - "yellow", - ], - description: "The color scheme of the badge", - }, - size: { - control: { type: "select" }, - options: ["xs", "sm", "md", "lg"], - description: "The size of the badge", - }, - isSquare: { - control: { type: "boolean" }, - description: "Whether the badge is square (for numeric badges)", - }, - children: { - control: { type: "text" }, - description: "The content of the badge", - }, + color: { control: { type: "select", options: colors } }, + shape: { control: { type: "select", options: shapes } }, + position: { control: { type: "select", options: positions } }, + content: { control: { type: "text" } }, }, args: { - children: "Badge", - colorScheme: "gray", - size: "xs", - isSquare: false, + content: "9", + color: "red", + shape: "round", + position: "top-right", }, } satisfies StoryDefault; -export const Default: Story = (args) => ; -Default.args = { - children: "Badge", -}; - -export const ColorSchemes: Story = (args) => ( -
- - Gray - - - Brand - - - Green - - - Orange - - - Red - - - Purple - - - Pink - - - Yellow - -
-); -ColorSchemes.parameters = { - controls: { exclude: ["children", "colorScheme"] }, -}; - -export const Sizes: Story = () => ( -
- Extra Small - Small - Medium - Large -
-); -Sizes.parameters = { - controls: { exclude: ["children", "size", "isSquare"] }, -}; +// A single showcase covering colours, shape, and content. +export const Default: Story = () => ( +
+ Colours +
+ {colors.map((color) => ( + + + + ))} +
-export const SquareBadges: Story = (args) => ( -
- - 2 - - - 5 - - - 9 - - - 12 - -
-); -SquareBadges.parameters = { - controls: { exclude: ["children", "size", "isSquare"] }, -}; + Shape +
+ {shapes.map((shape) => ( + + + + ))} +
-export const WithIcons: Story = (args) => ( -
-
- - - - } - > - With Left Icon + {/* `max` defaults to 99, so 100/1000 render as "99+". Content can be a + string, an icon, or an icon + text; omitting it renders a plain dot. */} + Content +
+ {[9, 99, 100, 1000].map((count) => ( + + + + ))} + + - - - - } - > - With Right Icon + } color="green"> + - - + content={ + <> + + New + } + color="purple" > - Premium + + + +
); -WithIcons.parameters = { - controls: { exclude: ["children"] }, +Default.parameters = { + controls: { exclude: ["color", "shape", "position", "content"] }, }; -export const AllCombinations: Story = () => { - const colors: Array = [ - "gray", - "brand", - "green", - "orange", - "red", - "purple", - "pink", - "yellow", - ]; - const sizes: Array = ["xs", "sm", "md", "lg"]; - - return ( -
- {colors.map((color) => ( -
= (args) => ( +
+
+ {positions.map((position) => ( + -
- {color} -
- {sizes.map((size) => ( - - {color} - - ))} - {sizes.map((size) => ( - - 2 - - ))} -
+ + ))}
- ); -}; -AllCombinations.parameters = { - controls: { disabled: true }, -}; + + Attaches to any content + + + + …and grows with longer content + + +
+); +Position.parameters = { controls: { exclude: ["position", "content"] } }; diff --git a/libs/@hashintel/ds-components/src/components/Badge/badge.tsx b/libs/@hashintel/ds-components/src/components/Badge/badge.tsx index 9bf5263271b..be97e6449ef 100644 --- a/libs/@hashintel/ds-components/src/components/Badge/badge.tsx +++ b/libs/@hashintel/ds-components/src/components/Badge/badge.tsx @@ -1,221 +1,82 @@ -import { css, cva } from "@hashintel/ds-helpers/css"; +import { cx } from "@hashintel/ds-helpers/css"; -import type { ReactNode } from "react"; +import { badgeRecipe } from "./badge.recipe"; +import { BaseBadge } from "./base-badge"; + +import type { ChipColor } from "../Chip/chip"; export interface BadgeProps { - /** The content of the badge */ - children: ReactNode; - /** The color scheme of the badge */ - colorScheme?: - | "gray" - | "brand" - | "green" - | "orange" - | "red" - | "purple" - | "pink" - | "yellow"; - /** The size of the badge */ - size?: "xs" | "sm" | "md" | "lg"; - /** Whether the badge is square (for numeric badges) */ - isSquare?: boolean; - /** Optional icon to display on the left */ - iconLeft?: ReactNode; - /** Optional icon to display on the right */ - iconRight?: ReactNode; + className?: string; + contentClassName?: string; + /** The element the badge attaches to (e.g. an icon). */ + children: React.ReactNode; + /** + * The badge's own content (e.g. a `99` unread count). Omit to render a small + * dot with no content. + */ + content?: React.ReactNode; + shape?: "square" | "round"; + color?: ChipColor; + /** Which corner of the anchor the badge overhangs. */ + position?: "top-left" | "top-right" | "bottom-left" | "bottom-right"; + /** + * Set to `"circle"` to align the badge to a circular anchor (e.g. a round + * avatar) — it sits on the circle's edge instead of the empty bounding-box + * corner. + */ + alignTo?: "circle"; + /** When true, the badge is not rendered — only the anchor (`children`) shows. */ + hide?: boolean; + /** + * Caps numeric `content`: a value above `max` renders as `{max}+` (e.g. + * `content={100}` with `max={99}` shows "99+"). Ignored for non-numeric + * content. + */ + max?: number; } -// Define recipe for badge styling variants -const badgeRecipe = cva({ - base: { - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - fontWeight: "medium", - textAlign: "center", - whiteSpace: "nowrap", - userSelect: "none", - overflow: "clip", - paddingY: "2", - }, - variants: { - colorScheme: { - gray: { - backgroundColor: "neutral.s20", - color: "neutral.s80", - }, - brand: { - backgroundColor: "blue.s10", - color: "blue.s80", - }, - green: { - backgroundColor: "green.s10", - color: "green.s80", - }, - orange: { - backgroundColor: "orange.s10", - color: "orange.s80", - }, - red: { - backgroundColor: "red.s00", - color: "red.s80", - }, - purple: { - backgroundColor: "purple.s00", - color: "purple.s80", - }, - pink: { - backgroundColor: "pink.s10", - color: "pink.s80", - }, - yellow: { - backgroundColor: "yellow.s10", - color: "yellow.s80", - }, - }, - size: { - xs: { - fontSize: "[9px]", - lineHeight: "[12px]", - gap: "3", - height: "[14px]", - }, - sm: { - fontSize: "xs", - lineHeight: "none", - gap: "3", - height: "[16px]", - }, - md: { - fontSize: "sm", - lineHeight: "none", - gap: "3", - height: "[20px]", - }, - lg: { - fontSize: "base", - lineHeight: "none", - gap: "3", - height: "[24px]", - }, - }, - isSquare: { - true: {}, - false: {}, - }, - }, - compoundVariants: [ - // Rounded badges - padding and border radius - { - isSquare: false, - size: "xs", - css: { - paddingX: "3", - borderRadius: "sm", - }, - }, - { - isSquare: false, - size: "sm", - css: { - paddingX: "3", - borderRadius: "sm", - }, - }, - { - isSquare: false, - size: "md", - css: { - paddingX: "4", - borderRadius: "md", - }, - }, - { - isSquare: false, - size: "lg", - css: { - paddingX: "4", - borderRadius: "md", - }, - }, - // Square badges - fixed width and border radius - { - isSquare: true, - size: "xs", - css: { - paddingX: "3", - width: "[14px]", - borderRadius: "sm", - }, - }, - { - isSquare: true, - size: "sm", - css: { - paddingX: "3", - width: "[16px]", - borderRadius: "sm", - }, - }, - { - isSquare: true, - size: "md", - css: { - paddingX: "4", - width: "[20px]", - borderRadius: "md", - }, - }, - { - isSquare: true, - size: "lg", - css: { - paddingX: "4", - width: "[24px]", - borderRadius: "md", - }, - }, - ], - defaultVariants: { - colorScheme: "gray", - size: "xs", - isSquare: false, - }, -}); - -export const Badge: React.FC = ({ +/** + * A small status pill that attaches to another element like a styled sup/sub — + * e.g. a "99+" unread count overhanging a mail icon. Pass the element to + * decorate as `children` and the badge's content as `content`; the badge is + * positioned in the chosen corner of that element. + */ +export const Badge = ({ + className, + contentClassName, children, - colorScheme = "gray", - size = "xs", - isSquare = false, - iconLeft, - iconRight, -}) => { - return ( - - {iconLeft && ( - - {iconLeft} - - )} - {children} - {iconRight && ( - - {iconRight} - + content, + shape = "round", + color = "grey", + position = "top-right", + alignTo, + hide, + max = 99, +}: BadgeProps) => { + const isDot = content === undefined || content === null; + const display = + typeof content === "number" && content > max ? `${max}+` : content; + + const pill = ( + + {display} ); + + return ( + + {children} + + ); }; diff --git a/libs/@hashintel/ds-components/src/components/Badge/base-badge.recipe.ts b/libs/@hashintel/ds-components/src/components/Badge/base-badge.recipe.ts new file mode 100644 index 00000000000..1d5ab0e9758 --- /dev/null +++ b/libs/@hashintel/ds-components/src/components/Badge/base-badge.recipe.ts @@ -0,0 +1,67 @@ +import { css, cva } from "@hashintel/ds-helpers/css"; + +export const baseBadgeWrapper = css({ + position: "relative", + display: "inline-flex", +}); + +export const baseBadgeFrame = css({ + position: "absolute", + inset: "0", + containerType: "inline-size", + pointerEvents: "none", +}); + +// Positions the overlay (`content`) on a corner of the anchor. +// The overlay is anchored by its left edge (left: 0) and shifted by a transform +// mixing `cqw` (a share of the anchor's width) with `%` (a share of the +// overlay's own width). For a right corner the inner (left) edge sits at +// `max(100cqw - 50%, 50cqw)`: the corner-centred position (anchor width minus +// half the overlay), floored at the 50cqw midline. Left corners mirror this. +export const baseBadgePosition = cva({ + base: { + position: "absolute", + display: "inline-flex", + left: "0", + "--align-inset": "0cqw", + }, + variants: { + position: { + "top-left": { + top: "0", + transform: + "[translate(min(-50%, 50cqw - 100%), -50%) translate(var(--align-inset), var(--align-inset))]", + }, + "top-right": { + top: "0", + transform: + "[translate(max(100cqw - 50%, 50cqw), -50%) translate(calc(-1 * var(--align-inset)), var(--align-inset))]", + }, + "bottom-left": { + bottom: "0", + transform: + "[translate(min(-50%, 50cqw - 100%), 50%) translate(var(--align-inset), calc(-1 * var(--align-inset)))]", + }, + "bottom-right": { + bottom: "0", + transform: + "[translate(max(100cqw - 50%, 50cqw), 50%) translate(calc(-1 * var(--align-inset)), calc(-1 * var(--align-inset)))]", + }, + }, + // Align to a circular anchor. Start from the circle's 45° edge point — + // inset (1 - cos45°) · radius = (1 - cos45°) · 50cqw ≈ 14.6cqw from each edge + // (using width for both axes, i.e. assuming a square/circular anchor) — then + // nudge the badge back outward along the diagonal by 1/6 of its own size + // (cos45° / 6 ≈ 11.8% per axis, resolving against the badge's own width/ + // height) so ~1/3 of it overlaps the circle and 2/3 sits outside, rather + // than being split evenly across the edge. + alignTo: { + circle: { + "--align-inset": "calc((1 - 0.70711) * 50cqw - (0.70711 / 6) * 100%)", + }, + }, + }, + defaultVariants: { + position: "top-right", + }, +}); diff --git a/libs/@hashintel/ds-components/src/components/Badge/base-badge.tsx b/libs/@hashintel/ds-components/src/components/Badge/base-badge.tsx new file mode 100644 index 00000000000..f7ae259596a --- /dev/null +++ b/libs/@hashintel/ds-components/src/components/Badge/base-badge.tsx @@ -0,0 +1,50 @@ +import { cx } from "@hashintel/ds-helpers/css"; + +import { + baseBadgeFrame, + baseBadgePosition, + baseBadgeWrapper, +} from "./base-badge.recipe"; + +export interface BaseBadgeProps { + className?: string; + /** The element the overlay attaches to (e.g. an icon). */ + children: React.ReactNode; + /** The overlay placed on the chosen corner of `children`. */ + content: React.ReactNode; + /** Which corner of the anchor the overlay overhangs. */ + position?: "top-left" | "top-right" | "bottom-left" | "bottom-right"; + /** + * Set to `"circle"` to align the overlay to a circular anchor (e.g. a round + * avatar) — it sits on the circle's edge instead of the empty bounding-box + * corner. Assumes a square/circular anchor. + */ + alignTo?: "circle"; + /** When true, the overlay is not rendered — only the anchor shows. */ + hide?: boolean; +} + +/** + * Positions arbitrary `content` on a corner of `children`, like a styled + * sup/sub. It owns only the layout — the relative wrap and corner placement — + * leaving the overlay's own appearance to the caller (see `Badge`). + */ +export const BaseBadge = ({ + className, + children, + content, + position = "top-right", + alignTo, + hide, +}: BaseBadgeProps) => ( + + {children} + {!hide && ( + + + {content} + + + )} + +); diff --git a/libs/@hashintel/ds-components/src/main.ts b/libs/@hashintel/ds-components/src/main.ts index 0cb5f868641..c2734367f8b 100644 --- a/libs/@hashintel/ds-components/src/main.ts +++ b/libs/@hashintel/ds-components/src/main.ts @@ -1,5 +1,6 @@ export { Avatar } from "./components/Avatar/avatar"; -export { Badge, type BadgeProps } from "./components/Badge/badge"; +export { Badge } from "./components/Badge/badge"; +export { BaseBadge } from "./components/Badge/base-badge"; export { Button, type ButtonProps } from "./components/Button/button"; export { CharacterCount } from "./components/CharacterCount/character-count"; export { Checkbox } from "./components/Checkbox/checkbox";