@kaizen/components 3.3.5 → 3.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,6 @@ import { type TitleBlockProps } from './types';
4
4
  * {@link https://cultureamp.design/?path=/docs/components-titleblock-api-specification--docs Storybook}
5
5
  */
6
6
  export declare const TitleBlock: {
7
- ({ title, variant, breadcrumb, avatar, subtitle, sectionTitle, sectionTitleDescription, renderSectionTitle, pageSwitcherSelect, handleHamburgerClick, primaryAction, defaultAction, secondaryActions, secondaryOverflowMenuItems, navigationTabs, collapseNavigationAreaWhenPossible, textDirection, surveyStatus, id, titleAutomationId, avatarAutomationId, subtitleAutomationId, sectionTitleAutomationId, sectionTitleDescriptionAutomationId, breadcrumbAutomationId, breadcrumbTextAutomationId, }: TitleBlockProps): JSX.Element;
7
+ ({ title, variant, breadcrumb, avatar, subtitle, sectionTitle, sectionTitleDescription, renderSectionTitle, pageSwitcherSelect, handleHamburgerClick, primaryAction, defaultAction, secondaryActions, secondaryOverflowMenuItems, navigationTabs, collapseNavigationAreaWhenPossible, sticky, textDirection, surveyStatus, id, titleAutomationId, avatarAutomationId, subtitleAutomationId, sectionTitleAutomationId, sectionTitleDescriptionAutomationId, breadcrumbAutomationId, breadcrumbTextAutomationId, }: TitleBlockProps): JSX.Element;
8
8
  displayName: string;
9
9
  };
@@ -31,6 +31,17 @@ export type TitleBlockProps = {
31
31
  secondaryOverflowMenuItems?: TitleBlockMenuItemProps[];
32
32
  navigationTabs?: NavigationTabs;
33
33
  collapseNavigationAreaWhenPossible?: boolean;
34
+ /**
35
+ * Makes the top strip stick to the top of its scrollable container as the
36
+ * content scrolls. The TitleBlock must be rendered inside a scrollable
37
+ * ancestor. When enabled the root becomes `display: contents` so the strip
38
+ * pins as a sibling of the scrolling content; its offset is
39
+ * `--app-chrome-sticky-offset` (owned by AppChrome, read-only) plus the
40
+ * opt-in `--titleblock-sticky-offset` a consumer sets to reserve space for a
41
+ * banner above it. Below 1080px the app-chrome offset is dropped to match the
42
+ * collapsed hamburger nav.
43
+ */
44
+ sticky?: boolean;
34
45
  textDirection?: TextDirection;
35
46
  surveyStatus?: SurveyStatus;
36
47
  id?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaizen/components",
3
- "version": "3.3.5",
3
+ "version": "3.3.7",
4
4
  "description": "Kaizen component library",
5
5
  "author": "Geoffrey Chong <geoff.chong@cultureamp.com>",
6
6
  "homepage": "https://cultureamp.design",
@@ -77,7 +77,7 @@
77
77
  "kaizen-codemod": "./bin/codemod.sh"
78
78
  },
79
79
  "dependencies": {
80
- "@floating-ui/react-dom": "^2.1.8",
80
+ "@floating-ui/react-dom": "^2.1.9",
81
81
  "@headlessui/react": "^2.2.10",
82
82
  "@internationalized/date": "^3.8.2",
83
83
  "@popperjs/core": "^2.11.8",
@@ -106,13 +106,13 @@
106
106
  "prosemirror-history": "^1.5.0",
107
107
  "prosemirror-inputrules": "^1.5.1",
108
108
  "prosemirror-keymap": "^1.2.3",
109
- "prosemirror-model": "^1.25.10",
109
+ "prosemirror-model": "^1.25.11",
110
110
  "prosemirror-schema-basic": "^1.2.4",
111
111
  "prosemirror-schema-list": "^1.5.1",
112
112
  "prosemirror-state": "^1.4.4",
113
113
  "prosemirror-transform": "^1.12.0",
114
114
  "prosemirror-utils": "^1.2.2",
115
- "prosemirror-view": "^1.42.0",
115
+ "prosemirror-view": "^1.42.1",
116
116
  "react-animate-height": "^3.2.4",
117
117
  "react-aria": "^3.41.1",
118
118
  "react-aria-components": "^1.10.1",
@@ -130,8 +130,8 @@
130
130
  },
131
131
  "devDependencies": {
132
132
  "@cultureamp/frontend-apis": "13.3.0",
133
- "@cultureamp/i18n-react-intl": "^4.2.1",
134
- "@cultureamp/package-bundler": "^4.0.1",
133
+ "@cultureamp/i18n-react-intl": "^4.3.2",
134
+ "@cultureamp/package-bundler": "^4.1.0",
135
135
  "@svgr/rollup": "^8.1.0",
136
136
  "cssnano": "^7.1.9",
137
137
  "@testing-library/dom": "^10.4.1",
@@ -140,13 +140,13 @@
140
140
  "@types/react-highlight": "^0.12.8",
141
141
  "@types/react-textfit": "^1.1.4",
142
142
  "@types/uuid": "^11.0.0",
143
- "autoprefixer": "^10.5.2",
143
+ "autoprefixer": "^10.5.4",
144
144
  "concat-cli": "^4.0.0",
145
145
  "identity-obj-proxy": "^3.0.0",
146
146
  "jest-axe": "^10.0.0",
147
147
  "lodash.isempty": "^4.4.0",
148
148
  "normalize.css": "^8.0.1",
149
- "postcss": "^8.5.16",
149
+ "postcss": "^8.5.19",
150
150
  "postcss-cli": "^11.0.1",
151
151
  "postcss-import": "^16.1.1",
152
152
  "postcss-preset-env": "^11.3.2",
@@ -155,15 +155,15 @@
155
155
  "react": "^19.2.7",
156
156
  "react-dom": "^19.2.7",
157
157
  "react-highlight": "^0.15.0",
158
- "react-intl": "^10.1.15",
158
+ "react-intl": "^10.1.18",
159
159
  "rollup": "^4.62.2",
160
160
  "sass": "1.79.6",
161
161
  "serialize-query-params": "^2.0.4",
162
- "svgo": "^4.0.1",
162
+ "svgo": "^4.0.2",
163
163
  "ts-patch": "^3.3.0",
164
164
  "tslib": "^2.8.1",
165
- "tsx": "^4.23.0",
166
- "@kaizen/design-tokens": "11.0.13"
165
+ "tsx": "^4.23.1",
166
+ "@kaizen/design-tokens": "11.0.14"
167
167
  },
168
168
  "devDependenciesComments": {
169
169
  "sass": "Prevent deprecation warnings introduced in 1.80 as we plan to move away from sass",
@@ -61,6 +61,35 @@
61
61
  display: flex;
62
62
  width: 100%;
63
63
  justify-content: center;
64
+ position: relative;
65
+ }
66
+
67
+ .sticky {
68
+ display: contents;
69
+
70
+ .titleRow {
71
+ z-index: 5;
72
+ position: sticky;
73
+ top: calc(var(--app-chrome-sticky-offset, 72px) + var(--titleblock-sticky-offset, 0px));
74
+
75
+ @media (width < 1080px) {
76
+ top: var(--titleblock-sticky-offset, 0);
77
+ }
78
+ }
79
+
80
+ .rowBelowSeparator {
81
+ z-index: 0;
82
+ }
83
+ }
84
+
85
+ .sticky .titleRow,
86
+ .sticky .rowBelowSeparator {
87
+ background-color: $dt-color-background-color-default;
88
+ }
89
+
90
+ .sticky.lightVariant .titleRow,
91
+ .sticky.lightVariant .rowBelowSeparator {
92
+ background-color: $color-white;
64
93
  }
65
94
 
66
95
  .lightVariant .titleRow {
@@ -71,6 +100,19 @@
71
100
  background-color: $color-white;
72
101
  }
73
102
 
103
+ .sticky.adminVariant .titleRow {
104
+ background-color: $color-white;
105
+ }
106
+
107
+ .sticky.adminVariant .rowBelowSeparator {
108
+ background-color: $dt-color-background-color-admin;
109
+ }
110
+
111
+ .sticky.educationVariant .titleRow,
112
+ .sticky.educationVariant .rowBelowSeparator {
113
+ background-color: $dt-color-background-color-eduction;
114
+ }
115
+
74
116
  %titleBlockInner {
75
117
  box-sizing: border-box;
76
118
  max-width: $layout-content-max-width;
@@ -248,6 +248,7 @@ export const TitleBlock = ({
248
248
  secondaryOverflowMenuItems,
249
249
  navigationTabs,
250
250
  collapseNavigationAreaWhenPossible = false,
251
+ sticky = false,
251
252
  textDirection,
252
253
  surveyStatus,
253
254
  id,
@@ -280,6 +281,7 @@ export const TitleBlock = ({
280
281
  collapseNavigationArea &&
281
282
  !(sectionTitle ?? sectionTitleDescription ?? renderSectionTitle) &&
282
283
  styles.collapseNavigationArea,
284
+ sticky && styles.sticky,
283
285
  title && title.length >= 30 && styles.hasLongTitle,
284
286
  subtitle &&
285
287
  typeof subtitle === 'string' &&
@@ -0,0 +1,192 @@
1
+ import { Canvas, Meta } from '@storybook/blocks'
2
+ import { ResourceLinks } from '~storybook/components'
3
+ import * as TitleBlockStories from './TitleBlock.stories'
4
+
5
+ <Meta title="Components/TitleBlock/Sticky Banner" />
6
+
7
+ # Sticky banner above a sticky TitleBlock
8
+
9
+ How to reserve sticky space for a banner above a Kaizen `TitleBlock` — using an
10
+ **additive** CSS variable.
11
+
12
+ 📌 **The rule.** The banner and the TitleBlock strip both pin to the top of the
13
+ scroll container. TitleBlock exposes an additive offset
14
+ `--titleblock-sticky-offset` that your app sets to the banner's height, so the
15
+ strip pins directly below the banner.
16
+
17
+ <Canvas of={TitleBlockStories.WithGlobalNotificationAbove} />
18
+
19
+ ## 1. The mechanism (@kaizen/components)
20
+
21
+ Passing `sticky` sets the TitleBlock root to `display: contents` (so it generates
22
+ no box and its strip becomes a sibling of your content in the scroll container)
23
+ and makes the top strip `position: sticky`. The strip's `top` is the sum of two
24
+ variables:
25
+
26
+ ```scss
27
+ /* @kaizen/components — TitleBlock strip */
28
+ .titleRow {
29
+ position: sticky;
30
+ top: calc(
31
+ var(--app-chrome-sticky-offset, 72px) /* owned by AppChrome */ +
32
+ var(--titleblock-sticky-offset, 0px) /* opt-in, per consumer */
33
+ );
34
+ }
35
+ @media (width < 1080px) {
36
+ /* nav collapses to a hamburger: no top bar */
37
+ .titleRow {
38
+ top: var(--titleblock-sticky-offset, 0);
39
+ }
40
+ }
41
+ ```
42
+
43
+ **Additive = safe by default.** `--titleblock-sticky-offset` defaults to `0`, so
44
+ every existing TitleBlock is byte-for-byte unchanged unless a consumer opts in.
45
+ And because the shared `--app-chrome-sticky-offset` keeps its value, apps that
46
+ read it for other things (e.g. public-api-ui's
47
+ `min-h-[calc(100vh - var(--app-chrome-sticky-offset))]`) are unaffected.
48
+
49
+ ## 2. Structure (consuming apps)
50
+
51
+ Render the banner and the TitleBlock as siblings in the scroll container. Set
52
+ `--titleblock-sticky-offset` on a thin `display: contents` wrapper around the
53
+ TitleBlock (it adds no box, so it doesn't affect sticky). The banner just reads
54
+ the shared offset for its own pinned position — read-only, never reassigned.
55
+
56
+ ```
57
+ scroll container (AppChrome content / window)
58
+
59
+ ├─ <banner> position: sticky
60
+ │ top: 0 → var(--app-chrome-sticky-offset) at ≥1080px
61
+
62
+ └─ <div> display: contents
63
+ --titleblock-sticky-offset: {bannerHeight}px
64
+
65
+ └─ <TitleBlock sticky />
66
+ └─ .titleRow sticky, top = chromeOffset + bannerHeight
67
+
68
+ └─ page content (scrolls beneath both)
69
+ ```
70
+
71
+ ## 3. The code (consuming apps)
72
+
73
+ ```tsx
74
+ <div
75
+ ref={bannerRef}
76
+ // sticky at all widths: top:0 on mobile (no nav bar),
77
+ // below the app-chrome bar at >=1080px
78
+ className="sticky top-0 z-[7] flex items-center justify-center
79
+ min-[1080px]:top-[var(--app-chrome-sticky-offset)]"
80
+ >
81
+ Banner content
82
+ </div>
83
+
84
+ <div
85
+ style={{
86
+ display: 'contents',
87
+ // reserve space for the banner; kaizen adds this to the strip's top
88
+ '--titleblock-sticky-offset': `${bannerHeight}px`,
89
+ }}
90
+ >
91
+ <TitleBlock sticky … />
92
+ </div>
93
+ ```
94
+
95
+ No alias variable, no cyclic-reference workaround, no second wrapper, and no
96
+ breakpoint gate on the banner — the additive var handles both breakpoints.
97
+
98
+ ## 4. Measure the banner height (consuming app)
99
+
100
+ Feed a live height into `--titleblock-sticky-offset` so the strip stays flush no
101
+ matter how tall the banner gets (wrapping text, responsive padding). A
102
+ `ResizeObserver` keeps it accurate. If the banner is a fixed height, a constant
103
+ is fine instead.
104
+
105
+ ```tsx
106
+ const bannerRef = React.useRef<HTMLDivElement>(null)
107
+ // set the initial bannerHeight to the height of the GlobalNotification banner on desktop with English text
108
+ const [bannerHeight, setBannerHeight] = React.useState(48)
109
+
110
+ React.useLayoutEffect(() => {
111
+ const el = bannerRef.current
112
+ if (!el) return
113
+ const update = () => setBannerHeight(el.offsetHeight)
114
+ update()
115
+ const observer = new ResizeObserver(update)
116
+ observer.observe(el)
117
+ return () => observer.disconnect()
118
+ }, [])
119
+ ```
120
+
121
+ > **⚠️ Server-side rendering.** With a **dynamic** banner height the measured
122
+ > value is only known after hydration. In an SSR app, when a user navigates
123
+ > back/forward the browser restores the saved scroll position (Chrome's default),
124
+ > but the page's memory cache can be missed — so the banner (starting at its
125
+ > initial `useState` value) can flash and shift the TitleBlock into place after
126
+ > hydration. Two mitigations, in order of preference:
127
+ >
128
+ > - **Prefer a fixed/bounded banner height.** Before reaching for a dynamic
129
+ > height, ask whether the content can be bounded: overflow the scroll, move
130
+ > items into a dropdown, or trim excess text with a "read more" modal. A
131
+ > constant height needs no measurement and can't flash.
132
+ > - **Seed a sensible initial height** (as above — the desktop/English
133
+ > GlobalNotification height, e.g. `48`). This matches most users on first
134
+ > paint; only smaller screens or longer translations (where the text wraps to
135
+ > more lines) will adjust after measuring.
136
+
137
+ ## 5. Result: verified both widths
138
+
139
+ Banner and strip stack flush, with no overlap or gap, both pinned through
140
+ scroll. `strip.top − banner.top` equals the banner height at every width
141
+ (example: a 48px banner on desktop may turn into a 128px banner on mobile as
142
+ content shifts to new lines).
143
+
144
+ <table>
145
+ <thead>
146
+ <tr>
147
+ <th>Width</th>
148
+ <th>Banner top</th>
149
+ <th>Strip top</th>
150
+ <th>Gap</th>
151
+ </tr>
152
+ </thead>
153
+ <tbody>
154
+ <tr>
155
+ <td>Desktop (≥1080px)</td>
156
+ <td>72px (chrome bar)</td>
157
+ <td>120px (72 + 48)</td>
158
+ <td>48px = banner height</td>
159
+ </tr>
160
+ <tr>
161
+ <td>Mobile (&lt;1080px)</td>
162
+ <td>0px (hamburger, no bar)</td>
163
+ <td>128px (0 + 128)</td>
164
+ <td>128px = banner height</td>
165
+ </tr>
166
+ </tbody>
167
+ </table>
168
+
169
+ Mobile pins the banner at `0` because the top nav collapses to a hamburger —
170
+ there's no persistent bar to sit under. The `<1080px` rule drops the chrome
171
+ offset and keeps only the additive banner offset, so the two stay in sync.
172
+
173
+ ## 6. Do & don't
174
+
175
+ **Do**
176
+
177
+ - Keep the banner and TitleBlock as **siblings** in the scroll container.
178
+ - Set `--titleblock-sticky-offset` on a `display: contents` wrapper right around
179
+ the TitleBlock.
180
+ - Feed a **measured** banner height into it (or a constant for a fixed banner).
181
+ - Let the banner _read_ `--app-chrome-sticky-offset` for its own pin.
182
+
183
+ **Don't**
184
+
185
+ - Reassign `--app-chrome-sticky-offset` — it's a shared "app-chrome height"
186
+ signal other apps read; changing it resizes their layouts.
187
+ - Wrap the TitleBlock in a box with height (e.g. a `<header>`) —
188
+ `display: contents` makes the strip's containing block that box, killing its
189
+ sticky travel.
190
+ - Set `--titleblock-sticky-offset` on `:root`/`html` — scope it near the
191
+ TitleBlock so it can't leak to another nested one.
192
+ - Hardcode `120px` — derive it from the banner height.
@@ -3,9 +3,11 @@ import { type Meta, type StoryObj } from '@storybook/react'
3
3
  import { expect, waitFor, within } from '@storybook/test'
4
4
  import { Heading } from 'react-aria-components'
5
5
  import { Icon } from '~components/Icon'
6
+ import { GlobalNotification } from '~components/Notification'
6
7
  import { assetUrl } from '~components/utils/hostedAssets'
7
8
  import { StickerSheet } from '~storybook/components/StickerSheet'
8
9
  import { NavigationTab, TitleBlock } from '../index'
10
+ import stickyBannerStyles from './stickyBanner.module.css'
9
11
 
10
12
  const SECONDARY_ACTIONS = [
11
13
  {
@@ -125,6 +127,50 @@ export default meta
125
127
 
126
128
  type Story = StoryObj<typeof meta>
127
129
 
130
+ const STICKY_SCROLLABLE_CONTAINER_STYLES = {
131
+ height: '200px',
132
+ overflowY: 'auto' as const,
133
+ backgroundColor: 'var(--color-white)',
134
+ }
135
+
136
+ const STICKY_SCROLLABLE_FRAME_STYLES = {
137
+ margin: '0 auto',
138
+ maxWidth: '1200px',
139
+ border: '1px solid var(--border-solid-border-color)',
140
+ borderRadius: '12px',
141
+ overflow: 'hidden' as const,
142
+ backgroundColor: 'var(--color-white)',
143
+ }
144
+
145
+ const renderStickyScrollableTitleBlock = (
146
+ args: React.ComponentProps<typeof TitleBlock>,
147
+ ): JSX.Element => (
148
+ <div style={STICKY_SCROLLABLE_FRAME_STYLES}>
149
+ <div
150
+ data-scroll-container="sticky-top-strip"
151
+ className={stickyBannerStyles.scrollContainer}
152
+ style={{
153
+ ...STICKY_SCROLLABLE_CONTAINER_STYLES,
154
+ // Taller than the shared default so the fake nav and the TitleBlock
155
+ // content fit without cramping.
156
+ height: '400px',
157
+ }}
158
+ >
159
+ <div className={stickyBannerStyles.fakeAppChromeNav}>Fake app chrome nav (72px)</div>
160
+
161
+ <TitleBlock {...args} />
162
+
163
+ <div
164
+ style={{
165
+ // Taller than the container so it overflows and the sticky behaviour
166
+ // (+ the play() scroll assertion) stays exercised.
167
+ height: '500px',
168
+ }}
169
+ />
170
+ </div>
171
+ </div>
172
+ )
173
+
128
174
  export const Playground: Story = {
129
175
  parameters: {
130
176
  docs: {
@@ -692,3 +738,197 @@ export const WithOnlySecondaryActions: Story = {
692
738
  avatar: undefined,
693
739
  },
694
740
  }
741
+
742
+ export const StickyTopStripInScrollableContainer: Story = {
743
+ name: 'Sticker Sheet (Sticky Top Strip In Scrollable Container)',
744
+ parameters: {
745
+ viewport: viewports,
746
+ chromatic: chromaticViewports,
747
+ },
748
+ args: {
749
+ sticky: true,
750
+ },
751
+ render: (args) => {
752
+ const { variant: _variant, ...argsWithoutVariant } = args
753
+
754
+ return (
755
+ <StickerSheet title="Sticky top strip within a scrollable container">
756
+ <StickerSheet.Row header="Default (Purple background)">
757
+ {renderStickyScrollableTitleBlock({
758
+ ...argsWithoutVariant,
759
+ title: 'Default Variant',
760
+ subtitle: 'Sticky top strip inside a scrollable content area',
761
+ breadcrumb: {
762
+ path: '#',
763
+ text: 'Back to home',
764
+ },
765
+ navigationTabs: [
766
+ <NavigationTab key="1" text="Overview" href="#" active />,
767
+ <NavigationTab key="2" text="Settings" href="#" />,
768
+ ],
769
+ })}
770
+ </StickerSheet.Row>
771
+ <StickerSheet.Row header="Education (Blue background)">
772
+ {renderStickyScrollableTitleBlock({
773
+ ...argsWithoutVariant,
774
+ variant: 'education',
775
+ title: 'Education Variant',
776
+ subtitle: 'Sticky top strip inside a scrollable content area',
777
+ breadcrumb: {
778
+ path: '#',
779
+ text: 'Back to courses',
780
+ },
781
+ navigationTabs: [
782
+ <NavigationTab key="1" variant="education" text="Lessons" href="#" active />,
783
+ <NavigationTab key="2" variant="education" text="Assignments" href="#" />,
784
+ ],
785
+ })}
786
+ </StickerSheet.Row>
787
+ <StickerSheet.Row header="Admin (White background)">
788
+ {renderStickyScrollableTitleBlock({
789
+ ...argsWithoutVariant,
790
+ variant: 'admin',
791
+ title: 'Admin Variant',
792
+ subtitle: 'Sticky top strip inside a scrollable content area',
793
+ breadcrumb: {
794
+ path: '#',
795
+ text: 'Back to dashboard',
796
+ },
797
+ navigationTabs: [
798
+ <NavigationTab key="1" variant="admin" text="Users" href="#" active />,
799
+ <NavigationTab key="2" variant="admin" text="Settings" href="#" />,
800
+ ],
801
+ })}
802
+ </StickerSheet.Row>
803
+ <StickerSheet.Row header="Light (White background)">
804
+ {renderStickyScrollableTitleBlock({
805
+ ...argsWithoutVariant,
806
+ variant: 'light',
807
+ title: 'Light Variant',
808
+ subtitle: 'Sticky top strip inside a scrollable content area',
809
+ breadcrumb: {
810
+ path: '#',
811
+ text: 'Back to overview',
812
+ },
813
+ navigationTabs: [
814
+ <NavigationTab key="1" variant="light" text="Details" href="#" active />,
815
+ <NavigationTab key="2" variant="light" text="Analytics" href="#" />,
816
+ ],
817
+ })}
818
+ </StickerSheet.Row>
819
+ </StickerSheet>
820
+ )
821
+ },
822
+ play: async ({ canvasElement, step }) => {
823
+ await step('scroll each sticky container before snapshot', async () => {
824
+ const scrollContainers = canvasElement.querySelectorAll<HTMLElement>(
825
+ '[data-scroll-container="sticky-top-strip"]',
826
+ )
827
+
828
+ scrollContainers.forEach((scrollContainer) => {
829
+ scrollContainer.scrollTo({
830
+ top: scrollContainer.scrollHeight - scrollContainer.clientHeight,
831
+ })
832
+ })
833
+
834
+ await waitFor(() => {
835
+ scrollContainers.forEach((scrollContainer) => {
836
+ expect(scrollContainer.scrollTop).toBeGreaterThan(0)
837
+ })
838
+ })
839
+ })
840
+ },
841
+ }
842
+
843
+ /**
844
+ * Sticky banner (GlobalNotification) above a sticky TitleBlock.
845
+ *
846
+ * Follows the additive-offset pattern: the banner pins below the app-chrome
847
+ * bar by *reading* the shared `--app-chrome-sticky-offset` (never reassigning
848
+ * it), and the TitleBlock strip reserves space for the banner via the opt-in
849
+ * `--titleblock-sticky-offset`, set on a `display: contents` wrapper right
850
+ * around the TitleBlock. The banner height is measured with a ResizeObserver
851
+ * and fed into that variable so the strip stays flush at any banner height.
852
+ */
853
+ const StickyBannerAboveTitleBlock = (
854
+ args: React.ComponentProps<typeof TitleBlock>,
855
+ ): JSX.Element => {
856
+ const containerRef = React.useRef<HTMLDivElement>(null)
857
+ const [bannerHeight, setBannerHeight] = React.useState(0)
858
+
859
+ React.useLayoutEffect(() => {
860
+ // GlobalNotification doesn't forward a ref, so grab its DOM node by the
861
+ // data attribute to measure the banner height.
862
+ const el = containerRef.current?.querySelector<HTMLElement>('[data-sticky-banner]')
863
+ if (!el) return
864
+ const update = (): void => setBannerHeight(el.offsetHeight)
865
+ update()
866
+ const observer = new ResizeObserver(update)
867
+ observer.observe(el)
868
+ return () => observer.disconnect()
869
+ }, [])
870
+
871
+ return (
872
+ <div
873
+ ref={containerRef}
874
+ data-scroll-container="sticky-top-strip"
875
+ className={stickyBannerStyles.scrollContainer}
876
+ style={{
877
+ height: '500px',
878
+ overflowY: 'auto',
879
+ }}
880
+ >
881
+ <div className={stickyBannerStyles.fakeAppChromeNav}>Fake app chrome nav (72px)</div>
882
+ <GlobalNotification
883
+ variant="informative"
884
+ persistent
885
+ data-sticky-banner
886
+ classNameOverride={stickyBannerStyles.stickyBanner}
887
+ >
888
+ This global notification renders directly above the TitleBlock.
889
+ </GlobalNotification>
890
+ <div
891
+ style={{
892
+ display: 'contents',
893
+ ['--titleblock-sticky-offset' as string]: `${bannerHeight}px`,
894
+ }}
895
+ >
896
+ <TitleBlock {...args} />
897
+ </div>
898
+
899
+ {/* Taller than the container so it overflows and the sticky behaviour
900
+ (+ the play() scroll assertion) stays exercised. */}
901
+ <div style={{ height: '600px' }} />
902
+ </div>
903
+ )
904
+ }
905
+
906
+ export const WithGlobalNotificationAbove: Story = {
907
+ parameters: {
908
+ viewport: viewports,
909
+ chromatic: chromaticViewports,
910
+ },
911
+ args: {
912
+ sticky: true,
913
+ },
914
+ render: (args) => <StickyBannerAboveTitleBlock {...args} />,
915
+ play: async ({ canvasElement, step }) => {
916
+ await step('scroll the sticky container before snapshot', async () => {
917
+ const scrollContainers = canvasElement.querySelectorAll<HTMLElement>(
918
+ '[data-scroll-container="sticky-top-strip"]',
919
+ )
920
+
921
+ scrollContainers.forEach((scrollContainer) => {
922
+ scrollContainer.scrollTo({
923
+ top: scrollContainer.scrollHeight - scrollContainer.clientHeight,
924
+ })
925
+ })
926
+
927
+ await waitFor(() => {
928
+ scrollContainers.forEach((scrollContainer) => {
929
+ expect(scrollContainer.scrollTop).toBeGreaterThan(0)
930
+ })
931
+ })
932
+ })
933
+ },
934
+ }
@@ -0,0 +1,44 @@
1
+ /* Pin below the app-chrome bar by reading the shared offset (read-only). */
2
+ .stickyBanner {
3
+ position: sticky;
4
+ top: var(--app-chrome-sticky-offset, 72px);
5
+ z-index: 5;
6
+ }
7
+
8
+ /*
9
+ * Stand-in for the AppChrome content scroll area. Sets the shared
10
+ * `--app-chrome-sticky-offset` that the sticky titleRow and banner read.
11
+ * Below 1080px the AppChrome nav collapses to a hamburger (no persistent top
12
+ * bar), so the offset drops to 0 — matching kaizen's own
13
+ * `@media (width < 1080px)` rule on the titleRow.
14
+ */
15
+ .scrollContainer {
16
+ --app-chrome-sticky-offset: 72px;
17
+ }
18
+
19
+ /*
20
+ * Fake AppChrome top nav — a sticky bar at >=1080px, removed below that
21
+ * (the real nav becomes a hamburger with no top bar).
22
+ */
23
+ .fakeAppChromeNav {
24
+ position: sticky;
25
+ top: 0;
26
+ z-index: 2;
27
+ display: flex;
28
+ align-items: center;
29
+ height: 72px;
30
+ padding-inline: 16px;
31
+ background-color: var(--color-purple-800);
32
+ color: var(--color-white);
33
+ font-weight: 600;
34
+ }
35
+
36
+ @media (width < 1080px) {
37
+ .scrollContainer {
38
+ --app-chrome-sticky-offset: 0;
39
+ }
40
+
41
+ .fakeAppChromeNav {
42
+ display: none;
43
+ }
44
+ }