@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.
- package/dist/cjs/src/TitleBlock/TitleBlock.cjs +20 -18
- package/dist/cjs/src/TitleBlock/TitleBlock.module.scss.cjs +54 -53
- package/dist/esm/src/TitleBlock/TitleBlock.mjs +20 -18
- package/dist/esm/src/TitleBlock/TitleBlock.module.scss.mjs +54 -53
- package/dist/styles.css +16 -16
- package/dist/types/TitleBlock/TitleBlock.d.ts +1 -1
- package/dist/types/TitleBlock/types.d.ts +11 -0
- package/package.json +12 -12
- package/src/TitleBlock/TitleBlock.module.scss +42 -0
- package/src/TitleBlock/TitleBlock.tsx +2 -0
- package/src/TitleBlock/_docs/TitleBlock--sticky-banner-guidelines.mdx +192 -0
- package/src/TitleBlock/_docs/TitleBlock.stories.tsx +240 -0
- package/src/TitleBlock/_docs/stickyBanner.module.css +44 -0
- package/src/TitleBlock/types.ts +11 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
134
|
-
"@cultureamp/package-bundler": "^4.0
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
162
|
+
"svgo": "^4.0.2",
|
|
163
163
|
"ts-patch": "^3.3.0",
|
|
164
164
|
"tslib": "^2.8.1",
|
|
165
|
-
"tsx": "^4.23.
|
|
166
|
-
"@kaizen/design-tokens": "11.0.
|
|
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 (<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
|
+
}
|