diff --git a/e2e/nextjs-app/src/app/components/badge/anchored.e2e.tsx b/e2e/nextjs-app/src/app/components/badge/anchored.e2e.tsx
new file mode 100644
index 0000000000..320a5bdc52
--- /dev/null
+++ b/e2e/nextjs-app/src/app/components/badge/anchored.e2e.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { Button } from "@lifesg/react-design-system";
+import { Avatar } from "@lifesg/react-design-system/avatar";
+import { Badge } from "@lifesg/react-design-system/badge";
+
+export default function Story() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ Example
+
+
+
+ Example
+
+
+ );
+}
diff --git a/e2e/nextjs-app/src/app/components/badge/badge.module.css b/e2e/nextjs-app/src/app/components/badge/badge.module.css
new file mode 100644
index 0000000000..fe941b34bd
--- /dev/null
+++ b/e2e/nextjs-app/src/app/components/badge/badge.module.css
@@ -0,0 +1,4 @@
+.wrapper {
+ padding: 1em;
+ background-color: burlywood;
+}
diff --git a/e2e/nextjs-app/src/app/components/badge/count.e2e.tsx b/e2e/nextjs-app/src/app/components/badge/count.e2e.tsx
new file mode 100644
index 0000000000..a8e35d9ead
--- /dev/null
+++ b/e2e/nextjs-app/src/app/components/badge/count.e2e.tsx
@@ -0,0 +1,15 @@
+"use client";
+
+import { Badge } from "@lifesg/react-design-system/badge";
+
+export default function Story() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/e2e/nextjs-app/src/app/components/badge/variants.e2e.tsx b/e2e/nextjs-app/src/app/components/badge/variants.e2e.tsx
new file mode 100644
index 0000000000..0e1c9c54c5
--- /dev/null
+++ b/e2e/nextjs-app/src/app/components/badge/variants.e2e.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { Badge } from "@lifesg/react-design-system/badge";
+import clsx from "clsx";
+import styles from "./badge.module.css";
+
+export default function Story() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/e2e/tests/components/badge/__screenshots__/chromium/Badge-Anchored-positioning-and-offset--mount.png b/e2e/tests/components/badge/__screenshots__/chromium/Badge-Anchored-positioning-and-offset--mount.png
new file mode 100644
index 0000000000..4db470d960
Binary files /dev/null and b/e2e/tests/components/badge/__screenshots__/chromium/Badge-Anchored-positioning-and-offset--mount.png differ
diff --git a/e2e/tests/components/badge/__screenshots__/chromium/Badge-Count-formatting--mount.png b/e2e/tests/components/badge/__screenshots__/chromium/Badge-Count-formatting--mount.png
new file mode 100644
index 0000000000..f34e1b678a
Binary files /dev/null and b/e2e/tests/components/badge/__screenshots__/chromium/Badge-Count-formatting--mount.png differ
diff --git a/e2e/tests/components/badge/__screenshots__/chromium/Badge-Variants--mount.png b/e2e/tests/components/badge/__screenshots__/chromium/Badge-Variants--mount.png
new file mode 100644
index 0000000000..b3b86d321c
Binary files /dev/null and b/e2e/tests/components/badge/__screenshots__/chromium/Badge-Variants--mount.png differ
diff --git a/e2e/tests/components/badge/__screenshots__/chromium/Badge-Variants-dark-mode--mount.png b/e2e/tests/components/badge/__screenshots__/chromium/Badge-Variants-dark-mode--mount.png
new file mode 100644
index 0000000000..b830da9ce2
Binary files /dev/null and b/e2e/tests/components/badge/__screenshots__/chromium/Badge-Variants-dark-mode--mount.png differ
diff --git a/e2e/tests/components/badge/badge.e2e.spec.ts b/e2e/tests/components/badge/badge.e2e.spec.ts
new file mode 100644
index 0000000000..03737527f5
--- /dev/null
+++ b/e2e/tests/components/badge/badge.e2e.spec.ts
@@ -0,0 +1,77 @@
+import { test as base, expect, Locator, Page } from "@playwright/test";
+import { AbstractStoryPage, compareScreenshot } from "../../utils";
+
+class StoryPage extends AbstractStoryPage {
+ protected readonly component = "badge";
+
+ public readonly locators: {
+ variantsRowDefault: Locator;
+ variantsRowImportant: Locator;
+ badgeCount1000: Locator;
+ badgeCount2090: Locator;
+ anchoredOffset: Locator;
+ anchoredAvatar: Locator;
+ };
+
+ constructor(page: Page) {
+ super(page);
+
+ this.locators = {
+ variantsRowDefault: page.getByTestId("badge-dot-default"),
+ variantsRowImportant: page.getByTestId("badge-dot-important"),
+ badgeCount1000: page.getByTestId("badge-count-1000"),
+ badgeCount2090: page.getByTestId("badge-count-2090"),
+ anchoredOffset: page.getByTestId("badge-anchored-offset"),
+ anchoredAvatar: page.getByTestId("badge-anchored-avatar"),
+ };
+ }
+}
+
+const test = base.extend<{ story: StoryPage }>({
+ story: async ({ page }, mountStory) => {
+ const story = new StoryPage(page);
+ await mountStory(story);
+ },
+});
+
+test.describe("Badge", () => {
+ test.describe(() => {
+ test.beforeEach(async ({ story }) => {
+ await story.init("variants");
+ });
+
+ test("Variants", async ({ story }) => {
+ await compareScreenshot(story, "mount");
+ });
+ });
+
+ test.describe(() => {
+ test.beforeEach(async ({ story }) => {
+ await story.init("variants", { mode: "dark" });
+ });
+
+ test("Variants dark mode", async ({ story }) => {
+ await compareScreenshot(story, "mount");
+ });
+ });
+
+ test.describe(() => {
+ test.beforeEach(async ({ story }) => {
+ await story.init("count");
+ });
+
+ test("Count formatting", async ({ story }) => {
+ await compareScreenshot(story, "mount");
+ });
+ });
+
+ test.describe(() => {
+ test.beforeEach(async ({ story }) => {
+ await story.init("anchored");
+ });
+
+ test("Anchored positioning and offset", async ({ story }) => {
+ await compareScreenshot(story, "mount");
+ });
+ });
+});
diff --git a/src/badge/badge.style.tsx b/src/badge/badge.style.tsx
deleted file mode 100644
index 6f8524e431..0000000000
--- a/src/badge/badge.style.tsx
+++ /dev/null
@@ -1,119 +0,0 @@
-import styled, { css } from "styled-components";
-
-import { V3_Border, V3_Colour, V3_Font, V3_Radius } from "../v3_theme";
-import type { BadgeProps } from "./types";
-
-// =============================================================================
-// STYLE INTERFACE, transient props are denoted with $
-// See more https://styled-components.com/docs/api#transient-props
-// =============================================================================
-interface StyledBadgeProps {
- $variant: BadgeProps["variant"];
- $color: BadgeProps["color"];
-}
-interface BadgeContainerProps {
- $isOverlay?: boolean;
-}
-interface BadgeWrapperProps extends BadgeContainerProps {
- $offset?: [string, string];
-}
-
-// =============================================================================
-// STYLING
-// =============================================================================
-export const BadgeOverlay = styled.div`
- ${(props) =>
- props.$isOverlay &&
- css`
- position: relative;
- width: fit-content;
- height: fit-content;
- `}
-`;
-
-export const BadgeWrapper = styled.div`
- ${(props) =>
- props.$isOverlay &&
- css`
- position: absolute;
- top: 0;
- right: 0;
- transform: translate(50%, -25%)
- ${props.$offset
- ? `translate(${props.$offset[0]},${props.$offset[1]})`
- : ""};
- `}
-`;
-
-const numberBadgeStyles = css`
- min-width: 1.25rem;
- padding: 0.25rem 0.375rem;
- font-size: ${V3_Font.Spec["body-size-xs"]};
- font-weight: ${V3_Font.Spec["weight-bold"]};
- line-height: 1;
-`;
-
-const dotBadgeStyles = css`
- border-radius: 50%;
- width: 0.5rem;
- height: 0.5rem;
-`;
-
-export const StyledBadge = styled.div`
- background-color: ${({ $color }) =>
- $color === "important"
- ? V3_Colour["icon-error"]
- : V3_Colour["bg-primary"]};
- color: ${V3_Colour["text-inverse"]};
- font-weight: ${V3_Font.Spec["weight-bold"]};
- display: flex;
- align-items: center;
- justify-content: center;
-
- width: fit-content;
- ${({ $variant, $color }) => {
- switch ($variant) {
- case "number":
- return css`
- ${numberBadgeStyles}
- border-radius: ${V3_Radius.full};
- `;
-
- case "number-with-border":
- return css`
- ${numberBadgeStyles}
- border-radius: ${V3_Radius.full};
- box-shadow: 0 0 0 ${V3_Border["width-020"]}
- ${V3_Colour["bg"]};
- `;
-
- case "dot":
- return css`
- ${dotBadgeStyles}
- `;
-
- case "dot-with-border":
- return css`
- ${dotBadgeStyles}
- box-shadow: 0 0 0 ${V3_Border["width-020"]} ${V3_Colour[
- "bg"
- ]};
- `;
-
- case "square-number":
- return css`
- ${numberBadgeStyles}
- border-radius: ${V3_Radius.sm};
- padding: 0.25rem 0.4375rem;
- ${$color === "default" &&
- css`
- background-color: ${V3_Colour["bg-primary-subtler"]};
- color: ${V3_Colour["text-primary"]};
- `}
- `;
-
- default:
- return "";
- }
- }};
-`;
diff --git a/src/badge/badge.styles.ts b/src/badge/badge.styles.ts
new file mode 100644
index 0000000000..61cad1c777
--- /dev/null
+++ b/src/badge/badge.styles.ts
@@ -0,0 +1,86 @@
+import { css } from "@linaria/core";
+
+import { Border, Colour, Font, Radius } from "../theme";
+
+export const tokens = {
+ wrapper: {
+ offsetX: "--fds-internal-badge-wrapper-offsetX",
+ offsetY: "--fds-internal-badge-wrapper-offsetY",
+ },
+};
+
+const numberBadgeStyles = `
+ min-width: 1.25rem;
+ padding: 0.25rem 0.375rem;
+ ${Font["body-xs-bold"]}
+ line-height: 1;
+`;
+
+const dotBadgeStyles = `
+ border-radius: 50%;
+ width: 0.5rem;
+ height: 0.5rem;
+`;
+
+export const badgeOverlay = css`
+ position: relative;
+ width: fit-content;
+ height: fit-content;
+`;
+
+export const badgeWrapper = css``;
+
+export const badgeWrapperIsOverlay = css`
+ position: absolute;
+ top: 0;
+ right: 0;
+ transform: translate(50%, -25%)
+ translate(
+ var(${tokens.wrapper.offsetX}, 0px),
+ var(${tokens.wrapper.offsetY}, 0px)
+ );
+`;
+
+export const badge = css`
+ background-color: ${Colour["bg-primary"]};
+ color: ${Colour["text-inverse"]};
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: fit-content;
+`;
+
+export const badgeImportantColor = css`
+ background-color: ${Colour["icon-error"]};
+`;
+
+export const badgeNumber = css`
+ ${numberBadgeStyles}
+ border-radius: ${Radius.full};
+`;
+
+export const badgeNumberWithBorder = css`
+ ${numberBadgeStyles}
+ border-radius: ${Radius.full};
+ box-shadow: 0 0 0 ${Border["width-020"]} ${Colour.bg};
+`;
+
+export const badgeDot = css`
+ ${dotBadgeStyles}
+`;
+
+export const badgeDotWithBorder = css`
+ ${dotBadgeStyles}
+ box-shadow: 0 0 0 ${Border["width-020"]} ${Colour.bg};
+`;
+
+export const badgeSquareNumber = css`
+ ${numberBadgeStyles}
+ border-radius: ${Radius.sm};
+ padding: 0.25rem 0.4375rem;
+`;
+
+export const badgeSquareNumberDefaultColor = css`
+ background-color: ${Colour["bg-primary-subtler"]};
+ color: ${Colour["text-primary"]};
+`;
diff --git a/src/badge/badge.tsx b/src/badge/badge.tsx
index 9867c9a4af..f71641267b 100644
--- a/src/badge/badge.tsx
+++ b/src/badge/badge.tsx
@@ -1,6 +1,21 @@
-import { BadgeOverlay, BadgeWrapper, StyledBadge } from "./badge.style";
+import clsx from "clsx";
+import { useRef } from "react";
+
+import { useApplyStyle } from "../theme";
+import * as styles from "./badge.styles";
import type { BadgeProps, BadgeVariant } from "./types";
+function getDisplayCount(count: number) {
+ if (count <= 999) return count.toString();
+ if (count === 1000) return "1K";
+ return "1K+";
+}
+const variantsToShowCount: Set = new Set([
+ "number",
+ "number-with-border",
+ "square-number",
+]);
+
export const Badge = ({
children,
count = 0,
@@ -8,47 +23,60 @@ export const Badge = ({
color = "default",
badgeOffset,
"data-testid": testId = "badge",
+ className,
...otherProps
}: BadgeProps) => {
// =============================================================================
// CONST
// =============================================================================
const displayCount = getDisplayCount(count);
- const variantsToShowCount: BadgeVariant[] = [
- "number",
- "number-with-border",
- "square-number",
- ];
- const shouldShowCount = variantsToShowCount.includes(variant);
+ const shouldShowCount = variantsToShowCount.has(variant);
// =============================================================================
- // HELPER FUNCTIONS
+ // REFS
// =============================================================================
- function getDisplayCount(count: number) {
- if (count <= 999) return count.toString();
- if (count === 1000) return "1K";
- return "1K+";
- }
+ const wrapperRef = useRef(null);
+
+ useApplyStyle(wrapperRef, {
+ [styles.tokens.wrapper.offsetX]: badgeOffset?.[0],
+ [styles.tokens.wrapper.offsetY]: badgeOffset?.[1],
+ });
// =============================================================================
// RENDER FUNCTIONS
// =============================================================================
return (
-
-
+
-
{shouldShowCount ? displayCount : null}
-
-
+
+
{children}
-
+
);
};
diff --git a/src/badge/types.ts b/src/badge/types.ts
index 5975eb2bf3..35278dfd54 100644
--- a/src/badge/types.ts
+++ b/src/badge/types.ts
@@ -10,8 +10,8 @@ export type BadgeColor = "default" | "important";
export interface BadgeProps extends React.HTMLAttributes {
badgeOffset?: [string, string] | undefined;
children?: JSX.Element | undefined;
- count?: number | undefined;
- variant?: BadgeVariant | undefined;
color?: BadgeColor | undefined;
+ count?: number | undefined;
"data-testid"?: string | undefined;
+ variant?: BadgeVariant | undefined;
}
diff --git a/tests/badge/badge.spec.tsx b/tests/badge/badge.spec.tsx
new file mode 100644
index 0000000000..298969c8db
--- /dev/null
+++ b/tests/badge/badge.spec.tsx
@@ -0,0 +1,44 @@
+import { render, screen } from "@testing-library/react";
+import { Badge } from "src/badge";
+
+describe("Badge", () => {
+ describe("count display", () => {
+ it("should display the count as-is when 999 or below", () => {
+ render();
+
+ expect(screen.getByText("999")).toBeInTheDocument();
+ });
+
+ it("should display '1K' when count is exactly 1000", () => {
+ render();
+
+ expect(screen.getByText("1K")).toBeInTheDocument();
+ });
+
+ it("should display '1K+' when count exceeds 1000", () => {
+ render();
+
+ expect(screen.getByText("1K+")).toBeInTheDocument();
+ });
+ });
+
+ describe("variants", () => {
+ it.each(["number", "number-with-border", "square-number"] as const)(
+ "should display the count for variant '%s'",
+ (variant) => {
+ render();
+
+ expect(screen.getByText("5")).toBeInTheDocument();
+ }
+ );
+
+ it.each(["dot", "dot-with-border"] as const)(
+ "should not display the count for variant '%s'",
+ (variant) => {
+ render();
+
+ expect(screen.queryByText("5")).not.toBeInTheDocument();
+ }
+ );
+ });
+});