@kaizen/components 3.3.4 → 3.3.6

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.
@@ -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
+ }
@@ -32,6 +32,17 @@ export type TitleBlockProps = {
32
32
  secondaryOverflowMenuItems?: TitleBlockMenuItemProps[]
33
33
  navigationTabs?: NavigationTabs
34
34
  collapseNavigationAreaWhenPossible?: boolean
35
+ /**
36
+ * Makes the top strip stick to the top of its scrollable container as the
37
+ * content scrolls. The TitleBlock must be rendered inside a scrollable
38
+ * ancestor. When enabled the root becomes `display: contents` so the strip
39
+ * pins as a sibling of the scrolling content; its offset is
40
+ * `--app-chrome-sticky-offset` (owned by AppChrome, read-only) plus the
41
+ * opt-in `--titleblock-sticky-offset` a consumer sets to reserve space for a
42
+ * banner above it. Below 1080px the app-chrome offset is dropped to match the
43
+ * collapsed hamburger nav.
44
+ */
45
+ sticky?: boolean
35
46
  textDirection?: TextDirection
36
47
  surveyStatus?: SurveyStatus
37
48
  id?: string