diff --git a/e2e/nextjs-app/src/app/components/notification-banner/all-variants.e2e.tsx b/e2e/nextjs-app/src/app/components/notification-banner/all-variants.e2e.tsx new file mode 100644 index 0000000000..ac92831c3c --- /dev/null +++ b/e2e/nextjs-app/src/app/components/notification-banner/all-variants.e2e.tsx @@ -0,0 +1,27 @@ +"use client"; +import { PlaceholderIcon } from "@lifesg/react-icons/placeholder"; +import { NotificationBanner } from "@lifesg/react-design-system/notification-banner"; + +export default function Story() { + return ( +
+ + This is a default notification banner + + + } + > + This is a notification banner with an icon + + + + This is a non-dismissible notification banner + +
+ ); +} diff --git a/e2e/nextjs-app/src/app/components/notification-banner/custom-content.e2e.tsx b/e2e/nextjs-app/src/app/components/notification-banner/custom-content.e2e.tsx new file mode 100644 index 0000000000..2f465fb0c7 --- /dev/null +++ b/e2e/nextjs-app/src/app/components/notification-banner/custom-content.e2e.tsx @@ -0,0 +1,20 @@ +"use client"; +import { GearFillIcon } from "@lifesg/react-icons/gear-fill"; +import { NotificationBanner } from "@lifesg/react-design-system/notification-banner"; +import styles from "./notification-banner.module.css"; + +export default function Story() { + return ( +
+ +
+ + Custom content +
+
+
+ ); +} diff --git a/e2e/nextjs-app/src/app/components/notification-banner/long-content.e2e.tsx b/e2e/nextjs-app/src/app/components/notification-banner/long-content.e2e.tsx new file mode 100644 index 0000000000..5b164854ea --- /dev/null +++ b/e2e/nextjs-app/src/app/components/notification-banner/long-content.e2e.tsx @@ -0,0 +1,43 @@ +"use client"; +import { NotificationBanner } from "@lifesg/react-design-system/notification-banner"; +import { ArrowRightIcon } from "@lifesg/react-icons/arrow-right"; + +export default function Story() { + return ( +
+ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor + in reprehenderit in voluptate velit esse cillum dolore eu fugiat + nulla pariatur. Excepteur sint occaecat cupidatat non proident, + sunt in culpa qui officia deserunt mollit anim id est laborum. + + + + View more + + + ), + }} + > + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor + in reprehenderit in voluptate velit esse cillum dolore eu fugiat + nulla pariatur. Excepteur sint occaecat cupidatat non proident, + sunt in culpa qui officia deserunt mollit anim id est laborum. + +
+ ); +} diff --git a/e2e/nextjs-app/src/app/components/notification-banner/non-sticky.e2e.tsx b/e2e/nextjs-app/src/app/components/notification-banner/non-sticky.e2e.tsx new file mode 100644 index 0000000000..b007cbc558 --- /dev/null +++ b/e2e/nextjs-app/src/app/components/notification-banner/non-sticky.e2e.tsx @@ -0,0 +1,25 @@ +"use client"; +import { NotificationBanner } from "@lifesg/react-design-system/notification-banner"; +import styles from "./notification-banner.module.css"; + +export default function Story() { + return ( +
+ + This is a non-sticky notification banner. + +
+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed + do eiusmod tempor incididunt ut labore et dolore magna + aliqua. Ut enim ad minim veniam, quis nostrud exercitation + ullamco laboris nisi ut aliquip ex ea commodo consequat. + Duis aute irure dolor in reprehenderit in voluptate velit + esse cillum dolore eu fugiat nulla pariatur. Excepteur sint + occaecat cupidatat non proident, sunt in culpa qui officia + deserunt mollit anim id est laborum. +

+
+
+ ); +} diff --git a/e2e/nextjs-app/src/app/components/notification-banner/notification-banner.module.css b/e2e/nextjs-app/src/app/components/notification-banner/notification-banner.module.css new file mode 100644 index 0000000000..61ac268d37 --- /dev/null +++ b/e2e/nextjs-app/src/app/components/notification-banner/notification-banner.module.css @@ -0,0 +1,15 @@ +.custom { + display: flex; + gap: 1rem; + align-items: center; + width: 100%; + padding: 1rem; + color: black; + background: yellow; +} + +.sticky { + height: 200vh; + background-color: teal; + border: solid 10px crimson; +} diff --git a/e2e/nextjs-app/src/app/components/notification-banner/sticky.e2e.tsx b/e2e/nextjs-app/src/app/components/notification-banner/sticky.e2e.tsx new file mode 100644 index 0000000000..01cd0ad090 --- /dev/null +++ b/e2e/nextjs-app/src/app/components/notification-banner/sticky.e2e.tsx @@ -0,0 +1,25 @@ +"use client"; +import { NotificationBanner } from "@lifesg/react-design-system/notification-banner"; +import styles from "./notification-banner.module.css"; + +export default function Story() { + return ( +
+ + This is a sticky notification banner. + +
+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed + do eiusmod tempor incididunt ut labore et dolore magna + aliqua. Ut enim ad minim veniam, quis nostrud exercitation + ullamco laboris nisi ut aliquip ex ea commodo consequat. + Duis aute irure dolor in reprehenderit in voluptate velit + esse cillum dolore eu fugiat nulla pariatur. Excepteur sint + occaecat cupidatat non proident, sunt in culpa qui officia + deserunt mollit anim id est laborum. +

+
+
+ ); +} diff --git a/e2e/nextjs-app/src/app/components/notification-banner/text-styling.e2e.tsx b/e2e/nextjs-app/src/app/components/notification-banner/text-styling.e2e.tsx new file mode 100644 index 0000000000..a2c6dc4364 --- /dev/null +++ b/e2e/nextjs-app/src/app/components/notification-banner/text-styling.e2e.tsx @@ -0,0 +1,19 @@ +"use client"; +import { NotificationBanner } from "@lifesg/react-design-system/notification-banner"; + +export default function Story() { + return ( +
+ + This banner has bold text, italic text + , anchor tag for normal{" "} + link, and Link component + for{" "} + + external link + + . + +
+ ); +} diff --git a/e2e/tests/components/notification-banner/__screenshots__/chromium/NotificationBanner-All-variants--mount.png b/e2e/tests/components/notification-banner/__screenshots__/chromium/NotificationBanner-All-variants--mount.png new file mode 100644 index 0000000000..50a2cd76f9 Binary files /dev/null and b/e2e/tests/components/notification-banner/__screenshots__/chromium/NotificationBanner-All-variants--mount.png differ diff --git a/e2e/tests/components/notification-banner/__screenshots__/chromium/NotificationBanner-All-variants-dark-mode---mount.png b/e2e/tests/components/notification-banner/__screenshots__/chromium/NotificationBanner-All-variants-dark-mode---mount.png new file mode 100644 index 0000000000..0986b17d43 Binary files /dev/null and b/e2e/tests/components/notification-banner/__screenshots__/chromium/NotificationBanner-All-variants-dark-mode---mount.png differ diff --git a/e2e/tests/components/notification-banner/__screenshots__/chromium/NotificationBanner-Custom-content--mount.png b/e2e/tests/components/notification-banner/__screenshots__/chromium/NotificationBanner-Custom-content--mount.png new file mode 100644 index 0000000000..a7639adc84 Binary files /dev/null and b/e2e/tests/components/notification-banner/__screenshots__/chromium/NotificationBanner-Custom-content--mount.png differ diff --git a/e2e/tests/components/notification-banner/__screenshots__/chromium/NotificationBanner-Long-content--mount.png b/e2e/tests/components/notification-banner/__screenshots__/chromium/NotificationBanner-Long-content--mount.png new file mode 100644 index 0000000000..8ca43de4d1 Binary files /dev/null and b/e2e/tests/components/notification-banner/__screenshots__/chromium/NotificationBanner-Long-content--mount.png differ diff --git a/e2e/tests/components/notification-banner/__screenshots__/chromium/NotificationBanner-Mobile--mount.png b/e2e/tests/components/notification-banner/__screenshots__/chromium/NotificationBanner-Mobile--mount.png new file mode 100644 index 0000000000..89343ff223 Binary files /dev/null and b/e2e/tests/components/notification-banner/__screenshots__/chromium/NotificationBanner-Mobile--mount.png differ diff --git a/e2e/tests/components/notification-banner/__screenshots__/chromium/NotificationBanner-Sticky-banner-behaviour--after-scroll.png b/e2e/tests/components/notification-banner/__screenshots__/chromium/NotificationBanner-Sticky-banner-behaviour--after-scroll.png new file mode 100644 index 0000000000..7bb71f41ca Binary files /dev/null and b/e2e/tests/components/notification-banner/__screenshots__/chromium/NotificationBanner-Sticky-banner-behaviour--after-scroll.png differ diff --git a/e2e/tests/components/notification-banner/__screenshots__/chromium/NotificationBanner-Sticky-banner-behaviour--mount.png b/e2e/tests/components/notification-banner/__screenshots__/chromium/NotificationBanner-Sticky-banner-behaviour--mount.png new file mode 100644 index 0000000000..64368e3133 Binary files /dev/null and b/e2e/tests/components/notification-banner/__screenshots__/chromium/NotificationBanner-Sticky-banner-behaviour--mount.png differ diff --git a/e2e/tests/components/notification-banner/__screenshots__/chromium/NotificationBanner-Text-styling--mount.png b/e2e/tests/components/notification-banner/__screenshots__/chromium/NotificationBanner-Text-styling--mount.png new file mode 100644 index 0000000000..2ff04eff40 Binary files /dev/null and b/e2e/tests/components/notification-banner/__screenshots__/chromium/NotificationBanner-Text-styling--mount.png differ diff --git a/e2e/tests/components/notification-banner/notification-banner.e2e.spec.ts b/e2e/tests/components/notification-banner/notification-banner.e2e.spec.ts new file mode 100644 index 0000000000..d38a0d3b34 --- /dev/null +++ b/e2e/tests/components/notification-banner/notification-banner.e2e.spec.ts @@ -0,0 +1,136 @@ +import { test as base, expect, Locator, Page } from "@playwright/test"; +import { AbstractStoryPage, compareScreenshot } from "../../utils"; + +class StoryPage extends AbstractStoryPage { + protected readonly component = "notification-banner"; + + public readonly locators: { + bannerNonSticky: Locator; + bannerSticky: Locator; + }; + + constructor(page: Page) { + super(page); + + this.locators = { + bannerNonSticky: page.getByTestId("banner-non-sticky"), + bannerSticky: page.getByTestId("banner-sticky"), + }; + } +} + +const test = base.extend<{ story: StoryPage }>({ + story: async ({ page }, use) => { + const story = new StoryPage(page); + await use(story); + }, +}); + +test.describe("NotificationBanner", () => { + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("all-variants"); + }); + + test("All variants", async ({ story }) => { + await compareScreenshot(story, "mount"); + }); + }); + + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("all-variants", { mode: "dark" }); + }); + + test("All variants (dark mode)", async ({ story }) => { + await compareScreenshot(story, "mount"); + }); + }); + + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("all-variants", { size: "mobile" }); + }); + + test("Mobile", async ({ story }) => { + await compareScreenshot(story, "mount"); + }); + }); + + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("long-content"); + }); + + test("Long content", async ({ story }) => { + await compareScreenshot(story, "mount"); + }); + }); + + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("custom-content"); + }); + + test("Custom content", async ({ story }) => { + await compareScreenshot(story, "mount"); + }); + }); + + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("text-styling"); + }); + + test("Text styling", async ({ story }) => { + await compareScreenshot(story, "mount"); + }); + }); + + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("non-sticky"); + }); + + test("Non-sticky banner scrolls out of view", async ({ story }) => { + await test.step("Verify non-sticky banner is visible initially", async () => { + await expect(story.locators.bannerNonSticky).toBeInViewport(); + }); + + await test.step("Scroll down the page", async () => { + await story.page.mouse.wheel(0, 100); + await story.page.waitForTimeout(300); + }); + + await test.step("Verify non-sticky banner scrolled out of view", async () => { + await expect( + story.locators.bannerNonSticky + ).not.toBeInViewport(); + }); + }); + }); + + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("sticky"); + }); + + test("Sticky banner behaviour", async ({ story }) => { + await test.step("Verify sticky banner is visible initially", async () => { + await expect(story.locators.bannerSticky).toBeInViewport(); + await compareScreenshot(story, "mount", { fullscreen: true }); + }); + + await test.step("Scroll down the page", async () => { + await story.scrollToEnd({ scrollTarget: story.layout }); + }); + + await test.step("Verify sticky banner remains in view", async () => { + await expect(story.locators.bannerSticky).toBeInViewport(); + await compareScreenshot(story, "after-scroll", { + fullscreen: true, + }); + }); + }); + }); +}); diff --git a/src/notification-banner/notification-banner.styles.ts b/src/notification-banner/notification-banner.styles.ts new file mode 100644 index 0000000000..6cd31f330a --- /dev/null +++ b/src/notification-banner/notification-banner.styles.ts @@ -0,0 +1,145 @@ +import { css } from "@linaria/core"; + +import { Colour, Font, Motion, Radius, Spacing } from "../theme"; + +export const tokens = { + contentText: { + maxCollapsedHeight: + "--fds-internal-notificationBanner-contentText-maxCollapsedHeight", + }, +}; + +const linkStyle = ` + color: ${Colour["hyperlink-inverse"]}; + + svg { + color: ${Colour["icon-primary-inverse"]}; + } + + &:hover, + &:active, + &:visited, + &:focus { + color: ${Colour["hyperlink-inverse"]}; + svg { + color: ${Colour["icon-primary-inverse"]}; + } + } +`; + +// ============================================================================= +// STYLING +// ============================================================================= +export const wrapper = css` + position: relative; + left: 0; + top: 0; + width: 100%; + transition: all ${Motion["duration-800"]} ${Motion["ease-default"]}; + background: ${Colour["bg-inverse-subtle"]}; + border-radius: ${Radius["none"]}; + z-index: 25; + cursor: default; +`; + +export const wrapperSticky = css` + position: sticky; +`; + +export const wrapperClickable = css` + cursor: pointer; +`; + +export const contentContainer = css` + flex: 1; + align-items: flex-start; + padding: ${Spacing["spacing-24"]} 0; +`; + +export const content = css` + display: flex; + flex: 1; + align-items: flex-start; + + ${Font["body-baseline-regular"]} + color: ${Colour["text-inverse"]}; + + p { + display: inline-block; + } + + strong { + ${Font["body-baseline-semibold"]} + color: ${Colour["text-inverse"]}; + } + + a { + ${Font["body-baseline-regular"]} + ${linkStyle} + } +`; + +export const contentWrapper = css` + display: flex; + flex-direction: column; + flex: 1; +`; + +export const contentText = css` + ${tokens.contentText.maxCollapsedHeight}: initial; + flex: 1; + word-wrap: break-word; + overflow-wrap: break-word; +`; + +export const contentTextCollapsed = css` + max-height: var(${tokens.contentText.maxCollapsedHeight}); + overflow: hidden; + -webkit-mask-image: linear-gradient(to bottom, black 50%, transparent 100%); + mask-image: linear-gradient(to bottom, black 50%, transparent 100%); +`; + +export const contentLink = css` + position: relative; + ${linkStyle} +`; + +export const dismissButton = css` + margin-right: calc(${Spacing["spacing-24"]} * -1); + padding-left: ${Spacing["spacing-16"]}; + height: max-content; + + svg { + height: 1.5rem; + width: 1.5rem; + color: ${Colour["icon-inverse"]}; + } +`; + +export const actionButton = css` + display: flex; + align-items: center; + gap: ${Spacing["spacing-4"]}; + align-self: flex-start; + margin-top: ${Spacing["spacing-8"]}; + + border: none; + background: transparent; + color: ${Colour["hyperlink-inverse"]}; + ${Font["body-md-semibold"]}; + + cursor: pointer; +`; + +export const iconContainer = css` + height: 1.5rem; + width: 1.5rem; + margin: ${Spacing["spacing-24"]} ${Spacing["spacing-24"]} 0 0; + flex-shrink: 0; + + svg { + height: 100%; + width: 100%; + color: ${Colour["hyperlink-inverse"]}; + } +`; diff --git a/src/notification-banner/notification-banner.styles.tsx b/src/notification-banner/notification-banner.styles.tsx deleted file mode 100644 index 6133335c25..0000000000 --- a/src/notification-banner/notification-banner.styles.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import styled, { css } from "styled-components"; - -import { Layout } from "../layout"; -import { ClickableIcon } from "../shared/clickable-icon"; -import { Typography } from "../typography"; -import { - V3_Colour, - V3_Font, - V3_Motion, - V3_Radius, - V3_Spacing, -} from "../v3_theme"; - -// ============================================================================= -// STYLE INTERFACES, transient props are denoted with $ -// See more https://styled-components.com/docs/api#transient-props -// ============================================================================= -interface WrapperStyleProps { - $sticky: boolean; - $clickable: boolean; -} - -interface ContentStyleProps { - $maxCollapsedHeight?: number; -} - -// ============================================================================= -// STYLING -// ============================================================================= -const commonLinkStyle = css` - color: ${V3_Colour["hyperlink-inverse"]}; - - svg { - color: ${V3_Colour["icon-primary-inverse"]}; - } - - &:hover, - &:active, - &:visited, - &:focus { - color: ${V3_Colour["hyperlink-inverse"]}; - svg { - color: ${V3_Colour["icon-primary-inverse"]}; - } - } -`; - -export const Wrapper = styled.div` - position: ${(props) => (props.$sticky ? "sticky" : "relative")}; - left: 0; - top: 0; - width: 100%; - transition: all ${V3_Motion["duration-800"]} ${V3_Motion["ease-default"]}; - background: ${V3_Colour["bg-inverse-subtle"]}; - border-radius: ${V3_Radius["none"]}; - z-index: 25; - cursor: ${(props) => (props.$clickable ? "pointer" : "default")}; -`; - -export const Container = styled(Layout.Content)``; - -export const ContentContainer = styled.div` - flex: 1; - align-items: flex-start; - padding: ${V3_Spacing["spacing-24"]} 0; -`; - -export const Content = styled.div` - display: flex; - flex: 1; - align-items: flex-start; - - ${V3_Font["body-baseline-regular"]} - color: ${V3_Colour["text-inverse"]}; - - p { - display: inline-block; - } - - strong { - ${V3_Font["body-baseline-semibold"]} - color: ${V3_Colour["text-inverse"]}; - } - - a { - ${V3_Font["body-baseline-regular"]} - ${commonLinkStyle} - } -`; - -export const ContentWrapper = styled.div` - display: flex; - flex-direction: column; - flex: 1; -`; - -export const ContentText = styled.div<{ $maxCollapsedHeight?: number }>` - flex: 1; - word-wrap: break-word; - overflow-wrap: break-word; - ${(props) => { - const gradient = - "linear-gradient(to bottom, black 50%, transparent 100%)"; - if (props.$maxCollapsedHeight) { - return css` - max-height: ${props.$maxCollapsedHeight}px; - overflow: hidden; - -webkit-mask-image: ${gradient}; - mask-image: ${gradient}; - `; - } - }} -`; - -export const ContentLink = styled(Typography.LinkBL)` - position: relative; - - ${commonLinkStyle} -`; - -export const StyledIconButton = styled(ClickableIcon)` - margin-right: -${V3_Spacing["spacing-24"]}; - padding-left: ${V3_Spacing["spacing-16"]}; - height: max-content; - svg { - height: 1.5rem; - width: 1.5rem; - color: ${V3_Colour["icon-inverse"]}; - } -`; - -export const ActionButton = styled.button` - display: flex; - align-items: center; - gap: ${V3_Spacing["spacing-4"]}; - align-self: flex-start; - margin-top: ${V3_Spacing["spacing-8"]}; - - border: none; - background: transparent; - color: ${V3_Colour["hyperlink-inverse"]}; - ${V3_Font["body-md-semibold"]}; - - cursor: pointer; -`; - -export const AccessibleBannerButton = styled.button` - clip: rect(0 0 0 0); - clip-path: inset(50%); - height: 1px; - overflow: hidden; - position: absolute; - white-space: nowrap; - width: 1px; -`; - -export const IconContainer = styled.div` - height: 1.5rem; - width: 1.5rem; - margin: ${V3_Spacing["spacing-24"]} ${V3_Spacing["spacing-24"]} 0 0; - flex-shrink: 0; - - svg { - height: 100%; - width: 100%; - color: ${V3_Colour["hyperlink-inverse"]}; - } -`; diff --git a/src/notification-banner/notification-banner.tsx b/src/notification-banner/notification-banner.tsx index a596722db8..831fa18ae7 100644 --- a/src/notification-banner/notification-banner.tsx +++ b/src/notification-banner/notification-banner.tsx @@ -1,21 +1,15 @@ import { CrossIcon } from "@lifesg/react-icons"; +import clsx from "clsx"; import type { NamedExoticComponent } from "react"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { useResizeDetector } from "react-resize-detector"; -import { - AccessibleBannerButton, - ActionButton, - Container, - Content, - ContentContainer, - ContentLink as NBLink, - ContentText, - ContentWrapper, - IconContainer, - StyledIconButton, - Wrapper, -} from "./notification-banner.styles"; +import { Layout } from "../layout"; +import { VisuallyHidden } from "../shared/accessibility"; +import { ClickableIcon } from "../shared/clickable-icon"; +import { formatUnitValue, useApplyStyle } from "../theme"; +import { Typography } from "../typography"; +import * as styles from "./notification-banner.styles"; import type { NotificationBannerProps, NotificationBannerWithForwardedRefProps, @@ -33,6 +27,7 @@ export const NBComponent = ({ onClick, actionButton, icon, + className, ...otherProps }: NotificationBannerWithForwardedRefProps) => { // ============================================================================= @@ -42,6 +37,16 @@ export const NBComponent = ({ const [isVisible, setVisible] = useState(visible); const { height: contentHeight = 0, ref: contentRef } = useResizeDetector(); + const contentTextRef = useRef(null); + + const isCollapsed = + maxCollapsedHeight && contentHeight > maxCollapsedHeight; + + useApplyStyle(contentTextRef, { + [styles.tokens.contentText.maxCollapsedHeight]: isCollapsed + ? formatUnitValue(maxCollapsedHeight, "px") + : undefined, + }); // ============================================================================= // EFFECTS @@ -77,7 +82,8 @@ export const NBComponent = ({ if (!isVisible) return null; const renderDismissButton = () => ( - - + ); const renderActionButton = () => { if (!actionButton) return null; return ( - {actionButton.children} - + ); }; const renderContent = () => ( - - - maxCollapsedHeight - ? maxCollapsedHeight - : undefined - } +
+
+
{children}
- +
{renderActionButton()} - - +
+
); const renderAccessibleBannerButton = () => ( - + +