From 95c231bb4b66146fcf33ae15bc4a320a3d0e945c Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 3 Aug 2026 11:01:11 +0200 Subject: [PATCH 01/11] Refactor badge component --- .../src/components/Badge/badge.recipe.ts | 133 ++++++ .../src/components/Badge/badge.stories.tsx | 377 +++++++----------- .../src/components/Badge/badge.tsx | 275 +++---------- 3 files changed, 353 insertions(+), 432 deletions(-) create mode 100644 libs/@hashintel/ds-components/src/components/Badge/badge.recipe.ts 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..c607afc2c9a --- /dev/null +++ b/libs/@hashintel/ds-components/src/components/Badge/badge.recipe.ts @@ -0,0 +1,133 @@ +import { css, cva } from "@hashintel/ds-helpers/css"; + +export const badgeVariants = ["fill", "outline"] as const; +export type BadgeVariant = (typeof badgeVariants)[number]; + +// The `position: relative` wrapper around the anchor (`children`), so the badge +// overhangs the anchor's corner rather than the whole viewport. +export const badgeWrapper = css({ + position: "relative", + display: "inline-flex", +}); + +// The badge itself: a small status pill (e.g. "99+") that overhangs a corner of +// the wrapper like a styled sup/sub. It's a passive overlay +// (pointer-events: none) unless clickable, so it never swallows clicks meant +// for the anchor it decorates. With no `content` it collapses to a small dot. +export const badgeRecipe = cva({ + base: { + position: "absolute", + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + 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", + pointerEvents: "none", + border: "1px solid transparent", + borderRadius: "[var(--badge-radius)]", + outline: "none", + "--badge-size": "[16px]", + "--badge-radius": "var(--radii-sm)", + "--badge-ring-color": + "[color-mix(in oklab, var(--colors-color-palette-fg-link) 65%, transparent)]", + transition: + "[background 0.15s ease, color 0.15s ease, border-color 0.15s ease]", + }, + variants: { + color: { + grey: { colorPalette: "neutral" }, + red: { colorPalette: "red" }, + blue: { colorPalette: "blue" }, + green: { colorPalette: "green" }, + orange: { colorPalette: "orange" }, + yellow: { colorPalette: "yellow" }, + purple: { colorPalette: "purple" }, + pink: { colorPalette: "pink" }, + }, + variant: { + fill: { + background: "colorPalette.bgSolid.solid", + color: "colorPalette.fg.onSolid", + }, + outline: { + background: "white", + borderColor: "colorPalette.bd.solid", + color: "colorPalette.fg.link", + }, + }, + shape: { + default: {}, + round: { "--badge-radius": "var(--radii-full)" }, + }, + // Centre the badge on the chosen corner of the wrapper. + position: { + "top-left": { top: "0", left: "0", transform: "[translate(-50%, -50%)]" }, + "top-right": { + top: "0", + right: "0", + transform: "[translate(50%, -50%)]", + }, + "bottom-left": { + bottom: "0", + left: "0", + transform: "[translate(-50%, 50%)]", + }, + "bottom-right": { + bottom: "0", + right: "0", + transform: "[translate(50%, 50%)]", + }, + }, + // Content-less: collapse to a small round dot. + dot: { + true: { + "--badge-size": "[8px]", + "--badge-radius": "var(--radii-full)", + paddingInline: "0", + }, + }, + clickable: { + true: { + cursor: "pointer", + appearance: "none", + pointerEvents: "auto", + "&:focus-visible": { + boxShadow: "[0 0 0 2px var(--badge-ring-color)]", + }, + }, + }, + }, + compoundVariants: [ + { + clickable: true, + variant: "fill", + css: { _hover: { background: "colorPalette.bgSolid.solid.hover" } }, + }, + { + clickable: true, + variant: "outline", + css: { + _hover: { + borderColor: "colorPalette.bd.solid.hover", + color: "colorPalette.fg.link.hover", + }, + }, + }, + ], + defaultVariants: { + color: "grey", + variant: "fill", + shape: "default", + position: "top-right", + }, +}); 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..3ec1f16e5a3 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,185 @@ import { css } from "@hashintel/ds-helpers/css"; +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 variants: NonNullable[] = ["fill", "outline"]; + +const shapes: NonNullable[] = ["default", "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", +}); + +const column = css({ + display: "flex", + flexDirection: "column", + gap: "[20px]", +}); + +const rowLabel = css({ + width: "[90px]", + flexShrink: "0", + textStyle: "sm", + color: "fg.muted", +}); + +const bell = css({ color: "fg.muted" }); + +/** The bell icon the badge attaches to in these examples. */ +const Bell = () => ; + 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 } }, + variant: { control: { type: "select", options: variants } }, + 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", + variant: "fill", + 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"] }, -}; - -export const SquareBadges: Story = (args) => ( -
- - 2 - - - 5 - - - 9 - - - 12 - +// Every colour, in both variants, attached to a bell. +export const Default: Story = (args) => ( +
+ {variants.map((variant) => ( +
+
{variant}
+ {colors.map((color) => ( + + + + ))} +
+ ))}
); -SquareBadges.parameters = { - controls: { exclude: ["children", "size", "isSquare"] }, -}; - -export const WithIcons: Story = (args) => ( -
-
+ +export const Shape: Story = (args) => ( +
+ {shapes.map((shape) => ( - - - } + key={shape} + content={9} + shape={shape} + color={args.color} + variant={args.variant} > - With Left Icon + + ))} +
+); +Shape.parameters = { controls: { exclude: ["shape", "content"] } }; + +// Each corner, so the overhang offsets can be checked. +export const Position: Story = (args) => ( +
+ {positions.map((position) => ( - - - } + key={position} + content={9} + position={position} + color={args.color} + variant={args.variant} + shape={args.shape} > - With Right Icon + + ))} +
+); +Position.parameters = { controls: { exclude: ["position", "content"] } }; + +// `max` caps numeric content (99 → "99", 100/1000 → "99+"). A string is shown +// verbatim, and omitting `content` renders a plain dot. +export const Content: Story = (args) => ( +
+ {[9, 99, 100, 1000].map((count) => ( - - - } + key={count} + content={count} + max={99} + color={args.color} + variant={args.variant} + shape={args.shape} > - Premium + -
+ ))} + + + + + +
); -WithIcons.parameters = { - controls: { exclude: ["children"] }, -}; - -export const AllCombinations: Story = () => { - const colors: Array = [ - "gray", - "brand", - "green", - "orange", - "red", - "purple", - "pink", - "yellow", - ]; - const sizes: Array = ["xs", "sm", "md", "lg"]; - - return ( -
= (args) => ( +
+ - {colors.map((color) => ( -
-
- {color} -
- {sizes.map((size) => ( - - {color} - - ))} - {sizes.map((size) => ( - - 2 - - ))} -
- ))} -
- ); -}; -AllCombinations.parameters = { - controls: { disabled: true }, -}; + + + + + +
+); +Clickable.parameters = { controls: { exclude: ["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..4b202ef6af7 100644 --- a/libs/@hashintel/ds-components/src/components/Badge/badge.tsx +++ b/libs/@hashintel/ds-components/src/components/Badge/badge.tsx @@ -1,221 +1,76 @@ -import { css, cva } from "@hashintel/ds-helpers/css"; +import { cx } from "@hashintel/ds-helpers/css"; -import type { ReactNode } from "react"; +import { badgeRecipe, badgeWrapper } from "./badge.recipe"; + +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; + /** 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?: "default" | "round"; + color?: ChipColor; + variant?: "fill" | "outline"; + /** Which corner of the anchor the badge overhangs. */ + position?: "top-left" | "top-right" | "bottom-left" | "bottom-right"; + /** + * 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; + onClick?: () => void; } -// 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, children, - colorScheme = "gray", - size = "xs", - isSquare = false, - iconLeft, - iconRight, -}) => { + content, + shape = "default", + color = "grey", + variant = "fill", + position = "top-right", + max, + onClick, +}: BadgeProps) => { + const isDot = content === undefined || content === null; + const display = + max !== undefined && typeof content === "number" && content > max + ? `${max}+` + : content; + + const badgeClassName = badgeRecipe({ + color, + variant, + shape, + position, + dot: isDot, + clickable: !!onClick, + }); + + const badge = onClick ? ( + + ) : ( + {display} + ); + return ( - - {iconLeft && ( - - {iconLeft} - - )} + {children} - {iconRight && ( - - {iconRight} - - )} + {badge} ); }; From 0bb32d8413273848da3d98b3d52623fe2c6c7941 Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 3 Aug 2026 11:36:52 +0200 Subject: [PATCH 02/11] Better stories for badge --- .../src/components/Badge/badge.recipe.ts | 1 + .../src/components/Badge/badge.stories.tsx | 273 ++++++++++++------ .../src/components/Badge/badge.tsx | 6 +- 3 files changed, 192 insertions(+), 88 deletions(-) diff --git a/libs/@hashintel/ds-components/src/components/Badge/badge.recipe.ts b/libs/@hashintel/ds-components/src/components/Badge/badge.recipe.ts index c607afc2c9a..e5ebdb5c669 100644 --- a/libs/@hashintel/ds-components/src/components/Badge/badge.recipe.ts +++ b/libs/@hashintel/ds-components/src/components/Badge/badge.recipe.ts @@ -20,6 +20,7 @@ export const badgeRecipe = cva({ display: "inline-flex", alignItems: "center", justifyContent: "center", + gap: "0.5", boxSizing: "border-box", flexShrink: "0", minWidth: "[var(--badge-size)]", 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 3ec1f16e5a3..e83f0b013a9 100644 --- a/libs/@hashintel/ds-components/src/components/Badge/badge.stories.tsx +++ b/libs/@hashintel/ds-components/src/components/Badge/badge.stories.tsx @@ -1,5 +1,7 @@ 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"; @@ -37,6 +39,15 @@ const row = css({ 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", @@ -50,11 +61,105 @@ const rowLabel = css({ color: "fg.muted", }); +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 these examples. */ +/** 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", +}); + +// 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, + variant, +}: Pick) => ( +
+ + + + + + + + + + + + + + + + + + Messages + + + + + Updates + + + + + + + +
+); + export default { title: "Components/Badge", parameters: { @@ -76,110 +181,110 @@ export default { }, } satisfies StoryDefault; -// Every colour, in both variants, attached to a bell. -export const Default: Story = (args) => ( +// A single showcase covering colours/variants, shape, content, and clickable. +export const Default: Story = () => (
+ Colours & variants {variants.map((variant) => (
{variant}
{colors.map((color) => ( - + ))}
))} -
-); -export const Shape: Story = (args) => ( -
- {shapes.map((shape) => ( - + Shape +
+ {shapes.map((shape) => ( + + + + ))} +
+ + {/* `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) => ( + + + + ))} + + + + } color="green"> - ))} -
-); -Shape.parameters = { controls: { exclude: ["shape", "content"] } }; - -// Each corner, so the overhang offsets can be checked. -export const Position: Story = (args) => ( -
- {positions.map((position) => ( + + New + + } + color="purple" > - ))} -
-); -Position.parameters = { controls: { exclude: ["position", "content"] } }; + + + +
-// `max` caps numeric content (99 → "99", 100/1000 → "99+"). A string is shown -// verbatim, and omitting `content` renders a plain dot. -export const Content: Story = (args) => ( -
- {[9, 99, 100, 1000].map((count) => ( - + {/* A clickable badge renders as a button with hover and focus-ring states. */} + Clickable +
+ - ))} - - - - - - + + + +
); -Content.parameters = { controls: { exclude: ["content"] } }; - -// A clickable badge renders as a button with hover and focus-ring states. -export const Clickable: Story = (args) => ( -
- - - - - - +Default.parameters = { + controls: { exclude: ["color", "variant", "shape", "position", "content"] }, +}; + +// Each corner, at three content lengths, then the same across a range of anchor +// elements — once with short content, once with long (~10 char) content. +const positionRows: { label: string; content: BadgeProps["content"] }[] = [ + { label: "short", content: 9 }, + { label: "medium", content: "Beta" }, + { label: "long", content: "Processing" }, +]; + +export const Position: Story = (args) => ( +
+ {positionRows.map(({ label, content }) => ( +
+
{label}
+ {positions.map((position) => ( + + + + ))} +
+ ))} + + Attaches to any content + + + …and grows with longer content +
); -Clickable.parameters = { controls: { exclude: ["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 4b202ef6af7..652aee8b43e 100644 --- a/libs/@hashintel/ds-components/src/components/Badge/badge.tsx +++ b/libs/@hashintel/ds-components/src/components/Badge/badge.tsx @@ -41,14 +41,12 @@ export const Badge = ({ color = "grey", variant = "fill", position = "top-right", - max, + max = 99, onClick, }: BadgeProps) => { const isDot = content === undefined || content === null; const display = - max !== undefined && typeof content === "number" && content > max - ? `${max}+` - : content; + typeof content === "number" && content > max ? `${max}+` : content; const badgeClassName = badgeRecipe({ color, From 15d536473abe4a018de64080d750270d72aef275 Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 3 Aug 2026 11:53:16 +0200 Subject: [PATCH 03/11] Better colors --- .../src/components/Badge/badge.recipe.ts | 111 ++++++++---------- .../src/components/Badge/badge.stories.tsx | 63 ++++------ .../src/components/Badge/badge.tsx | 27 +---- 3 files changed, 76 insertions(+), 125 deletions(-) diff --git a/libs/@hashintel/ds-components/src/components/Badge/badge.recipe.ts b/libs/@hashintel/ds-components/src/components/Badge/badge.recipe.ts index e5ebdb5c669..6530b44a028 100644 --- a/libs/@hashintel/ds-components/src/components/Badge/badge.recipe.ts +++ b/libs/@hashintel/ds-components/src/components/Badge/badge.recipe.ts @@ -1,8 +1,5 @@ import { css, cva } from "@hashintel/ds-helpers/css"; -export const badgeVariants = ["fill", "outline"] as const; -export type BadgeVariant = (typeof badgeVariants)[number]; - // The `position: relative` wrapper around the anchor (`children`), so the badge // overhangs the anchor's corner rather than the whole viewport. export const badgeWrapper = css({ @@ -12,8 +9,8 @@ export const badgeWrapper = css({ // The badge itself: a small status pill (e.g. "99+") that overhangs a corner of // the wrapper like a styled sup/sub. It's a passive overlay -// (pointer-events: none) unless clickable, so it never swallows clicks meant -// for the anchor it decorates. With no `content` it collapses to a small dot. +// (pointer-events: none), so it never swallows clicks meant for the anchor it +// decorates. With no `content` it collapses to a small dot. export const badgeRecipe = cva({ base: { position: "absolute", @@ -34,40 +31,60 @@ export const badgeRecipe = cva({ whiteSpace: "nowrap", userSelect: "none", pointerEvents: "none", - border: "1px solid transparent", + background: "[var(--badge-bg)]", + border: "[1px solid var(--badge-bd)]", borderRadius: "[var(--badge-radius)]", - outline: "none", "--badge-size": "[16px]", "--badge-radius": "var(--radii-sm)", - "--badge-ring-color": - "[color-mix(in oklab, var(--colors-color-palette-fg-link) 65%, transparent)]", - transition: - "[background 0.15s ease, color 0.15s ease, border-color 0.15s ease]", }, variants: { + // Each colour is a light tint background (s30) with a saturated text colour + // (s110, except orange's brighter s90 and grey's near-black s120) and a + // slightly deeper border tint (`--badge-bd`). color: { - grey: { colorPalette: "neutral" }, - red: { colorPalette: "red" }, - blue: { colorPalette: "blue" }, - green: { colorPalette: "green" }, - orange: { colorPalette: "orange" }, - yellow: { colorPalette: "yellow" }, - purple: { colorPalette: "purple" }, - pink: { colorPalette: "pink" }, - }, - variant: { - fill: { - background: "colorPalette.bgSolid.solid", - color: "colorPalette.fg.onSolid", + 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", }, - outline: { - background: "white", - borderColor: "colorPalette.bd.solid", - color: "colorPalette.fg.link", + 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: { - default: {}, + square: {}, round: { "--badge-radius": "var(--radii-full)" }, }, // Centre the badge on the chosen corner of the wrapper. @@ -89,46 +106,22 @@ export const badgeRecipe = cva({ transform: "[translate(50%, 50%)]", }, }, - // Content-less: collapse to a small round dot. + // Content-less: collapse to a small round dot of the solid accent colour + // (the tint fill would be near-invisible at this size). `--badge-bd: + // transparent` drops the outline border so the dot stays a clean circle. dot: { true: { "--badge-size": "[8px]", "--badge-radius": "var(--radii-full)", + "--badge-bd": "[transparent]", + background: "[currentColor]", paddingInline: "0", }, }, - clickable: { - true: { - cursor: "pointer", - appearance: "none", - pointerEvents: "auto", - "&:focus-visible": { - boxShadow: "[0 0 0 2px var(--badge-ring-color)]", - }, - }, - }, }, - compoundVariants: [ - { - clickable: true, - variant: "fill", - css: { _hover: { background: "colorPalette.bgSolid.solid.hover" } }, - }, - { - clickable: true, - variant: "outline", - css: { - _hover: { - borderColor: "colorPalette.bd.solid.hover", - color: "colorPalette.fg.link.hover", - }, - }, - }, - ], defaultVariants: { color: "grey", - variant: "fill", - shape: "default", + shape: "round", position: "top-right", }, }); 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 e83f0b013a9..a398419de33 100644 --- a/libs/@hashintel/ds-components/src/components/Badge/badge.stories.tsx +++ b/libs/@hashintel/ds-components/src/components/Badge/badge.stories.tsx @@ -19,9 +19,7 @@ const colors: ChipColor[] = [ "pink", ]; -const variants: NonNullable[] = ["fill", "outline"]; - -const shapes: NonNullable[] = ["default", "round"]; +const shapes: NonNullable[] = ["square", "round"]; const positions: NonNullable[] = [ "top-left", @@ -119,41 +117,37 @@ const AnchorCell = ({ ); // The range of elements a badge attaches to, all carrying the same `content`. -const Anchors = ({ - content, - color, - variant, -}: Pick) => ( +const Anchors = ({ content, color }: Pick) => (
- + - + - + - + Messages - + Updates - + @@ -167,7 +161,6 @@ export default { }, argTypes: { color: { control: { type: "select", options: colors } }, - variant: { control: { type: "select", options: variants } }, shape: { control: { type: "select", options: shapes } }, position: { control: { type: "select", options: positions } }, content: { control: { type: "text" } }, @@ -175,26 +168,22 @@ export default { args: { content: "9", color: "red", - variant: "fill", shape: "round", position: "top-right", }, } satisfies StoryDefault; -// A single showcase covering colours/variants, shape, content, and clickable. +// A single showcase covering colours, shape, and content. export const Default: Story = () => (
- Colours & variants - {variants.map((variant) => ( -
-
{variant}
- {colors.map((color) => ( - - - - ))} -
- ))} + Colours +
+ {colors.map((color) => ( + + + + ))} +
Shape
@@ -235,21 +224,10 @@ export const Default: Story = () => (
- - {/* A clickable badge renders as a button with hover and focus-ring states. */} - Clickable -
- - - - - - -
); Default.parameters = { - controls: { exclude: ["color", "variant", "shape", "position", "content"] }, + controls: { exclude: ["color", "shape", "position", "content"] }, }; // Each corner, at three content lengths, then the same across a range of anchor @@ -271,7 +249,6 @@ export const Position: Story = (args) => ( content={content} position={position} color={args.color} - variant={args.variant} shape={args.shape} > @@ -281,10 +258,10 @@ export const Position: Story = (args) => ( ))} 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 652aee8b43e..73a6a9ee015 100644 --- a/libs/@hashintel/ds-components/src/components/Badge/badge.tsx +++ b/libs/@hashintel/ds-components/src/components/Badge/badge.tsx @@ -13,9 +13,8 @@ export interface BadgeProps { * dot with no content. */ content?: React.ReactNode; - shape?: "default" | "round"; + shape?: "square" | "round"; color?: ChipColor; - variant?: "fill" | "outline"; /** Which corner of the anchor the badge overhangs. */ position?: "top-left" | "top-right" | "bottom-left" | "bottom-right"; /** @@ -24,7 +23,6 @@ export interface BadgeProps { * content. */ max?: number; - onClick?: () => void; } /** @@ -37,38 +35,21 @@ export const Badge = ({ className, children, content, - shape = "default", + shape = "round", color = "grey", - variant = "fill", position = "top-right", max = 99, - onClick, }: BadgeProps) => { const isDot = content === undefined || content === null; const display = typeof content === "number" && content > max ? `${max}+` : content; - const badgeClassName = badgeRecipe({ - color, - variant, - shape, - position, - dot: isDot, - clickable: !!onClick, - }); - - const badge = onClick ? ( - - ) : ( - {display} - ); + const badgeClassName = badgeRecipe({ color, shape, position, dot: isDot }); return ( {children} - {badge} + {display} ); }; From 39ad662c8eefbce10d34c621930516bf968a216b Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 3 Aug 2026 12:24:31 +0200 Subject: [PATCH 04/11] Split badge into base-badge --- .../src/components/Badge/badge.recipe.ts | 38 ++--------------- .../src/components/Badge/badge.tsx | 14 ++++--- .../src/components/Badge/base-badge.recipe.ts | 42 +++++++++++++++++++ .../src/components/Badge/base-badge.tsx | 26 ++++++++++++ 4 files changed, 81 insertions(+), 39 deletions(-) create mode 100644 libs/@hashintel/ds-components/src/components/Badge/base-badge.recipe.ts create mode 100644 libs/@hashintel/ds-components/src/components/Badge/base-badge.tsx diff --git a/libs/@hashintel/ds-components/src/components/Badge/badge.recipe.ts b/libs/@hashintel/ds-components/src/components/Badge/badge.recipe.ts index 6530b44a028..0022552a82a 100644 --- a/libs/@hashintel/ds-components/src/components/Badge/badge.recipe.ts +++ b/libs/@hashintel/ds-components/src/components/Badge/badge.recipe.ts @@ -1,19 +1,10 @@ -import { css, cva } from "@hashintel/ds-helpers/css"; +import { cva } from "@hashintel/ds-helpers/css"; -// The `position: relative` wrapper around the anchor (`children`), so the badge -// overhangs the anchor's corner rather than the whole viewport. -export const badgeWrapper = css({ - position: "relative", - display: "inline-flex", -}); - -// The badge itself: a small status pill (e.g. "99+") that overhangs a corner of -// the wrapper like a styled sup/sub. It's a passive overlay -// (pointer-events: none), so it never swallows clicks meant for the anchor it -// decorates. With no `content` it collapses to a small dot. +// 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: { - position: "absolute", display: "inline-flex", alignItems: "center", justifyContent: "center", @@ -30,7 +21,6 @@ export const badgeRecipe = cva({ fontVariantNumeric: "tabular-nums", whiteSpace: "nowrap", userSelect: "none", - pointerEvents: "none", background: "[var(--badge-bg)]", border: "[1px solid var(--badge-bd)]", borderRadius: "[var(--badge-radius)]", @@ -87,25 +77,6 @@ export const badgeRecipe = cva({ square: {}, round: { "--badge-radius": "var(--radii-full)" }, }, - // Centre the badge on the chosen corner of the wrapper. - position: { - "top-left": { top: "0", left: "0", transform: "[translate(-50%, -50%)]" }, - "top-right": { - top: "0", - right: "0", - transform: "[translate(50%, -50%)]", - }, - "bottom-left": { - bottom: "0", - left: "0", - transform: "[translate(-50%, 50%)]", - }, - "bottom-right": { - bottom: "0", - right: "0", - transform: "[translate(50%, 50%)]", - }, - }, // Content-less: collapse to a small round dot of the solid accent colour // (the tint fill would be near-invisible at this size). `--badge-bd: // transparent` drops the outline border so the dot stays a clean circle. @@ -122,6 +93,5 @@ export const badgeRecipe = cva({ defaultVariants: { color: "grey", shape: "round", - position: "top-right", }, }); diff --git a/libs/@hashintel/ds-components/src/components/Badge/badge.tsx b/libs/@hashintel/ds-components/src/components/Badge/badge.tsx index 73a6a9ee015..85f6b68b21e 100644 --- a/libs/@hashintel/ds-components/src/components/Badge/badge.tsx +++ b/libs/@hashintel/ds-components/src/components/Badge/badge.tsx @@ -1,6 +1,7 @@ import { cx } from "@hashintel/ds-helpers/css"; -import { badgeRecipe, badgeWrapper } from "./badge.recipe"; +import { badgeRecipe } from "./badge.recipe"; +import { BaseBadge } from "./base-badge"; import type { ChipColor } from "../Chip/chip"; @@ -44,12 +45,15 @@ export const Badge = ({ const display = typeof content === "number" && content > max ? `${max}+` : content; - const badgeClassName = badgeRecipe({ color, shape, position, dot: isDot }); + const pill = ( + + {display} + + ); return ( - + {children} - {display} - + ); }; 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..10897b19d38 --- /dev/null +++ b/libs/@hashintel/ds-components/src/components/Badge/base-badge.recipe.ts @@ -0,0 +1,42 @@ +import { css, cva } from "@hashintel/ds-helpers/css"; + +// The `position: relative` wrapper around the anchor (`children`), so the +// overlay overhangs the anchor's corner rather than the whole viewport. +export const baseBadgeWrapper = css({ + position: "relative", + display: "inline-flex", +}); + +// Centres the overlay (`content`) on the chosen corner of the wrapper. It's a +// passive layer (pointer-events: none, inherited by `content`), so it never +// swallows clicks meant for the anchor it decorates. +export const baseBadgePosition = cva({ + base: { + position: "absolute", + display: "inline-flex", + pointerEvents: "none", + }, + variants: { + position: { + "top-left": { top: "0", left: "0", transform: "[translate(-50%, -50%)]" }, + "top-right": { + top: "0", + right: "0", + transform: "[translate(50%, -50%)]", + }, + "bottom-left": { + bottom: "0", + left: "0", + transform: "[translate(-50%, 50%)]", + }, + "bottom-right": { + bottom: "0", + right: "0", + transform: "[translate(50%, 50%)]", + }, + }, + }, + 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..d5e1ec48193 --- /dev/null +++ b/libs/@hashintel/ds-components/src/components/Badge/base-badge.tsx @@ -0,0 +1,26 @@ +import { baseBadgePosition, baseBadgeWrapper } from "./base-badge.recipe"; + +export interface BaseBadgeProps { + /** 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"; +} + +/** + * 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 = ({ + children, + content, + position = "top-right", +}: BaseBadgeProps) => ( + + {children} + {content} + +); From 2627b177732dbd3b7a2ea7533ef17371a5bd2db7 Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 3 Aug 2026 13:01:03 +0200 Subject: [PATCH 05/11] Better positioning --- .../src/components/Badge/badge.stories.tsx | 61 +++++++++---------- .../src/components/Badge/base-badge.recipe.ts | 42 +++++++++---- .../src/components/Badge/base-badge.tsx | 10 ++- 3 files changed, 69 insertions(+), 44 deletions(-) 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 a398419de33..1f88d30b32b 100644 --- a/libs/@hashintel/ds-components/src/components/Badge/badge.stories.tsx +++ b/libs/@hashintel/ds-components/src/components/Badge/badge.stories.tsx @@ -117,37 +117,41 @@ const AnchorCell = ({ ); // The range of elements a badge attaches to, all carrying the same `content`. -const Anchors = ({ content, color }: Pick) => ( +const Anchors = ({ + content, + color, + position, +}: Pick) => (
- + - + - + - + Messages - + Updates - + @@ -230,38 +234,33 @@ Default.parameters = { controls: { exclude: ["color", "shape", "position", "content"] }, }; -// Each corner, at three content lengths, then the same across a range of anchor -// elements — once with short content, once with long (~10 char) content. -const positionRows: { label: string; content: BadgeProps["content"] }[] = [ - { label: "short", content: 9 }, - { label: "medium", content: "Beta" }, - { label: "long", content: "Processing" }, -]; - +// Short content at each corner, then the badge across a range of anchor +// elements — with short content, and with long content at two corners to show +// the width clamp extending outward from the anchor's midline. export const Position: Story = (args) => (
- {positionRows.map(({ label, content }) => ( -
-
{label}
- {positions.map((position) => ( - - - - ))} -
- ))} +
+
short
+ {positions.map((position) => ( + + + + ))} +
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/base-badge.recipe.ts b/libs/@hashintel/ds-components/src/components/Badge/base-badge.recipe.ts index 10897b19d38..45cf5acb68d 100644 --- a/libs/@hashintel/ds-components/src/components/Badge/base-badge.recipe.ts +++ b/libs/@hashintel/ds-components/src/components/Badge/base-badge.recipe.ts @@ -7,32 +7,52 @@ export const baseBadgeWrapper = css({ display: "inline-flex", }); -// Centres the overlay (`content`) on the chosen corner of the wrapper. It's a -// passive layer (pointer-events: none, inherited by `content`), so it never -// swallows clicks meant for the anchor it decorates. +// A passive layer the exact size of the anchor (inset: 0). It's an inline-size +// query container, which lets the overlay position against the anchor's width +// via `cqw` units (100cqw === the anchor's width). pointer-events: none — +// inherited by the overlay — keeps it from swallowing clicks meant for the +// anchor; it adds no paint containment, so the overlay can still overflow it. +export const baseBadgeFrame = css({ + position: "absolute", + inset: "0", + containerType: "inline-size", + pointerEvents: "none", +}); + +// Positions the overlay (`content`) on a corner of the anchor. Normally it's +// centred on the corner (the original behaviour); once the overlay's inner edge +// would cross the anchor's horizontal midline — long content and/or a narrow +// anchor — it clamps there and grows outward instead, so the overlay never +// covers more than half the anchor's width. +// +// 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", - pointerEvents: "none", + left: "0", }, variants: { position: { - "top-left": { top: "0", left: "0", transform: "[translate(-50%, -50%)]" }, + "top-left": { + top: "0", + transform: "[translate(min(-50%, 50cqw - 100%), -50%)]", + }, "top-right": { top: "0", - right: "0", - transform: "[translate(50%, -50%)]", + transform: "[translate(max(100cqw - 50%, 50cqw), -50%)]", }, "bottom-left": { bottom: "0", - left: "0", - transform: "[translate(-50%, 50%)]", + transform: "[translate(min(-50%, 50cqw - 100%), 50%)]", }, "bottom-right": { bottom: "0", - right: "0", - transform: "[translate(50%, 50%)]", + transform: "[translate(max(100cqw - 50%, 50cqw), 50%)]", }, }, }, diff --git a/libs/@hashintel/ds-components/src/components/Badge/base-badge.tsx b/libs/@hashintel/ds-components/src/components/Badge/base-badge.tsx index d5e1ec48193..0cc0273df52 100644 --- a/libs/@hashintel/ds-components/src/components/Badge/base-badge.tsx +++ b/libs/@hashintel/ds-components/src/components/Badge/base-badge.tsx @@ -1,4 +1,8 @@ -import { baseBadgePosition, baseBadgeWrapper } from "./base-badge.recipe"; +import { + baseBadgeFrame, + baseBadgePosition, + baseBadgeWrapper, +} from "./base-badge.recipe"; export interface BaseBadgeProps { /** The element the overlay attaches to (e.g. an icon). */ @@ -21,6 +25,8 @@ export const BaseBadge = ({ }: BaseBadgeProps) => ( {children} - {content} + + {content} + ); From a2f7e6772186f0d1e148aef2b89a594df64ff062 Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 3 Aug 2026 14:12:20 +0200 Subject: [PATCH 06/11] Added className prop to badge --- .../ds-components/src/components/Badge/badge.tsx | 11 +++++++++-- .../ds-components/src/components/Badge/base-badge.tsx | 6 +++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/libs/@hashintel/ds-components/src/components/Badge/badge.tsx b/libs/@hashintel/ds-components/src/components/Badge/badge.tsx index 85f6b68b21e..f2cfa938602 100644 --- a/libs/@hashintel/ds-components/src/components/Badge/badge.tsx +++ b/libs/@hashintel/ds-components/src/components/Badge/badge.tsx @@ -7,6 +7,7 @@ import type { ChipColor } from "../Chip/chip"; export interface BadgeProps { className?: string; + contentClassName?: string; /** The element the badge attaches to (e.g. an icon). */ children: React.ReactNode; /** @@ -34,6 +35,7 @@ export interface BadgeProps { */ export const Badge = ({ className, + contentClassName, children, content, shape = "round", @@ -46,13 +48,18 @@ export const Badge = ({ typeof content === "number" && content > max ? `${max}+` : content; const pill = ( - + {display} ); return ( - + {children} ); diff --git a/libs/@hashintel/ds-components/src/components/Badge/base-badge.tsx b/libs/@hashintel/ds-components/src/components/Badge/base-badge.tsx index 0cc0273df52..d457a3e96f4 100644 --- a/libs/@hashintel/ds-components/src/components/Badge/base-badge.tsx +++ b/libs/@hashintel/ds-components/src/components/Badge/base-badge.tsx @@ -1,3 +1,5 @@ +import { cx } from "@hashintel/ds-helpers/css"; + import { baseBadgeFrame, baseBadgePosition, @@ -5,6 +7,7 @@ import { } 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`. */ @@ -19,11 +22,12 @@ export interface BaseBadgeProps { * leaving the overlay's own appearance to the caller (see `Badge`). */ export const BaseBadge = ({ + className, children, content, position = "top-right", }: BaseBadgeProps) => ( - + {children} {content} From c492baa4f64ffb11f2f76f071775a8199970e9fa Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 3 Aug 2026 14:30:22 +0200 Subject: [PATCH 07/11] Add alignTo prop --- .../src/components/Badge/badge.stories.tsx | 7 ++++- .../src/components/Badge/badge.tsx | 14 ++++++++- .../src/components/Badge/base-badge.recipe.ts | 29 ++++++++++++++++--- .../src/components/Badge/base-badge.tsx | 11 ++++++- 4 files changed, 54 insertions(+), 7 deletions(-) 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 1f88d30b32b..3c3e8a301fc 100644 --- a/libs/@hashintel/ds-components/src/components/Badge/badge.stories.tsx +++ b/libs/@hashintel/ds-components/src/components/Badge/badge.stories.tsx @@ -136,7 +136,12 @@ const Anchors = ({ - + diff --git a/libs/@hashintel/ds-components/src/components/Badge/badge.tsx b/libs/@hashintel/ds-components/src/components/Badge/badge.tsx index f2cfa938602..27a0d5ec030 100644 --- a/libs/@hashintel/ds-components/src/components/Badge/badge.tsx +++ b/libs/@hashintel/ds-components/src/components/Badge/badge.tsx @@ -19,6 +19,12 @@ export interface BadgeProps { 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"; /** * Caps numeric `content`: a value above `max` renders as `{max}+` (e.g. * `content={100}` with `max={99}` shows "99+"). Ignored for non-numeric @@ -41,6 +47,7 @@ export const Badge = ({ shape = "round", color = "grey", position = "top-right", + alignTo, max = 99, }: BadgeProps) => { const isDot = content === undefined || content === null; @@ -59,7 +66,12 @@ export const Badge = ({ ); 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 index 45cf5acb68d..6b6ff2c25f6 100644 --- a/libs/@hashintel/ds-components/src/components/Badge/base-badge.recipe.ts +++ b/libs/@hashintel/ds-components/src/components/Badge/base-badge.recipe.ts @@ -30,29 +30,50 @@ export const baseBadgeFrame = css({ // 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. +// +// A trailing inset translate (`--align-inset`, 0 unless `alignTo: circle`) +// nudges the overlay diagonally toward the centre so it lands on a circular +// anchor's edge instead of its empty bounding-box corner. 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%)]", + 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%)]", + 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%)]", + 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%)]", + 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%)", }, }, }, diff --git a/libs/@hashintel/ds-components/src/components/Badge/base-badge.tsx b/libs/@hashintel/ds-components/src/components/Badge/base-badge.tsx index d457a3e96f4..31ae3542840 100644 --- a/libs/@hashintel/ds-components/src/components/Badge/base-badge.tsx +++ b/libs/@hashintel/ds-components/src/components/Badge/base-badge.tsx @@ -14,6 +14,12 @@ export interface BaseBadgeProps { 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"; } /** @@ -26,11 +32,14 @@ export const BaseBadge = ({ children, content, position = "top-right", + alignTo, }: BaseBadgeProps) => ( {children} - {content} + + {content} + ); From 93f99b9f92b75573fca0d24b065722c98c269dca Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 3 Aug 2026 14:33:53 +0200 Subject: [PATCH 08/11] Add hideBadge prop --- .../ds-components/src/components/Badge/badge.tsx | 4 ++++ .../src/components/Badge/base-badge.tsx | 13 +++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/libs/@hashintel/ds-components/src/components/Badge/badge.tsx b/libs/@hashintel/ds-components/src/components/Badge/badge.tsx index 27a0d5ec030..be97e6449ef 100644 --- a/libs/@hashintel/ds-components/src/components/Badge/badge.tsx +++ b/libs/@hashintel/ds-components/src/components/Badge/badge.tsx @@ -25,6 +25,8 @@ export interface BadgeProps { * 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 @@ -48,6 +50,7 @@ export const Badge = ({ color = "grey", position = "top-right", alignTo, + hide, max = 99, }: BadgeProps) => { const isDot = content === undefined || content === null; @@ -70,6 +73,7 @@ export const Badge = ({ className={className} position={position} alignTo={alignTo} + hide={hide} content={pill} > {children} diff --git a/libs/@hashintel/ds-components/src/components/Badge/base-badge.tsx b/libs/@hashintel/ds-components/src/components/Badge/base-badge.tsx index 31ae3542840..f7ae259596a 100644 --- a/libs/@hashintel/ds-components/src/components/Badge/base-badge.tsx +++ b/libs/@hashintel/ds-components/src/components/Badge/base-badge.tsx @@ -20,6 +20,8 @@ export interface BaseBadgeProps { * corner. Assumes a square/circular anchor. */ alignTo?: "circle"; + /** When true, the overlay is not rendered — only the anchor shows. */ + hide?: boolean; } /** @@ -33,13 +35,16 @@ export const BaseBadge = ({ content, position = "top-right", alignTo, + hide, }: BaseBadgeProps) => ( {children} - - - {content} + {!hide && ( + + + {content} + - + )} ); From 1d755747d0ced64f44315dcfe9afd329e1988275 Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 3 Aug 2026 14:42:01 +0200 Subject: [PATCH 09/11] Added changeset --- .changeset/shy-turkeys-pump.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/shy-turkeys-pump.md 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 From f3a5b42d2f07a0d9051eeb0ba83437d4e1935618 Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 3 Aug 2026 15:15:24 +0200 Subject: [PATCH 10/11] Cleanup comments on badge --- .../src/components/Badge/badge.recipe.ts | 6 ------ .../src/components/Badge/base-badge.recipe.ts | 18 +----------------- 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/libs/@hashintel/ds-components/src/components/Badge/badge.recipe.ts b/libs/@hashintel/ds-components/src/components/Badge/badge.recipe.ts index 0022552a82a..54b9d679884 100644 --- a/libs/@hashintel/ds-components/src/components/Badge/badge.recipe.ts +++ b/libs/@hashintel/ds-components/src/components/Badge/badge.recipe.ts @@ -28,9 +28,6 @@ export const badgeRecipe = cva({ "--badge-radius": "var(--radii-sm)", }, variants: { - // Each colour is a light tint background (s30) with a saturated text colour - // (s110, except orange's brighter s90 and grey's near-black s120) and a - // slightly deeper border tint (`--badge-bd`). color: { grey: { "--badge-bg": "var(--colors-neutral-s30)", @@ -77,9 +74,6 @@ export const badgeRecipe = cva({ square: {}, round: { "--badge-radius": "var(--radii-full)" }, }, - // Content-less: collapse to a small round dot of the solid accent colour - // (the tint fill would be near-invisible at this size). `--badge-bd: - // transparent` drops the outline border so the dot stays a clean circle. dot: { true: { "--badge-size": "[8px]", 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 index 6b6ff2c25f6..1d5ab0e9758 100644 --- a/libs/@hashintel/ds-components/src/components/Badge/base-badge.recipe.ts +++ b/libs/@hashintel/ds-components/src/components/Badge/base-badge.recipe.ts @@ -1,17 +1,10 @@ import { css, cva } from "@hashintel/ds-helpers/css"; -// The `position: relative` wrapper around the anchor (`children`), so the -// overlay overhangs the anchor's corner rather than the whole viewport. export const baseBadgeWrapper = css({ position: "relative", display: "inline-flex", }); -// A passive layer the exact size of the anchor (inset: 0). It's an inline-size -// query container, which lets the overlay position against the anchor's width -// via `cqw` units (100cqw === the anchor's width). pointer-events: none — -// inherited by the overlay — keeps it from swallowing clicks meant for the -// anchor; it adds no paint containment, so the overlay can still overflow it. export const baseBadgeFrame = css({ position: "absolute", inset: "0", @@ -19,21 +12,12 @@ export const baseBadgeFrame = css({ pointerEvents: "none", }); -// Positions the overlay (`content`) on a corner of the anchor. Normally it's -// centred on the corner (the original behaviour); once the overlay's inner edge -// would cross the anchor's horizontal midline — long content and/or a narrow -// anchor — it clamps there and grows outward instead, so the overlay never -// covers more than half the anchor's width. -// +// 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. -// -// A trailing inset translate (`--align-inset`, 0 unless `alignTo: circle`) -// nudges the overlay diagonally toward the centre so it lands on a circular -// anchor's edge instead of its empty bounding-box corner. export const baseBadgePosition = cva({ base: { position: "absolute", From 90f03102c67cc68c8ad592f1d0126309c65423c8 Mon Sep 17 00:00:00 2001 From: alex leon Date: Tue, 4 Aug 2026 11:25:27 +0200 Subject: [PATCH 11/11] Rebase against main --- .../ds-components/src/components/Avatar/avatar.tsx | 4 ++-- .../src/components/Badge/badge.stories.tsx | 11 ++--------- libs/@hashintel/ds-components/src/main.ts | 3 ++- 3 files changed, 6 insertions(+), 12 deletions(-) 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.stories.tsx b/libs/@hashintel/ds-components/src/components/Badge/badge.stories.tsx index 3c3e8a301fc..46c9963fa79 100644 --- a/libs/@hashintel/ds-components/src/components/Badge/badge.stories.tsx +++ b/libs/@hashintel/ds-components/src/components/Badge/badge.stories.tsx @@ -52,13 +52,6 @@ const column = css({ gap: "[20px]", }); -const rowLabel = css({ - width: "[90px]", - flexShrink: "0", - textStyle: "sm", - color: "fg.muted", -}); - const sectionTitle = css({ textStyle: "sm", fontWeight: "semibold", @@ -91,6 +84,7 @@ 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. @@ -142,7 +136,7 @@ const Anchors = ({ position={position} alignTo="circle" > - + @@ -245,7 +239,6 @@ Default.parameters = { export const Position: Story = (args) => (
-
short
{positions.map((position) => (