diff --git a/.husky/pre-commit b/.husky/pre-commit old mode 100644 new mode 100755 diff --git a/src/components/Accordion/Accordion.module.css b/src/components/Accordion/Accordion.module.css new file mode 100644 index 00000000..2a10a2e8 --- /dev/null +++ b/src/components/Accordion/Accordion.module.css @@ -0,0 +1,6 @@ +.accordion { + --component-accordion-color-background: var(--colors-neutral-000); + --component-panel-size-width: 100%; + background-color: var(--component-accordion-color-background); + width: var(--component-panel-size-width); +} diff --git a/src/components/Accordion/Accordion.stories.module.css b/src/components/Accordion/Accordion.stories.module.css new file mode 100644 index 00000000..bfd85744 --- /dev/null +++ b/src/components/Accordion/Accordion.stories.module.css @@ -0,0 +1,3 @@ +.container { + width: 80vw; +} diff --git a/src/components/Accordion/Accordion.stories.tsx b/src/components/Accordion/Accordion.stories.tsx new file mode 100644 index 00000000..77c77ef6 --- /dev/null +++ b/src/components/Accordion/Accordion.stories.tsx @@ -0,0 +1,87 @@ +import React, { useState } from 'react'; +import type { ComponentStory, ComponentMeta } from '@storybook/react'; +import { config } from 'storybook-addon-designs'; +import cn from 'classnames'; + +import { StoryPage } from '@sb/StoryPage'; + +import { Button } from '../Button'; + +import { Accordion } from './Accordion'; +import { AccordionHeader } from './AccordionHeader'; +import { AccordionContent } from './AccordionContent'; +import classes from './Accordion.stories.module.css'; + +const figmaLink = ''; // TODO: Add figma link + +export default { + title: `Components/Accordion`, + component: Accordion, + parameters: { + design: config([ + { + type: 'figma', + url: figmaLink, + }, + { + type: 'link', + url: figmaLink, + }, + ]), + docs: { + page: () => ( + + ), + }, + }, +} as ComponentMeta; + +const Template: ComponentStory = () => { + const [open1, setOpen1] = useState(false); + const [open2, setOpen2] = useState(false); + + const handleClick1 = () => { + setOpen1(!open1); + }; + + const handleClick2 = () => { + setOpen2(!open2); + }; + + const AccordionExampleContent = + 'Accordion-innhold uten css for å tilrettelegge for selvalgt styling'; + + const ActionButton = ; + return ( +
+ + Accordion 1 + {AccordionExampleContent} + + + Accordion 2 + {AccordionExampleContent} + +
+ ); +}; + +export const Example = Template.bind({}); +Example.args = { + // TODO: Add story specific args +}; +Example.parameters = { + docs: { + description: { + story: '', // TODO: add story description, supports markdown + }, + }, +}; diff --git a/src/components/Accordion/Accordion.test.tsx b/src/components/Accordion/Accordion.test.tsx new file mode 100644 index 00000000..a15f9c18 --- /dev/null +++ b/src/components/Accordion/Accordion.test.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { render as renderRtl, screen } from '@testing-library/react'; + +import type { AccordionProps } from './Accordion'; +import { Accordion } from './Accordion'; +import { AccordionHeader } from './AccordionHeader'; +import { AccordionContent } from './AccordionContent'; + +const render = (props: Partial = {}) => { + const allProps = { + children: ( + <> + AccordionHeader + AccordionContent + + ), + onClick: jest.fn(), + open: true, + ...props, + }; + renderRtl(); +}; + +const user = userEvent.setup(); + +describe('Accordion', () => { + it('should call handleClick when AccordionHeader is clicked', async () => { + const handleClick = jest.fn(); + render({ onClick: handleClick }); + + await user.click(screen.getByRole('button', { name: 'AccordionHeader' })); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('should have aria-expanded=true when open=true', () => { + render({ open: true }); + expect( + screen.getByRole('button', { name: 'AccordionHeader', expanded: true }), + ).toBeInTheDocument(); + }); + + it('should have aria-expanded=false when open=false', () => { + render({ open: false }); + + expect( + screen.getByRole('button', { name: 'AccordionHeader', expanded: false }), + ).toBeInTheDocument(); + }); + + it('should call handleClick when AccordionHeader is clicked using key press Space', async () => { + const handleClick = jest.fn(); + render({ onClick: handleClick }); + + const accordionHeader = screen.getByRole('button', { + name: 'AccordionHeader', + }); + await user.type(accordionHeader, '{Space}'); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('should call handleClick when AccordionHeader is clicked using key press Enter', async () => { + const handleClick = jest.fn(); + render({ onClick: handleClick }); + + await user.keyboard('{Tab}'); + await user.keyboard('{Enter}'); + expect(handleClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/Accordion/Accordion.tsx b/src/components/Accordion/Accordion.tsx new file mode 100644 index 00000000..32d4d616 --- /dev/null +++ b/src/components/Accordion/Accordion.tsx @@ -0,0 +1,32 @@ +import React, { useId } from 'react'; + +import type { ClickHandler } from './Context'; +import { AccordionContext } from './Context'; +import classes from './Accordion.module.css'; + +export interface AccordionProps { + children?: React.ReactNode; + onClick: ClickHandler; + open: boolean; +} + +export const Accordion = ({ children, open, onClick }: AccordionProps) => { + const headerId = useId(); + const contentId = useId(); + return ( +
+ + {children} + +
+ ); +}; + +export default Accordion; diff --git a/src/components/Accordion/AccordionContent.tsx b/src/components/Accordion/AccordionContent.tsx new file mode 100644 index 00000000..7726f601 --- /dev/null +++ b/src/components/Accordion/AccordionContent.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import { useAccordionContext } from './Context'; + +export interface AccordionContentProps { + children?: React.ReactNode; +} + +export const AccordionContent = ({ children }: AccordionContentProps) => { + const { open, contentId, headerId } = useAccordionContext(); + + return ( +
+ {open && ( +
+ {children} +
+ )} +
+ ); +}; + +export default AccordionContent; diff --git a/src/components/Accordion/AccordionHeader.module.css b/src/components/Accordion/AccordionHeader.module.css new file mode 100644 index 00000000..4797370f --- /dev/null +++ b/src/components/Accordion/AccordionHeader.module.css @@ -0,0 +1,55 @@ +.accordion-header { + --component-accordion_header-border_top_style: solid; + --component-accordion_header-border_top_color: var(--colors-neutral-200); + --component-accordion_header-border_top_width: var(--border-width-thin); + --component-accordion_header-color-background: var(--colors-neutral-000); + display: flex; + border-top-width: var(--component-accordion_header-border_top_width); + border-top-style: var(--component-accordion_header-border_top_style); + border-top-color: var(--component-accordion_header-border_top_color); + background-color: var(--component-accordion_header-color-background); +} + +.accordion-header__title { + --component-accordion_header_title-spacing-margin_left: 2.5rem; + --component-accordion_header_title-border_top_style: none; + --component-accordion_header_title-border_bottom_style: none; + --component-accordion_header_title-border_right_style: none; + --component-accordion_header_title-border_left_style: none; + --component-accordion_header_title-font_size: var(--font-size-300); + --component-accordion_header_title-font_weight: var( + --component-panel-weight-heading + ); + --component-accordion_header_title-color-background: none; + font-family: inherit; + flex: 1 1 auto; + border-top-style: var(--component-accordion_header_title-border_top_style); + border-bottom-style: var( + --component-accordion_header_title-border_bottom_style + ); + border-left-style: var(--component-accordion_header_title-border_left_style); + border-right-style: var( + --component-accordion_header_title-border_right_style + ); + background-color: var(--component-accordion_header_title-color-background); + text-align: var(--component-accordion_header_title-text-align); + margin-left: var(--component-accordion_header_title-margin-left); + font-size: var(--component-accordion_header_title-font_size); + font-weight: var(--component-accordion_header_title-font_weight); + margin-left: var(--component-accordion_header_title-spacing-margin_left); +} + +.accordion-header__actions { + margin-top: 0.3rem; +} + +.accordion-header__icon { + padding-top: 1rem; + margin-left: 2.5rem; +} + +.accordion-header__icon--opened { + transform: rotate(90deg); + margin-top: 0.3rem; + margin-left: 3rem; +} diff --git a/src/components/Accordion/AccordionHeader.tsx b/src/components/Accordion/AccordionHeader.tsx new file mode 100644 index 00000000..818b46b4 --- /dev/null +++ b/src/components/Accordion/AccordionHeader.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import cn from 'classnames'; + +import classes from './AccordionHeader.module.css'; +import { useAccordionContext } from './Context'; +import { ReactComponent as ExpandCollapseArrow } from './expand-collapse.svg'; + +export interface AccordionHeaderProps { + children?: React.ReactNode; + actions?: React.ReactNode; +} + +export const AccordionHeader = ({ + children, + actions, +}: AccordionHeaderProps) => { + const { onClick, open, headerId, contentId } = useAccordionContext(); + + return ( +
+ + +
{actions}
+
+ ); +}; + +export default AccordionHeader; diff --git a/src/components/Accordion/Context.ts b/src/components/Accordion/Context.ts new file mode 100644 index 00000000..a110bd4a --- /dev/null +++ b/src/components/Accordion/Context.ts @@ -0,0 +1,24 @@ +import { createContext, useContext } from 'react'; + +export type ClickHandler = () => void; + +export const AccordionContext = createContext< + | { + open: boolean; + onClick: ClickHandler; + headerId: string; + contentId: string; + } + | undefined +>(undefined); + +export const useAccordionContext = () => { + const context = useContext(AccordionContext); + if (context === undefined) { + throw new Error( + 'useAccordionContext must be used within a AccordionContext', + ); + } + + return context; +}; diff --git a/src/components/Accordion/expand-collapse.svg b/src/components/Accordion/expand-collapse.svg new file mode 100644 index 00000000..58625dc5 --- /dev/null +++ b/src/components/Accordion/expand-collapse.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/Accordion/index.ts b/src/components/Accordion/index.ts new file mode 100644 index 00000000..d31f202e --- /dev/null +++ b/src/components/Accordion/index.ts @@ -0,0 +1,3 @@ +export { Accordion } from './Accordion'; +export { AccordionHeader } from './AccordionHeader'; +export { AccordionContent } from './AccordionContent'; diff --git a/src/components/index.ts b/src/components/index.ts index 0d7ed298..b27a060e 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -2,4 +2,5 @@ export { Panel, PanelVariant } from './Panel'; export { CircularProgress } from './CircularProgress'; export { AppWrapper } from './AppWrapper'; export { ToggleButton, ToggleButtonGroup } from './ToggleButtonGroup'; +export { Accordion, AccordionHeader, AccordionContent } from './Accordion'; export { Button, ButtonVariant } from './Button';