From b70b8ab2c5d6bd5261766c04b25a596a574734e1 Mon Sep 17 00:00:00 2001 From: avivkeller Date: Tue, 14 Jul 2026 12:12:17 -0700 Subject: [PATCH 1/5] feat(banner): add `x` for closing --- packages/ui-components/package.json | 2 +- .../src/Common/Banner/index.module.css | 15 ++++++- .../ui-components/src/Common/Banner/index.tsx | 44 +++++++++++++++---- 3 files changed, 50 insertions(+), 11 deletions(-) diff --git a/packages/ui-components/package.json b/packages/ui-components/package.json index b05e7e5119a64..76a3b62338c4a 100644 --- a/packages/ui-components/package.json +++ b/packages/ui-components/package.json @@ -1,6 +1,6 @@ { "name": "@node-core/ui-components", - "version": "1.7.1", + "version": "1.7.2", "type": "module", "exports": { "./*": { diff --git a/packages/ui-components/src/Common/Banner/index.module.css b/packages/ui-components/src/Common/Banner/index.module.css index d55d5359cf8c8..0f7f382e972d5 100644 --- a/packages/ui-components/src/Common/Banner/index.module.css +++ b/packages/ui-components/src/Common/Banner/index.module.css @@ -1,7 +1,8 @@ @reference "../../styles/index.css"; .banner { - @apply flex + @apply relative + flex w-full flex-row items-center @@ -40,3 +41,15 @@ .warning { @apply bg-warning-600; } + +.closeButton { + @apply absolute + right-4 + text-lg; + + span { + @apply text-white/60 + transition-colors + hover:text-white; + } +} diff --git a/packages/ui-components/src/Common/Banner/index.tsx b/packages/ui-components/src/Common/Banner/index.tsx index b28ceade24816..1d28e84f807ab 100644 --- a/packages/ui-components/src/Common/Banner/index.tsx +++ b/packages/ui-components/src/Common/Banner/index.tsx @@ -1,22 +1,48 @@ -import type { FC, HTMLAttributes, PropsWithChildren } from 'react'; +'use client'; + +import classNames from 'classnames'; +import { + useState, + type FC, + type HTMLAttributes, + type PropsWithChildren, +} from 'react'; import styles from './index.module.css'; export type BannerProps = { type?: 'default' | 'warning' | 'error'; + onClose?: (e: React.MouseEvent) => void; } & HTMLAttributes; const Banner: FC> = ({ type = 'default', + onClose, children, ...props -}) => ( -
- {children} -
-); +}) => { + const [dismissed, setDismissed] = useState(false); + if (dismissed) {return null;} + + return ( +
+ {children} + +
+ ); +}; export default Banner; From bd140ea4ccbef9793d42a06f1759ca84c0481c61 Mon Sep 17 00:00:00 2001 From: avivkeller Date: Tue, 14 Jul 2026 12:16:34 -0700 Subject: [PATCH 2/5] fixup! --- packages/ui-components/src/Common/Banner/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ui-components/src/Common/Banner/index.tsx b/packages/ui-components/src/Common/Banner/index.tsx index 1d28e84f807ab..310e871ac1f81 100644 --- a/packages/ui-components/src/Common/Banner/index.tsx +++ b/packages/ui-components/src/Common/Banner/index.tsx @@ -22,7 +22,9 @@ const Banner: FC> = ({ ...props }) => { const [dismissed, setDismissed] = useState(false); - if (dismissed) {return null;} + if (dismissed) { + return null; + } return (
Date: Tue, 14 Jul 2026 12:54:39 -0700 Subject: [PATCH 3/5] fixup! --- apps/site/components/withBanner.tsx | 23 +++++++++- .../src/Common/Banner/index.stories.tsx | 8 ++++ .../ui-components/src/Common/Banner/index.tsx | 45 ++++++++----------- 3 files changed, 48 insertions(+), 28 deletions(-) diff --git a/apps/site/components/withBanner.tsx b/apps/site/components/withBanner.tsx index 1023051e1d0d5..077eda597e07e 100644 --- a/apps/site/components/withBanner.tsx +++ b/apps/site/components/withBanner.tsx @@ -1,6 +1,9 @@ +'use client'; + import { ArrowUpRightIcon } from '@heroicons/react/24/outline'; import Banner from '@node-core/ui-components/Common/Banner'; import { useTranslations } from 'next-intl'; +import { useEffect, useState } from 'react'; import Link from '#site/components/Link'; import { siteConfig } from '#site/next.json.mjs'; @@ -8,17 +11,35 @@ import { dateIsBetween } from '#site/util/date'; import type { FC } from 'react'; +const STORAGE_KEY = 'banner-dismissal'; + const WithBanner: FC<{ section: string }> = ({ section }) => { const banner = siteConfig.websiteBanners[section]; const t = useTranslations(); - if (banner && dateIsBetween(banner.startDate, banner.endDate)) { + const [dismissed, setDismissed] = useState(false); + + // localStorage is only available on the client, so the banner is always + // rendered on the server and hidden after hydration if previously dismissed + useEffect(() => { + if (banner) { + setDismissed(localStorage.getItem(STORAGE_KEY) === banner.text); + } + }, [banner]); + + if (banner && !dismissed && dateIsBetween(banner.startDate, banner.endDate)) { const bannerType = banner.type || 'default'; + const onClose = () => { + localStorage.setItem(STORAGE_KEY, banner.text); + setDismissed(true); + }; + return ( {banner.link ? ( {banner.text} diff --git a/packages/ui-components/src/Common/Banner/index.stories.tsx b/packages/ui-components/src/Common/Banner/index.stories.tsx index 60fc4c4d2e336..1ca61a6312971 100644 --- a/packages/ui-components/src/Common/Banner/index.stories.tsx +++ b/packages/ui-components/src/Common/Banner/index.stories.tsx @@ -26,4 +26,12 @@ export const Warning: Story = { }, }; +export const Dismissible: Story = { + args: { + children: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + type: 'default', + onClose: () => {}, + }, +}; + export default { component: Banner } as Meta; diff --git a/packages/ui-components/src/Common/Banner/index.tsx b/packages/ui-components/src/Common/Banner/index.tsx index 310e871ac1f81..6d0fd5a165dfe 100644 --- a/packages/ui-components/src/Common/Banner/index.tsx +++ b/packages/ui-components/src/Common/Banner/index.tsx @@ -1,18 +1,17 @@ -'use client'; - import classNames from 'classnames'; -import { - useState, - type FC, - type HTMLAttributes, - type PropsWithChildren, + +import type { + FC, + HTMLAttributes, + MouseEventHandler, + PropsWithChildren, } from 'react'; import styles from './index.module.css'; export type BannerProps = { type?: 'default' | 'warning' | 'error'; - onClose?: (e: React.MouseEvent) => void; + onClose?: MouseEventHandler; } & HTMLAttributes; const Banner: FC> = ({ @@ -20,31 +19,23 @@ const Banner: FC> = ({ onClose, children, ...props -}) => { - const [dismissed, setDismissed] = useState(false); - if (dismissed) { - return null; - } - - return ( -
- {children} +}) => ( +
+ {children} + {onClose && ( -
- ); -}; + )} +
+); export default Banner; From dc9e701cc7f838d04d859ed45428eaf3c4c2ddac Mon Sep 17 00:00:00 2001 From: "Kamat, Trivikram" <16024985+trivikr@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:53:00 -0700 Subject: [PATCH 4/5] fix(banner): prevent dismissal flicker before hydration --- apps/site/app/[locale]/layout.tsx | 17 +++++++++++++++++ apps/site/components/withBanner.tsx | 14 ++++++++------ apps/site/styles/index.css | 4 ++++ apps/site/util/banner.ts | 1 + 4 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 apps/site/util/banner.ts diff --git a/apps/site/app/[locale]/layout.tsx b/apps/site/app/[locale]/layout.tsx index 5e1e440c5b974..42eae65689ca8 100644 --- a/apps/site/app/[locale]/layout.tsx +++ b/apps/site/app/[locale]/layout.tsx @@ -1,3 +1,5 @@ +/* eslint-disable @eslint-react/dom-no-dangerously-set-innerhtml -- The script is static; banner data is escaped in data attributes. */ + import { availableLocales, defaultLocale } from '@node-core/website-i18n'; import { Analytics } from '@vercel/analytics/react'; import { SpeedInsights } from '@vercel/speed-insights/next'; @@ -7,7 +9,9 @@ import { NextIntlClientProvider } from 'next-intl'; import BaseLayout from '#site/layouts/Base'; import { VERCEL_ENV } from '#site/next.constants.mjs'; import { IBM_PLEX_MONO, OPEN_SANS } from '#site/next.fonts'; +import { siteConfig } from '#site/next.json.mjs'; import { ThemeProvider } from '#site/providers/themeProvider'; +import { BANNER_DISMISSAL_STORAGE_KEY } from '#site/util/banner'; import type { FC, PropsWithChildren } from 'react'; @@ -15,6 +19,10 @@ import '#site/styles/index.css'; const fontClasses = classNames(IBM_PLEX_MONO.variable, OPEN_SANS.variable); +const banner = siteConfig.websiteBanners.index; + +const bannerPreHydrationScript = `try{var s=document.currentScript;if(s&&localStorage.getItem(s.dataset.storageKey)===s.dataset.banner){document.documentElement.dataset.bannerDismissed='true'}}catch{}`; + type RootLayoutProps = PropsWithChildren<{ params: Promise<{ locale: string }>; }>; @@ -32,6 +40,15 @@ const RootLayout: FC = async ({ children, params }) => { lang={hrefLang} suppressHydrationWarning > + + {banner && ( + )}