Skip to content
5 changes: 5 additions & 0 deletions .changeset/smooth-doodles-follow.md
Original file line number Diff line number Diff line change
@@ -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.
42 changes: 42 additions & 0 deletions packages/components/src/TitleBlock/TitleBlock.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions packages/components/src/TitleBlock/TitleBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ export const TitleBlock = ({
secondaryOverflowMenuItems,
navigationTabs,
collapseNavigationAreaWhenPossible = false,
sticky = false,
textDirection,
surveyStatus,
id,
Expand Down Expand Up @@ -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' &&
Expand Down
240 changes: 240 additions & 0 deletions packages/components/src/TitleBlock/_docs/TitleBlock.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down Expand Up @@ -125,6 +127,50 @@ export default meta

type Story = StoryObj<typeof meta>

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<typeof TitleBlock>,
): JSX.Element => (
<div style={STICKY_SCROLLABLE_FRAME_STYLES}>
<div
data-scroll-container="sticky-top-strip"
className={stickyBannerStyles.scrollContainer}
style={{
...STICKY_SCROLLABLE_CONTAINER_STYLES,
// Taller than the shared default so the fake nav and the TitleBlock
// content fit without cramping.
height: '400px',
}}
>
<div className={stickyBannerStyles.fakeAppChromeNav}>Fake app chrome nav (72px)</div>

<TitleBlock {...args} />

<div
style={{
// Taller than the container so it overflows and the sticky behaviour
// (+ the play() scroll assertion) stays exercised.
height: '500px',
}}
/>
</div>
</div>
)

export const Playground: Story = {
parameters: {
docs: {
Expand Down Expand Up @@ -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 (
<StickerSheet title="Sticky top strip within a scrollable container">
<StickerSheet.Row header="Default (Purple background)">
{renderStickyScrollableTitleBlock({
...argsWithoutVariant,
title: 'Default Variant',
subtitle: 'Sticky top strip inside a scrollable content area',
breadcrumb: {
path: '#',
text: 'Back to home',
},
navigationTabs: [
<NavigationTab key="1" text="Overview" href="#" active />,
<NavigationTab key="2" text="Settings" href="#" />,
],
})}
</StickerSheet.Row>
<StickerSheet.Row header="Education (Blue background)">
{renderStickyScrollableTitleBlock({
...argsWithoutVariant,
variant: 'education',
title: 'Education Variant',
subtitle: 'Sticky top strip inside a scrollable content area',
breadcrumb: {
path: '#',
text: 'Back to courses',
},
navigationTabs: [
<NavigationTab key="1" variant="education" text="Lessons" href="#" active />,
<NavigationTab key="2" variant="education" text="Assignments" href="#" />,
],
})}
</StickerSheet.Row>
<StickerSheet.Row header="Admin (White background)">
{renderStickyScrollableTitleBlock({
...argsWithoutVariant,
variant: 'admin',
title: 'Admin Variant',
subtitle: 'Sticky top strip inside a scrollable content area',
breadcrumb: {
path: '#',
text: 'Back to dashboard',
},
navigationTabs: [
<NavigationTab key="1" variant="admin" text="Users" href="#" active />,
<NavigationTab key="2" variant="admin" text="Settings" href="#" />,
],
})}
</StickerSheet.Row>
<StickerSheet.Row header="Light (White background)">
{renderStickyScrollableTitleBlock({
...argsWithoutVariant,
variant: 'light',
title: 'Light Variant',
subtitle: 'Sticky top strip inside a scrollable content area',
breadcrumb: {
path: '#',
text: 'Back to overview',
},
navigationTabs: [
<NavigationTab key="1" variant="light" text="Details" href="#" active />,
<NavigationTab key="2" variant="light" text="Analytics" href="#" />,
],
})}
</StickerSheet.Row>
</StickerSheet>
)
},
play: async ({ canvasElement, step }) => {
await step('scroll each sticky container before snapshot', async () => {
const scrollContainers = canvasElement.querySelectorAll<HTMLElement>(
'[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<typeof TitleBlock>,
): JSX.Element => {
const containerRef = React.useRef<HTMLDivElement>(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<HTMLElement>('[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 (
<div
ref={containerRef}
data-scroll-container="sticky-top-strip"
className={stickyBannerStyles.scrollContainer}
style={{
height: '500px',
overflowY: 'auto',
}}
>
<div className={stickyBannerStyles.fakeAppChromeNav}>Fake app chrome nav (72px)</div>
<GlobalNotification
variant="informative"
persistent
data-sticky-banner
classNameOverride={stickyBannerStyles.stickyBanner}
>
This global notification renders directly above the TitleBlock.
</GlobalNotification>
<div
style={{
display: 'contents',
['--titleblock-sticky-offset' as string]: `${bannerHeight}px`,
}}
>
<TitleBlock {...args} />
</div>

{/* Taller than the container so it overflows and the sticky behaviour
(+ the play() scroll assertion) stays exercised. */}
<div style={{ height: '600px' }} />
</div>
)
}

export const WithGlobalNotificationAbove: Story = {
parameters: {
viewport: viewports,
chromatic: chromaticViewports,
},
args: {
sticky: true,
},
render: (args) => <StickyBannerAboveTitleBlock {...args} />,
play: async ({ canvasElement, step }) => {
await step('scroll the sticky container before snapshot', async () => {
const scrollContainers = canvasElement.querySelectorAll<HTMLElement>(
'[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)
})
})
})
},
}
44 changes: 44 additions & 0 deletions packages/components/src/TitleBlock/_docs/stickyBanner.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading