diff --git a/.changeset/smooth-doodles-follow.md b/.changeset/smooth-doodles-follow.md new file mode 100644 index 00000000000..657f5b75ec1 --- /dev/null +++ b/.changeset/smooth-doodles-follow.md @@ -0,0 +1,5 @@ +--- +'@kaizen/components': patch +--- + +Add `sticky` prop to TitleBlock. When enabled, the top strip sticks to the top of its scrollable container as the content scrolls, offset below the app-chrome nav via the `--app-chrome-sticky-offset` and `--titleblock-sticky-offset` CSS variables. diff --git a/packages/components/src/TitleBlock/TitleBlock.module.scss b/packages/components/src/TitleBlock/TitleBlock.module.scss index 33122251ee9..0f1b5cd943b 100644 --- a/packages/components/src/TitleBlock/TitleBlock.module.scss +++ b/packages/components/src/TitleBlock/TitleBlock.module.scss @@ -61,6 +61,35 @@ display: flex; width: 100%; justify-content: center; + position: relative; + } + + .sticky { + display: contents; + + .titleRow { + z-index: 5; + position: sticky; + top: calc(var(--app-chrome-sticky-offset, 72px) + var(--titleblock-sticky-offset, 0)); + + @media (width < 1080px) { + top: var(--titleblock-sticky-offset, 0); + } + } + + .rowBelowSeparator { + z-index: 0; + } + } + + .sticky .titleRow, + .sticky .rowBelowSeparator { + background-color: $dt-color-background-color-default; + } + + .sticky.lightVariant .titleRow, + .sticky.lightVariant .rowBelowSeparator { + background-color: $color-white; } .lightVariant .titleRow { @@ -71,6 +100,19 @@ background-color: $color-white; } + .sticky.adminVariant .titleRow { + background-color: $color-white; + } + + .sticky.adminVariant .rowBelowSeparator { + background-color: $dt-color-background-color-admin; + } + + .sticky.educationVariant .titleRow, + .sticky.educationVariant .rowBelowSeparator { + background-color: $dt-color-background-color-eduction; + } + %titleBlockInner { box-sizing: border-box; max-width: $layout-content-max-width; diff --git a/packages/components/src/TitleBlock/TitleBlock.tsx b/packages/components/src/TitleBlock/TitleBlock.tsx index 7057c278a60..bfc7bed373b 100644 --- a/packages/components/src/TitleBlock/TitleBlock.tsx +++ b/packages/components/src/TitleBlock/TitleBlock.tsx @@ -248,6 +248,7 @@ export const TitleBlock = ({ secondaryOverflowMenuItems, navigationTabs, collapseNavigationAreaWhenPossible = false, + sticky = false, textDirection, surveyStatus, id, @@ -280,6 +281,7 @@ export const TitleBlock = ({ collapseNavigationArea && !(sectionTitle ?? sectionTitleDescription ?? renderSectionTitle) && styles.collapseNavigationArea, + sticky && styles.sticky, title && title.length >= 30 && styles.hasLongTitle, subtitle && typeof subtitle === 'string' && diff --git a/packages/components/src/TitleBlock/_docs/TitleBlock.stories.tsx b/packages/components/src/TitleBlock/_docs/TitleBlock.stories.tsx index 6aa7cea71b9..e0e29ba75fe 100644 --- a/packages/components/src/TitleBlock/_docs/TitleBlock.stories.tsx +++ b/packages/components/src/TitleBlock/_docs/TitleBlock.stories.tsx @@ -3,9 +3,11 @@ import { type Meta, type StoryObj } from '@storybook/react' import { expect, waitFor, within } from '@storybook/test' import { Heading } from 'react-aria-components' import { Icon } from '~components/Icon' +import { GlobalNotification } from '~components/Notification' import { assetUrl } from '~components/utils/hostedAssets' import { StickerSheet } from '~storybook/components/StickerSheet' import { NavigationTab, TitleBlock } from '../index' +import stickyBannerStyles from './stickyBanner.module.css' const SECONDARY_ACTIONS = [ { @@ -125,6 +127,50 @@ export default meta type Story = StoryObj +const STICKY_SCROLLABLE_CONTAINER_STYLES = { + height: '200px', + overflowY: 'auto' as const, + backgroundColor: 'var(--color-white)', +} + +const STICKY_SCROLLABLE_FRAME_STYLES = { + margin: '0 auto', + maxWidth: '1200px', + border: '1px solid var(--border-solid-border-color)', + borderRadius: '12px', + overflow: 'hidden' as const, + backgroundColor: 'var(--color-white)', +} + +const renderStickyScrollableTitleBlock = ( + args: React.ComponentProps, +): JSX.Element => ( +
+
+
Fake app chrome nav (72px)
+ + + +
+
+
+) + export const Playground: Story = { parameters: { docs: { @@ -692,3 +738,197 @@ export const WithOnlySecondaryActions: Story = { avatar: undefined, }, } + +export const StickyTopStripInScrollableContainer: Story = { + name: 'Sticker Sheet (Sticky Top Strip In Scrollable Container)', + parameters: { + viewport: viewports, + chromatic: chromaticViewports, + }, + args: { + sticky: true, + }, + render: (args) => { + const { variant: _variant, ...argsWithoutVariant } = args + + return ( + + + {renderStickyScrollableTitleBlock({ + ...argsWithoutVariant, + title: 'Default Variant', + subtitle: 'Sticky top strip inside a scrollable content area', + breadcrumb: { + path: '#', + text: 'Back to home', + }, + navigationTabs: [ + , + , + ], + })} + + + {renderStickyScrollableTitleBlock({ + ...argsWithoutVariant, + variant: 'education', + title: 'Education Variant', + subtitle: 'Sticky top strip inside a scrollable content area', + breadcrumb: { + path: '#', + text: 'Back to courses', + }, + navigationTabs: [ + , + , + ], + })} + + + {renderStickyScrollableTitleBlock({ + ...argsWithoutVariant, + variant: 'admin', + title: 'Admin Variant', + subtitle: 'Sticky top strip inside a scrollable content area', + breadcrumb: { + path: '#', + text: 'Back to dashboard', + }, + navigationTabs: [ + , + , + ], + })} + + + {renderStickyScrollableTitleBlock({ + ...argsWithoutVariant, + variant: 'light', + title: 'Light Variant', + subtitle: 'Sticky top strip inside a scrollable content area', + breadcrumb: { + path: '#', + text: 'Back to overview', + }, + navigationTabs: [ + , + , + ], + })} + + + ) + }, + play: async ({ canvasElement, step }) => { + await step('scroll each sticky container before snapshot', async () => { + const scrollContainers = canvasElement.querySelectorAll( + '[data-scroll-container="sticky-top-strip"]', + ) + + scrollContainers.forEach((scrollContainer) => { + scrollContainer.scrollTo({ + top: scrollContainer.scrollHeight - scrollContainer.clientHeight, + }) + }) + + await waitFor(() => { + scrollContainers.forEach((scrollContainer) => { + expect(scrollContainer.scrollTop).toBeGreaterThan(0) + }) + }) + }) + }, +} + +/** + * Sticky banner (GlobalNotification) above a sticky TitleBlock. + * + * Follows the additive-offset pattern: the banner pins below the app-chrome + * bar by *reading* the shared `--app-chrome-sticky-offset` (never reassigning + * it), and the TitleBlock strip reserves space for the banner via the opt-in + * `--titleblock-sticky-offset`, set on a `display: contents` wrapper right + * around the TitleBlock. The banner height is measured with a ResizeObserver + * and fed into that variable so the strip stays flush at any banner height. + */ +const StickyBannerAboveTitleBlock = ( + args: React.ComponentProps, +): JSX.Element => { + const containerRef = React.useRef(null) + const [bannerHeight, setBannerHeight] = React.useState(0) + + React.useEffect(() => { + // GlobalNotification doesn't forward a ref, so grab its DOM node by the + // data attribute to measure the banner height. + const el = containerRef.current?.querySelector('[data-sticky-banner]') + if (!el) return + const update = (): void => setBannerHeight(el.offsetHeight) + update() + const observer = new ResizeObserver(update) + observer.observe(el) + return () => observer.disconnect() + }, []) + + return ( +
+
Fake app chrome nav (72px)
+ + This global notification renders directly above the TitleBlock. + +
+ +
+ + {/* Taller than the container so it overflows and the sticky behaviour + (+ the play() scroll assertion) stays exercised. */} +
+
+ ) +} + +export const WithGlobalNotificationAbove: Story = { + parameters: { + viewport: viewports, + chromatic: chromaticViewports, + }, + args: { + sticky: true, + }, + render: (args) => , + play: async ({ canvasElement, step }) => { + await step('scroll the sticky container before snapshot', async () => { + const scrollContainers = canvasElement.querySelectorAll( + '[data-scroll-container="sticky-top-strip"]', + ) + + scrollContainers.forEach((scrollContainer) => { + scrollContainer.scrollTo({ + top: scrollContainer.scrollHeight - scrollContainer.clientHeight, + }) + }) + + await waitFor(() => { + scrollContainers.forEach((scrollContainer) => { + expect(scrollContainer.scrollTop).toBeGreaterThan(0) + }) + }) + }) + }, +} diff --git a/packages/components/src/TitleBlock/_docs/stickyBanner.module.css b/packages/components/src/TitleBlock/_docs/stickyBanner.module.css new file mode 100644 index 00000000000..f4eeff3d213 --- /dev/null +++ b/packages/components/src/TitleBlock/_docs/stickyBanner.module.css @@ -0,0 +1,44 @@ +/* Pin below the app-chrome bar by reading the shared offset (read-only). */ +.stickyBanner { + position: sticky; + top: var(--app-chrome-sticky-offset, 72px); + z-index: 5; +} + +/* + * Stand-in for the AppChrome content scroll area. Sets the shared + * `--app-chrome-sticky-offset` that the sticky titleRow and banner read. + * Below 1080px the AppChrome nav collapses to a hamburger (no persistent top + * bar), so the offset drops to 0 — matching kaizen's own + * `@media (width < 1080px)` rule on the titleRow. + */ +.scrollContainer { + --app-chrome-sticky-offset: 72px; +} + +/* + * Fake AppChrome top nav — a sticky bar at >=1080px, removed below that + * (the real nav becomes a hamburger with no top bar). + */ +.fakeAppChromeNav { + position: sticky; + top: 0; + z-index: 2; + display: flex; + align-items: center; + height: 72px; + padding-inline: 16px; + background-color: var(--color-purple-800); + color: var(--color-white); + font-weight: 600; +} + +@media (width < 1080px) { + .scrollContainer { + --app-chrome-sticky-offset: 0; + } + + .fakeAppChromeNav { + display: none; + } +} diff --git a/packages/components/src/TitleBlock/types.ts b/packages/components/src/TitleBlock/types.ts index ea11a432aeb..de7c60ab6c5 100644 --- a/packages/components/src/TitleBlock/types.ts +++ b/packages/components/src/TitleBlock/types.ts @@ -32,6 +32,17 @@ export type TitleBlockProps = { secondaryOverflowMenuItems?: TitleBlockMenuItemProps[] navigationTabs?: NavigationTabs collapseNavigationAreaWhenPossible?: boolean + /** + * Makes the top strip stick to the top of its scrollable container as the + * content scrolls. The TitleBlock must be rendered inside a scrollable + * ancestor. When enabled the root becomes `display: contents` so the strip + * pins as a sibling of the scrolling content; its offset is + * `--app-chrome-sticky-offset` (owned by AppChrome, read-only) plus the + * opt-in `--titleblock-sticky-offset` a consumer sets to reserve space for a + * banner above it. Below 1080px the app-chrome offset is dropped to match the + * collapsed hamburger nav. + */ + sticky?: boolean textDirection?: TextDirection surveyStatus?: SurveyStatus id?: string