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 ( +
+ +
+ ); +} 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(); + } + ); + }); +});