Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
18.17
18.18
12 changes: 2 additions & 10 deletions .storybook/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,12 @@ const StylelintWebpackPlugin = require('stylelint-webpack-plugin');
const YAML = require('yaml');

module.exports = {
staticDirs: [path.resolve(__dirname, '../public')],
staticDirs: ['../public'],
stories: ['../source/**/*.stories.@(js|jsx|ts|tsx)', '../source/**/*.mdx'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
{
name: '@storybook/addon-styling',
options: {
postcssLoaderOptions: {
implementation: require('postcss'),
},
},
},
'@storybook/addon-a11y',
],
framework: {
Expand All @@ -26,7 +18,7 @@ module.exports = {
webpackFinal: async config => {
config.plugins.push(
new StylelintWebpackPlugin({
exclude: ['node_modules', 'storybook'],
exclude: ['node_modules', 'storybook', '.next'],
}),
);
config.module.rules.find(
Expand Down
2 changes: 1 addition & 1 deletion .storybook/manager.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { addons } from '@storybook/addons';
import { addons } from '@storybook/manager-api';
import theme from './theme';

addons.setConfig({
Expand Down
1 change: 1 addition & 0 deletions .storybook/theme.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const storybookTheme = create({
appContentBg: '#fff',
barBg: '#19013A',
barSelectedColor: '#EE2737',
barHoverColor: '#EE2737',
barTextColor: '#fff',
base: 'light',
brandTitle: 'Forum One',
Expand Down
3,638 changes: 2,070 additions & 1,568 deletions package-lock.json

Large diffs are not rendered by default.

27 changes: 14 additions & 13 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,17 @@
"react-select": "^5.7.2"
},
"devDependencies": {
"@babel/core": "^7.20.2",
"@babel/core": "^7.23.2",
"@csstools/postcss-global-data": "^1.0.1",
"@storybook/addon-a11y": "^7.1.0",
"@storybook/addon-actions": "^7.1.0",
"@storybook/addon-essentials": "^7.1.0",
"@storybook/addon-interactions": "^7.1.0",
"@storybook/addon-links": "^7.1.0",
"@storybook/addon-styling": "^1.3.4",
"@storybook/nextjs": "^7.1.0",
"@storybook/react": "^7.1.0",
"@storybook/testing-library": "^0.2.0",
"@storybook/theming": "^7.1.0",
"@storybook/addon-a11y": "^7.5.1",
"@storybook/addon-actions": "^7.5.1",
"@storybook/addon-essentials": "^7.5.1",
"@storybook/addon-interactions": "^7.5.1",
"@storybook/addon-links": "^7.5.1",
"@storybook/nextjs": "^7.5.1",
"@storybook/react": "^7.5.1",
"@storybook/testing-library": "^0.2.2",
"@storybook/theming": "^7.5.1",
"@svgr/babel-plugin-add-jsx-attribute": "^8.0.0",
"@svgr/babel-plugin-remove-jsx-attribute": "^8.0.0",
"@svgr/cli": "^8.0.1",
Expand All @@ -51,6 +50,7 @@
"@typescript-eslint/parser": "^5.20.0",
"babel-loader": "^8.3.0",
"clsx": "^1.2.1",
"css-loader": "^6.8.1",
"enhanced-resolve": "5.10.0",
"eslint": "^8.13.0",
"eslint-config-next": "^13.2.3",
Expand All @@ -62,13 +62,14 @@
"mkdirp": "^1.0.4",
"postcss": "^8.4.21",
"postcss-advanced-variables": "github:kmonahan/postcss-advanced-variables",
"postcss-loader": "^7.0.2",
"postcss-loader": "^7.3.3",
"postcss-nesting": "^11.1.0",
"postcss-preset-env": "^8.0.1",
"postcss-rem": "^2.0.2",
"prettier": "^2.6.2",
"prettier-plugin-organize-imports": "^2.3.4",
"storybook": "^7.1.0",
"storybook": "^7.5.1",
"style-loader": "^3.3.3",
"stylelint": "^15.5.0",
"stylelint-config-standard": "^33.0.0",
"stylelint-order": "^6.0.3",
Expand Down
17 changes: 16 additions & 1 deletion source/00-config/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
const MAIN_ID = 'main';

export { MAIN_ID };
const KEYCODE = {
TAB: 'Tab',
RETURN: 'Enter',
ESC: 'Escape',
SPACE: 'Space',
PAGEUP: 'PageUp',
PAGEDOWN: 'PageDown',
END: 'End',
HOME: 'Home',
LEFT: 'ArrowLeft',
UP: 'ArrowUp',
RIGHT: 'ArrowRight',
DOWN: 'ArrowDown',
};

export { MAIN_ID, KEYCODE };
36 changes: 36 additions & 0 deletions source/03-components/Accordion/Accordion.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Meta, StoryObj } from '@storybook/react';
import parse from 'html-react-parser';
import AccordionComponent from './Accordion';
import accordionArgs from './accordion.yml';
import { AccordionItemProps } from './AccordionItem';

const meta: Meta<typeof AccordionComponent> = {
title: 'Components/Accordion',
component: AccordionComponent,
tags: ['autodocs'],
};

type Story = StoryObj<typeof AccordionComponent>;

accordionArgs.accordionItems = accordionArgs.accordionItems.map(
(
item:
| (Omit<AccordionItemProps, 'content'> & { content: string })
| AccordionItemProps,
) => {
if (typeof item.content === 'string') {
item.content = parse(item.content) as string;
}
return item;
},
);

const Accordion: Story = {
render: args => <AccordionComponent {...args} />,
args: {
...accordionArgs,
},
};

export default meta;
export { Accordion };
148 changes: 148 additions & 0 deletions source/03-components/Accordion/Accordion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import clsx from 'clsx';
import { GessoComponent } from 'gesso';
import { KeyboardEvent, createRef, useId, useMemo, useState } from 'react';
import styles from './accordion.module.css';
import getCssVar from '../../06-utility/getCssVar';
import { KEYCODE } from '../../00-config/constants';
import AccordionItem, { AccordionItemProps } from './AccordionItem';

interface AccordionProps extends GessoComponent {
accordionItems: AccordionItemProps[];
accordionSpeed?: string;
allowMultiple?: boolean;
allowToggle?: boolean;
}

function Accordion({
accordionItems,
accordionSpeed = getCssVar('duration-standard'),
allowMultiple,
allowToggle,
modifierClasses,
}: AccordionProps): JSX.Element {
const accordionId = useId();
const [accordionItemsStatus, setAccordionItemsStatus] = useState(
accordionItems.map((item, index) => ({
...item,
id: `${accordionId}-${index}`,
})),
);
const accordionItemRefs = useMemo(() => {
const refs: { [key: string]: React.RefObject<HTMLButtonElement> } = {};
accordionItemsStatus.forEach(item => (refs[item.id] = createRef()));
return refs;
}, [accordionItemsStatus]);

const openAccordionItem = (items: AccordionItemProps[], index: number) => {
return [
...items.slice(0, index),
{
...items[index],
isOpen: true,
},
...items.slice(index + 1),
];
};

const closeAccordionItem = (items: AccordionItemProps[], index: number) => {
return [
...items.slice(0, index),
{
...items[index],
isOpen: false,
},
...items.slice(index + 1),
];
};

const handleClick = (id: string, isOpen = false) => {
const toggleAllowed = allowMultiple ? true : allowToggle;
const active = accordionItemsStatus.findIndex(item => item.isOpen);
const itemIndex = accordionItemsStatus.findIndex(item => item.id === id);
let itemStatusUpdated = [...accordionItemsStatus];

// Without allowMultiple, close the open accordion
if (!allowMultiple && active !== -1 && active !== itemIndex) {
itemStatusUpdated = closeAccordionItem(itemStatusUpdated, active);
}

if (!isOpen) {
itemStatusUpdated = openAccordionItem(itemStatusUpdated, itemIndex);
} else if (toggleAllowed && isOpen) {
itemStatusUpdated = closeAccordionItem(itemStatusUpdated, itemIndex);
}

return setAccordionItemsStatus(itemStatusUpdated);
};

const handleKeydown = (event: KeyboardEvent) => {
const currentTarget = event.target as HTMLButtonElement;

// Create the array of toggle elements for the accordion group
const triggers = Object.values(accordionItemRefs).map(ref => ref.current);

// Is this coming from an accordion header?
if (triggers && currentTarget.tagName === 'BUTTON') {
// Up/ Down arrow and Control + Page Up/ Page Down keyboard operations
if (
event.code === KEYCODE.UP ||
event.code === KEYCODE.DOWN ||
event.code === KEYCODE.PAGEUP ||
event.code === KEYCODE.PAGEDOWN
) {
const index = triggers.indexOf(currentTarget);
let direction;
if (event.code === KEYCODE.DOWN || event.code === KEYCODE.PAGEDOWN) {
direction = 1;
} else {
direction = -1;
}
const triggerLength = triggers.length;
const newIndex = (index + triggerLength + direction) % triggerLength;
triggers[newIndex]?.focus();
event.preventDefault();
} else if (event.code === KEYCODE.HOME || event.code === KEYCODE.END) {
switch (event.code) {
// Go to first accordion
case KEYCODE.HOME:
triggers[0]?.focus();
break;
// Go to last accordion
case KEYCODE.END:
triggers[triggers.length - 1]?.focus();
break;
default:
triggers[0]?.focus();
break;
}
event.preventDefault();
}
}
};

return (
<>
<div
className={clsx(styles.accordion, modifierClasses)}
id={accordionId}
onKeyDown={handleKeydown}
>
<div className={styles.content}>
{accordionItemsStatus.map(item => {
return (
<AccordionItem
key={item.id}
{...item}
accordionSpeed={accordionSpeed}
toggleRef={accordionItemRefs[item.id]}
handleClick={() => handleClick(item.id, item.isOpen)}
/>
);
})}
</div>
</div>
</>
);
}

export default Accordion;
78 changes: 78 additions & 0 deletions source/03-components/Accordion/AccordionItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import clsx from 'clsx';
import { GessoComponent } from 'gesso';
import {
ElementType,
MouseEventHandler,
ReactElement,
useEffect,
useRef,
} from 'react';
import styles from './accordion-item.module.css';
import { slideCollapse, slideExpand } from '../../06-utility/slide';

export interface AccordionItemProps extends GessoComponent {
id: string;
title: string;
content: ReactElement;
titleElement?: ElementType;
isOpen?: boolean;
accordionSpeed?: string;
toggleRef?: React.RefObject<HTMLButtonElement>;
handleClick: MouseEventHandler;
}

function AccordionItem({
id,
title,
content,
titleElement: TitleElement = 'h3',
isOpen,
accordionSpeed,
toggleRef,
modifierClasses,
handleClick,
}: AccordionItemProps): JSX.Element {
const accordionItemSectionRef = useRef(null);

const sectionId = `accordion-section-${id}`;
const buttonId = `accordion-button-${id}`;

useEffect(() => {
if (isOpen && accordionItemSectionRef.current) {
slideExpand(accordionItemSectionRef.current, accordionSpeed);
} else if (!isOpen && accordionItemSectionRef.current) {
slideCollapse(accordionItemSectionRef.current, accordionSpeed);
}
}, [isOpen, accordionSpeed]);

return (
<div className={clsx(styles.accordionItem, modifierClasses)}>
<div className={styles.panel}>
<TitleElement className={styles.heading}>
<button
className={styles.toggle}
id={buttonId}
aria-expanded={isOpen}
aria-controls={sectionId}
ref={toggleRef}
onClick={handleClick}
>
{title}
<span className={styles.icon}></span>
</button>
</TitleElement>
<div
ref={accordionItemSectionRef}
className={styles.drawer}
id={sectionId}
aria-labelledby={buttonId}
aria-expanded={isOpen}
>
<div className={styles.drawerInner}>{content}</div>
</div>
</div>
</div>
);
}

export default AccordionItem;
Loading