Skip to content

Commit

Permalink
DS-1176 | Add Tabs/Agenda to Momentum (#391)
Browse files Browse the repository at this point in the history
* Clone Agenda/TabGroup component and other utilities from Tour

* Update font
  • Loading branch information
yvonnetangsu authored Feb 26, 2025
1 parent 752d816 commit 8b869ac
Show file tree
Hide file tree
Showing 10 changed files with 335 additions and 6 deletions.
5 changes: 4 additions & 1 deletion components/Animate/AnimateInView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AnimationMap, type AnimationType } from './AnimationMap';

type AnimateInViewProps = {
animation?: AnimationType;
id?: string;
once?: boolean;
duration?: number;
delay?: number;
Expand All @@ -15,6 +16,7 @@ type AnimateInViewProps = {

export const AnimateInView = ({
animation = 'zoomIn',
id,
once = true,
duration = 0.6,
delay,
Expand All @@ -28,14 +30,15 @@ export const AnimateInView = ({

// Don't animate if the user has "reduced motion" enabled
if (animation === 'none') {
return <div>{children}</div>;
return <div id={id}>{children}</div>;
}

const beforeAnimationState = prefersReducedMotion ? 'hiddenReduced' : 'hidden';

return (
<m.div
ref={ref}
id={id}
variants={AnimationMap[animation]}
transition={{
delay,
Expand Down
4 changes: 2 additions & 2 deletions components/LazyMotionProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
'use client';
import { LazyMotion, domAnimation } from 'framer-motion';
import { LazyMotion, domMax } from 'framer-motion';

interface LazyMotionProviderProps {
children: React.ReactNode;
}
const LazyMotionProvider = ({ children }: LazyMotionProviderProps) => {
return (
<LazyMotion features={domAnimation} strict>
<LazyMotion features={domMax} strict>
{children}
</LazyMotion>
);
Expand Down
46 changes: 46 additions & 0 deletions components/Storyblok/SbTabGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { storyblokEditable } from '@storyblok/react/rsc';
import { Tabs } from '@/components/Tabs';
import { type SbTabItemType } from '@/components/Storyblok/Storyblok.types';
import { type HeadingType } from '@/components/Typography';
import { type AnimationType } from '@/components/Animate';

export type SbTabGroupProps = {
blok: {
_uid: string;
isHidden?: boolean;
tabItems?: SbTabItemType[];
id?: string;
headingLevel?: HeadingType;
isSerifHeading?: boolean;
isLightText?: boolean;
animation?: AnimationType;
};
};
export const SbTabGroup = ({
blok: {
isHidden,
tabItems,
id,
headingLevel,
isSerifHeading,
isLightText,
animation,
},
blok,
}: SbTabGroupProps) => {
if (isHidden || !tabItems?.length) {
return null;
}

return (
<Tabs
{...storyblokEditable(blok)}
id={id}
tabItems={tabItems}
headingLevel={headingLevel || 'h3'}
isSerifHeading={isSerifHeading}
isLightText={isLightText}
animation={animation}
/>
);
};
12 changes: 12 additions & 0 deletions components/Storyblok/Storyblok.types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { type SbBlokData } from '@storyblok/react/rsc';
import { type FontSizeType } from '@/components/Typography';
import { type TabItemHeadingSizeType } from '@/components/Tabs';
import { type StoryblokRichtext } from 'storyblok-rich-text-react-renderer-ts';

/**
Expand Down Expand Up @@ -108,3 +109,14 @@ export type SbSliderImageType = {
alt?: string;
caption?: StoryblokRichtext;
};

// Used for SbTabGroup
export type SbTabItemType = {
_uid: string;
label: string;
featuredMedia?: SbBlokData[];
heading?: string;
body?: StoryblokRichtext;
otherContent?: SbBlokData[];
headingSize?: TabItemHeadingSizeType;
};
2 changes: 2 additions & 0 deletions components/StoryblokProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { SbStoryImage } from '@/components/Storyblok/SbStoryImage';
import { SbStoryListHero } from '@/components/Storyblok/SbStoryListHero';
import { SbStoryListNav } from '@/components/Storyblok/SbStoryListNav';
import { SbStoryMvp } from '@/components/Storyblok/SbStoryMvp/SbStoryMvp';
import { SbTabGroup } from '@/components/Storyblok/SbTabGroup';
import { SbText } from '@/components/Storyblok/SbText';
import { SbTextCard } from '@/components/Storyblok/SbTextCard';
import { SbTexturedBar } from '@/components/Storyblok/SbTexturedBar';
Expand Down Expand Up @@ -83,6 +84,7 @@ export const components = {
sbStoryListNav: SbStoryListNav,
sbStoryMvp: SbStoryMvp,
sbStoryImage: SbStoryImage,
sbTabGroup: SbTabGroup,
sbText: SbText,
sbTextCard: SbTextCard,
sbTexturedBar: SbTexturedBar,
Expand Down
28 changes: 28 additions & 0 deletions components/Tabs/Tabs.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { cnb } from 'cnbuilder';

export const headingSizes = {
small: 'fluid-type-4',
medium: 'fluid-type-4 md:fluid-type-5',
large: 'fluid-type-4 md:fluid-type-6',
};
export type TabItemHeadingSizeType = keyof typeof headingSizes;

export const root = 'relative cc break-words scroll-mt-80';
export const tabGroup = 'hidden sm:grid grid-cols-12';
export const tabList = (isKeyboardUser: boolean) => cnb('flex flex-col col-span-4', isKeyboardUser && 'focus-within:outline focus-within:outline-digital-blue');
export const tabItem = (isLightText: boolean) => cnb('relative data-[hover]:underline data-[hover]:underline-offset-4 data-[selected]:outline-none text-left md:pl-20 pr-20 lg:px-26 py-20 lg:py-26 type-1 lg:text-[2.7rem] xl:text-[3.4rem] font-normal data-[selected]:underline data-[selected]:underline-offset-4 leading-display transition-colors',
isLightText ?
'text-black-30 data-[selected]:text-white hover:text-white'
: 'text-black/60 data-[selected]:text-gc-black hover:text-gc-black',
);
export const tabItemBar = (isLightText: boolean) => cnb('h-full w-20 absolute right-0 top-0 ',
isLightText ? 'bg-digital-red-light' : 'bg-digital-red',
);
export const tabPanel = (isLightText: boolean) => cnb('border-l pl-20 lg:pl-30 xl:pl-36 col-span-8',
isLightText ? 'border-black-50' : 'border-black-60',
);

export const superhead = 'rs-mt-2 first:mt-0 mb-04em';
export const heading = (headingSize: TabItemHeadingSizeType) => headingSizes[headingSize || 'medium'];
export const mobileGrid = 'sm:hidden list-unstyled';
export const li = 'scroll-mt-30';
205 changes: 205 additions & 0 deletions components/Tabs/Tabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import {
Fragment, useEffect, useId, useRef, useState,
} from 'react';
import {
Tab, TabGroup, TabList, TabPanel, TabPanels,
} from '@headlessui/react';
import { m } from 'framer-motion';
import { useMediaQuery } from 'usehooks-ts';
import { useKeyboard } from '@/hooks/useKeyboard';
import { AnimateInView, type AnimationType } from '@/components/Animate';
import { CreateBloks } from '@/components/CreateBloks';
import { Grid } from '@/components/Grid';
import { RichText } from '@/components/RichText';
import {
Heading, SrOnlyText, Text, type HeadingType,
} from '@/components/Typography';
import { type SbTabItemType } from '@/components/Storyblok/Storyblok.types';
import { hasRichText } from '@/utilities/hasRichText';
import { slugify } from '@/utilities/slugify';
import { config } from '@/utilities/config';
import * as styles from './Tabs.styles';

type TabsProps = React.HTMLAttributes<HTMLDivElement> & {
tabItems: SbTabItemType[];
isSerifHeading?: boolean;
headingLevel?: HeadingType;
isLightText?: boolean;
animation?: AnimationType;
};

type TabContentProps = Omit<TabsProps, 'tabItems'> & Omit<SbTabItemType, '_uid'>;

/**
* Content inside each tab item that will be display expanded on mobile
*/
const TabContent = ({
isSerifHeading,
headingLevel,
headingSize,
isLightText,
animation,
label,
heading,
featuredMedia,
id,
body,
otherContent,
}: TabContentProps) => (
<AnimateInView animation={animation} id={id}>
<CreateBloks blokSection={featuredMedia} />
<Text
size={1}
weight="semibold"
aria-hidden="true"
color={isLightText ? 'white' : 'black'}
leading="display"
className={styles.superhead}
>
{label}
</Text>
<Heading
as={headingLevel}
font={isSerifHeading ? 'serif' : 'druk'}
color={isLightText ? 'white' : 'black'}
className={styles.heading(headingSize)}
>
<SrOnlyText>{`${label}:`}</SrOnlyText>{heading}
</Heading>
{hasRichText(body) && (
<RichText
wysiwyg={body}
textColor={isLightText ? 'white' : 'black'}
linkColor={isLightText ? 'digital-red-xlight' : 'unset'}
/>
)}
<CreateBloks blokSection={otherContent} />
</AnimateInView>
);

export const Tabs = ({
tabItems,
isSerifHeading,
headingLevel,
isLightText,
animation,
id,
...props
}: TabsProps) => {
const isKeyboardUser = useKeyboard();

// We only render the component as tabs on SM breakpoint and above
const isRenderTabs = useMediaQuery(`(min-width: ${config.breakpoints.sm}px)`);

/**
* We need a unique id for each tab group when there are multiple
* on a page for the framer motion layout animation to work
*/
const tabGroupId = encodeURIComponent(useId());
const tabGroupRef = useRef<HTMLDivElement>(null);
const [selectedIndex, setSelectedIndex] = useState(0);

/**
* We need a unique prefix for each tab group to set the hash in the URL
* The user can add an id through storyblok, but we use the tabGroupId as a fallback
*/
const uniquePrefix = `${id || tabGroupId}-`;

const handleTabChange = (index: number) => {
setSelectedIndex(index);
const tabHash = `#${uniquePrefix}${slugify(tabItems[index].label)}`;
window.history.replaceState(null, '', tabHash); // Update hash without adding to history
};

// Check URL hash on initial load, update the selected tab and scroll to the correct position
useEffect(() => {
const pageHash = window.location.hash.slice(1); // Remove the "#" from the hash

// Check if the current page hash starts with the unique prefix
if (pageHash.startsWith(uniquePrefix)) {
// Remove the unique prefix from the page hash
const strippedHash = pageHash.replace(uniquePrefix, '');

// Find the index of the tab item with a tab hash that matches the stripped page shash
const index = tabItems.findIndex(tabItem => slugify(tabItem.label) === strippedHash);

if (index !== -1) {
/**
* For SM breakpoint and above, if the page hash matches a tab hash,
* set that tab as active and scroll to the top of the correct tab group
*/
if (isRenderTabs) {
setSelectedIndex(index);
tabGroupRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// On mobile (XS), scroll to the id anchor at the top of the exposed item content
else {
const element = document.getElementById(`#${uniquePrefix}${slugify(tabItems[index].label)}`);
element?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
}
}, [isRenderTabs, tabItems, uniquePrefix]);

return (
<div ref={tabGroupRef} className={styles.root} {...props} id={id}>
{/* For SM breakpoint and above, display tab group */}
<TabGroup vertical className={styles.tabGroup} selectedIndex={selectedIndex} onChange={handleTabChange}>
<TabList className={styles.tabList(isKeyboardUser)}>
{tabItems?.map((tabItem) => (
<Tab as={Fragment} key={tabItem._uid}>
{({ selected }) => (
<button className={styles.tabItem(isLightText)}>
{tabItem.label}
{selected && (
<m.div
className={styles.tabItemBar(isLightText)}
layoutId={tabGroupId}
/>
)}
</button>
)}
</Tab>
))}
</TabList>
<TabPanels className={styles.tabPanel(isLightText)}>
{tabItems?.map((tabItem) => (
<TabPanel key={tabItem._uid}>
<TabContent
label={tabItem.label}
heading={tabItem.heading}
featuredMedia={tabItem.featuredMedia}
body={tabItem.body}
otherContent={tabItem.otherContent}
headingSize={tabItem.headingSize}
isSerifHeading={isSerifHeading}
headingLevel={headingLevel || 'h3'}
isLightText={isLightText}
animation={animation}
/>
</TabPanel>
))}
</TabPanels>
</TabGroup>
{/* For mobile (XS only), display expanded list of all the tab item content */}
<Grid as="ul" gap="card" className={styles.mobileGrid}>
{tabItems.map((tabItem, index) => (
<li key={tabItem._uid} id={`#${uniquePrefix}${slugify(tabItems[index].label)}`} className={styles.li}>
<TabContent
label={tabItem.label}
heading={tabItem.heading}
featuredMedia={tabItem.featuredMedia}
body={tabItem.body}
otherContent={tabItem.otherContent}
headingSize={tabItem.headingSize}
isSerifHeading={isSerifHeading}
headingLevel={headingLevel || 'h3'}
isLightText={isLightText}
animation={animation}
/>
</li>
))}
</Grid>
</div>
);
};
2 changes: 2 additions & 0 deletions components/Tabs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './Tabs';
export * from './Tabs.styles';
Loading

0 comments on commit 8b869ac

Please sign in to comment.