Skip to content

Commit

Permalink
Added accordion and tabs components
Browse files Browse the repository at this point in the history
  • Loading branch information
pookmish committed Mar 25, 2024
1 parent 084dff1 commit b42676f
Show file tree
Hide file tree
Showing 8 changed files with 2,233 additions and 360 deletions.
48 changes: 48 additions & 0 deletions .storybook/stories/elements/Accordion.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type {Meta, StoryObj} from '@storybook/react';
import Accordion from "@components/elements/accordion";

// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
const meta: Meta<typeof Accordion> = {
title: 'Design/Elements/Accordion',
component: Accordion,
tags: ['autodocs'],
argTypes: {
button: {
control: "text"
},
onClick: {
table: {
disable: true,
}
},
buttonProps: {
table: {
disable: true,
}
},
panelProps: {
table: {
disable: true,
}
},
isVisible: {
table: {
disable: true,
}
}
}
};

export default meta;
type Story = StoryObj<typeof Accordion>;

// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args
export const AccordionElement: Story = {
render: ({onClick, ...args}) => {
return <Accordion {...args}/>
},
args: {
button: "Id arcu nec vel tempus rutrum.",
children: "Mi amet tempus congue erat fusce euismod eros cursus morbi amet amet diam tristique bibendum hendrerit sed commodo quisque cursus scelerisque morbi placerat tristique magna."
},
};
12 changes: 7 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@
},
"dependencies": {
"@formkit/auto-animate": "^0.8.1",
"@heroicons/react": "^2.1.1",
"@heroicons/react": "^2.1.3",
"@js-temporal/polyfill": "^0.4.4",
"@mui/base": "^5.0.0-beta.40",
"@next/third-parties": "^14.1.4",
"@tailwindcss/container-queries": "^0.1.1",
"@types/node": "^20.11.30",
"@types/react": "^18.2.67",
"@types/react": "^18.2.70",
"@types/react-dom": "^18.2.22",
"algoliasearch": "^4.22.1",
"autoprefixer": "^10.4.19",
Expand All @@ -35,21 +35,23 @@
"graphql-tag": "^2.12.6",
"html-entities": "^2.5.2",
"html-react-parser": "^5.1.9",
"next": "^14.2.0-canary.35",
"next": "^14.2.0-canary.42",
"next-drupal": "^1.6.0",
"postcss": "^8.4.38",
"qs": "^6.12.0",
"react": "^18.2.0",
"react-aria": "^3.32.1",
"react-dom": "^18.2.0",
"react-focus-lock": "^2.11.2",
"react-instantsearch": "^7.7.0",
"react-instantsearch-nextjs": "^0.1.14",
"react-stately": "^3.30.1",
"react-tiny-oembed": "^1.1.0",
"sharp": "^0.33.2",
"sharp": "^0.33.3",
"tailwind-merge": "^2.2.2",
"tailwindcss": "^3.4.1",
"typescript": "^5.4.3",
"usehooks-ts": "^3.0.1",
"usehooks-ts": "^3.0.2",
"zustand": "^4.5.2"
},
"devDependencies": {
Expand Down
85 changes: 85 additions & 0 deletions src/components/elements/accordion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"use client";

import {HTMLAttributes, JSX, useId} from "react";
import {useBoolean} from "usehooks-ts";
import {H2, H3, H4} from "@components/elements/headers";
import {ChevronDownIcon} from "@heroicons/react/20/solid";
import {clsx} from "clsx";
import {twMerge} from "tailwind-merge";

type Props = HTMLAttributes<HTMLElement> & {
/**
* Button clickable element or string.
*/
button: JSX.Element | string
/**
* Heading level element.
*/
headingLevel?: 'h2' | 'h3' | 'h4'
/**
* If the accordion should be visible on first render.
*/
initiallyVisible?: boolean
/**
* Button click event if the component is controlled.
*/
onClick?: () => void
/**
* Panel visibility state if the component is controlled.
*/
isVisible?: boolean
buttonProps?: HTMLAttributes<HTMLButtonElement>
panelProps?: HTMLAttributes<HTMLDivElement>
}

const Accordion = ({
button,
children,
headingLevel = 'h2',
onClick,
isVisible,
initiallyVisible = false,
buttonProps,
panelProps,
...props
}: Props) => {
const {value: expanded, toggle: toggleExpanded} = useBoolean(initiallyVisible)
const id = useId();

const onButtonClick = () => {
onClick ? onClick() : toggleExpanded()
}

// When the accordion is externally controlled.
const isExpanded = onClick ? isVisible : expanded;

const Heading = headingLevel === 'h2' ? H2 : headingLevel === 'h3' ? H3 : H4;
return (
<section aria-labelledby={`${id}-button`} {...props}>
<Heading>
<button
{...buttonProps}
className={twMerge("w-full items-center flex border-b border-transparent hocus:border-black-true", buttonProps?.className)}
id={`${id}-button`}
aria-expanded={isExpanded}
aria-controls={`${id}-panel`}
onClick={onButtonClick}
>
{button}
<ChevronDownIcon height={30} className={clsx("shrink-0 ml-auto duration-150", {"rotate-180": isExpanded})}/>
</button>
</Heading>

<div
{...panelProps}
id={`${id}-panel`}
className={twMerge(isExpanded ? "block" : "hidden", panelProps?.className)}
role="region"
aria-labelledby={`${id}-button`}
>
{children}
</div>
</section>
)
}
export default Accordion;
125 changes: 125 additions & 0 deletions src/components/elements/tabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"use client";

import {AriaTabListOptions, Key, useTab, useTabList, useTabPanel} from 'react-aria';
import {Item, TabListState, useTabListState} from 'react-stately';
import {JSX, useRef} from "react";
import {TabListStateOptions} from "@react-stately/tabs";
import {Node} from "@react-types/shared/src/collections";
import {AriaTabPanelProps} from "@react-types/tabs";
import {clsx} from "clsx";
import {useRouter, useSearchParams} from "next/navigation";
import {useIsClient, useWindowSize} from "usehooks-ts";

type Props = AriaTabListOptions<JSX.Element> & TabListStateOptions<JSX.Element> & {
paramId?: string
tabs: { id: string, title: string, contents: JSX.Element | string }[]
}

const Tabs = ({tabs, orientation = "horizontal", ...props}: Props) => {
if (!props['aria-label'] && !props['aria-labelledby']) console.warn('Tabs missing appropriate aria labelling');

const isClient = useIsClient();
const {width: windowWidth = 0} = useWindowSize({debounceDelay: 200})
const adjustedOrientation = windowWidth < 550 ? "vertical" : windowWidth >= 992 ? orientation : "horizontal"

if (isClient) return (
<TabsWrapper {...props} isDisabled={tabs.length === 1} orientation={adjustedOrientation}>
{tabs.map(tab =>
<Item key={`tab-${tab.id}`} title={tab.title}>
{tab.contents}
</Item>
)}
</TabsWrapper>
)

// Server rendering only prints the tabs. We also want to display at least the first tab panel on the server and
// allow the client to rehydrate.
return (
<div className={clsx("flex", {"flex-col gap-5": orientation !== "vertical"})}>
<div className={clsx("flex", {"flex-col w-1/3": orientation === "vertical"})}>
{tabs.map((item, i) => (
<div
key={item.id}
className={clsx('text-left p-5', {
'border-l-3': orientation === "vertical",
'border-b-3': orientation !== "vertical",
'border-cardinal-red': i === 0,
'border-transparent': i !== 0,
})}
>
{item.title}
</div>
))}
</div>
<div>
{tabs[0]?.contents}
</div>
</div>
)
}

const TabsWrapper = ({paramId = 'tab', ...props}: AriaTabListOptions<JSX.Element> & TabListStateOptions<JSX.Element> & {
paramId?: string
}) => {
const router = useRouter()
const searchParams = useSearchParams();
const defaultSelectedKey = paramId ? (searchParams.get(paramId) || undefined) : undefined

const onSelectionChange = (key: Key) => {
const params = new URLSearchParams(searchParams);
key === state.collection.getFirstKey() ? params.delete(paramId) : params.set(paramId, `${key}`)
router.replace(`?${params.toString()}`, {scroll: false})
}

let state = useTabListState({...props, onSelectionChange, defaultSelectedKey});
let ref = useRef<HTMLDivElement>(null);
let {tabListProps} = useTabList(props, state, ref);

return (
<div className={clsx("flex flex-col", {"lg:flex-row gap-5": props.orientation === "vertical"})}>
<div {...tabListProps} ref={ref} className={clsx("flex", {"flex-col w-1/3": props.orientation === "vertical"})}>
{[...state.collection].map(item => (
<Tab key={item.key} item={item} state={state} orientation={props.orientation}/>
))}
</div>
<TabPanel key={state.selectedItem?.key} state={state}/>
</div>
);
}


const Tab = ({item, state, orientation}: {
item: Node<JSX.Element>,
state: TabListState<JSX.Element>,
orientation?: AriaTabListOptions<JSX.Element>["orientation"]
}) => {
let {key, rendered} = item;
let ref = useRef<HTMLButtonElement>(null);
let {tabProps} = useTab({key}, state, ref);
const isActive = tabProps['aria-selected']
return (
<button
{...tabProps}
ref={ref}
className={clsx('text-left p-5', {
'border-l-3': orientation === "vertical",
'border-b-3': orientation !== "vertical",
'border-cardinal-red': isActive,
'border-transparent': !isActive
})}
>
{rendered}
</button>
);
}

const TabPanel = ({state, ...props}: { state: TabListState<JSX.Element> } & AriaTabPanelProps) => {
let ref = useRef<HTMLDivElement>(null);
let {tabPanelProps} = useTabPanel(props, state, ref);
return (
<div {...tabPanelProps} ref={ref}>
{state.selectedItem?.props.children}
</div>
);
}
export default Tabs;
3 changes: 1 addition & 2 deletions src/components/menu/main-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import useOutsideClick from "@lib/hooks/useOutsideClick";
import {ChevronDownIcon} from "@heroicons/react/20/solid";
import {MenuItem as MenuItemType} from "@lib/gql/__generated__/drupal.d";
import {clsx} from "clsx";
import {useBoolean, useEventListener, useIsClient} from "usehooks-ts";
import {useBoolean, useEventListener} from "usehooks-ts";
import {useCallback, useEffect, useId, useLayoutEffect, useRef, useState} from "react";
import {usePathname} from "next/navigation";

Expand Down Expand Up @@ -64,7 +64,6 @@ type MenuItemProps = MenuItemType & {
}

const MenuItem = ({id, url, title, activeTrail, children, level}: MenuItemProps) => {
const isClient = useIsClient();
const linkId = useId();
const menuItemRef = useRef<HTMLLIElement>(null);
const belowListRef = useRef<HTMLUListElement>(null);
Expand Down
8 changes: 4 additions & 4 deletions src/components/paragraphs/stanford-lists/list-paragraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ type Props = HtmlHTMLAttributes<HTMLDivElement> & {

const ListParagraph = async ({paragraph, ...props}: Props) => {
const behaviors = getParagraphBehaviors(paragraph);
const viewId = paragraph.suListView?.view || '';
const displayId = paragraph.suListView?.display || '';
const viewItems = await getViewItems(viewId, displayId, paragraph.suListView?.contextualFilter, paragraph.suListView?.pageSize);
const viewId = paragraph.suListView?.view;
const displayId = paragraph.suListView?.display;
const viewItems = viewId && displayId ? await getViewItems(viewId, displayId, paragraph.suListView?.contextualFilter, paragraph.suListView?.pageSize) : [];

if (behaviors.list_paragraph?.hide_empty && viewItems.length === 0) return null;

Expand All @@ -46,7 +46,7 @@ const ListParagraph = async ({paragraph, ...props}: Props) => {

<Wysiwyg html={paragraph.suListDescription?.processed}/>

{viewItems &&
{(viewId && displayId && viewItems) &&
<View
viewId={viewId}
displayId={displayId}
Expand Down
2 changes: 1 addition & 1 deletion src/components/patterns/hero-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const HeroBanner = ({imageUrl, imageAlt, eagerLoadImage, isSection, overlayPosit
return (
<BannerWrapper
{...props}
className={twMerge("@container md:min-h-[400px] rs-mb-5 flex items-center", props.className)}
className={twMerge("@container md:min-h-[400px] rs-mb-5", props.className)}
>
<div className="aspect-[16/9] @6xl:aspect-auto relative @6xl:absolute w-full @6xl:h-full bg-cool-grey">
{imageUrl &&
Expand Down
Loading

0 comments on commit b42676f

Please sign in to comment.