Skip to content

Commit 8b84eb5

Browse files
gabrielwallinoscarcarlstromalexanbj
authored
Accordion (#760)
* Add first version of accordion * Add stories to accordion * wip * fix selector * Update accordion to make the API nicer * Fix heading styling * fix stories * add rounded corners * update styling to match figma 99% * Fix aria props (and move pseudo element) * Add animation * fix classnames * Update ids * Fix comments * Add styling to content * refactor AccordionContent * Use pseudo element as border to prevent extra div * Eliminate extra div in accordion * Removed redundant classes * accordion with context props * add support for both controlled and uncontrolled * update stories * update stories * cleanup * add aria-hidden to separator * export Accordion * immedeiate return * use pseudo element for content height instead of padding doesnt affect the height of the element * use cx * use pseudo element for content wrapper * changeset * Use same transition duartion on all transitions --------- Co-authored-by: Oscar Carlström <oscar.carlstrom@gmail.com> Co-authored-by: Alexander Bjerkan <alexander.bjerkan@obos.no>
1 parent 46c7181 commit 8b84eb5

File tree

6 files changed

+372
-6
lines changed

6 files changed

+372
-6
lines changed

.changeset/cyan-rings-dance.md

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@obosbbl/grunnmuren-react": minor
3+
---
4+
5+
Add Accordion and AccordionItem components. Use as follows:
6+
7+
```jsx
8+
<Accordion>
9+
<AccordionItem>
10+
<Heading>Item 1</Heading>
11+
<Content>Item 1</Content>
12+
</AccordionItem>
13+
<AccordionItem>
14+
<Heading>Item 2</Heading>
15+
<Content>Item 2</Content>
16+
</AccordionItem>
17+
</Accordion>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { useReducer } from 'react';
2+
import { StoryObj, Meta } from '@storybook/react';
3+
import { Accordion, AccordionItem, AccordionItemProps } from './Accordion';
4+
import { Content, Heading } from '..';
5+
6+
const Template = (args: AccordionItemProps) => {
7+
return (
8+
<Accordion>
9+
<AccordionItem
10+
onOpenChange={args.onOpenChange}
11+
defaultOpen={args.defaultOpen}
12+
>
13+
<Heading level={2}>Hvordan betaler jeg ned på rammelånet?</Heading>
14+
<Content className="prose">
15+
Den største forskjellen mellom et vanlig boliglån og et rammelån er
16+
fleksibiliteten. Med et rammelån kan du velge å betale mindre ned på
17+
lånet i trangere tider, hvis du for eksempel i en periode opplever å
18+
få andre uforutsette utgifter. Med et rammelån kan det friste å bruke
19+
mer penger enn det du egentlig har behov for. Derfor er det viktig at
20+
du har god økonomisk disiplin, og vi anbefaler alltid å sette opp et
21+
fast månedlig trekk som minimum dekker dekker rentene. Du kan når som
22+
helst betale ned på rammelånet ditt. Det gjør du enkelt i mobilbanken
23+
eller nettbanken ved å overføre beløpet du ønsker fra en konto og inn
24+
til rammelånet.
25+
</Content>
26+
</AccordionItem>
27+
<AccordionItem
28+
onOpenChange={args.onOpenChange}
29+
defaultOpen={args.defaultOpen}
30+
>
31+
<Heading level={2}>Bør jeg velge rammelån eller boliglån?</Heading>
32+
<Content className="prose">
33+
<p>
34+
Om du bør velge rammelån eller boliglån avhenger av den økonomiske
35+
situasjonen din, preferansene dine og hvor mye fleksibilitet du
36+
ønsker knyttet til nedbetaling av lånet.
37+
</p>
38+
<p>
39+
De viktigste forskjellene på rammelån og boliglån som du bør være
40+
klar over før du tar et valg:
41+
</p>
42+
<ul>
43+
<li>
44+
Fleksibilitet: Rammelån gir deg større fleksibilitet til å låne og
45+
betale tilbake penger etter behov, mens et vanlig boliglån har en
46+
fast nedbetalingsplan.
47+
</li>
48+
<li>
49+
Nedbetaling av lånet: Et vanlig boliglån har en fastsatt
50+
nedbetalingsplan, mens rammelånet gir deg mer frihet til å velge
51+
når og hvor mye du ønsker å betale tilbake.
52+
</li>
53+
<li>
54+
Beregning av renter: Med et rammelån betaler du renter bare på det
55+
beløpet du faktisk har brukt, mens med et vanlig boliglån baserer
56+
renter seg på hele lånebeløpet.
57+
</li>
58+
</ul>
59+
</Content>
60+
</AccordionItem>
61+
<AccordionItem
62+
onOpenChange={args.onOpenChange}
63+
defaultOpen={args.defaultOpen}
64+
>
65+
<Heading level={2}>Overfør penger fra Boligspar Ung?</Heading>
66+
<Content className="prose">
67+
<p>
68+
Ønsker du å overføre penger fra Boligspar Ung til en av dine andre
69+
kontoer, er det en enkel sak. <a href="#">Logg inn i nettbanken</a>{' '}
70+
og velg &quot;Uttak Boligspar&quot; Ung i menyen. Slik som BSU, kan
71+
du ta ut det du har spart inneværende år. Om du vil ta ut mer vil
72+
kontoen avsluttes.
73+
</p>
74+
</Content>
75+
</AccordionItem>
76+
</Accordion>
77+
);
78+
};
79+
80+
function controlledReducer(state: boolean[], indexToFlip: number) {
81+
state[indexToFlip] = !state[indexToFlip];
82+
return [...state];
83+
}
84+
const ControlledTemplate = () => {
85+
const [state, dispatch] = useReducer(controlledReducer, [
86+
false,
87+
false,
88+
false,
89+
]);
90+
91+
return (
92+
<Accordion>
93+
{state.map((isOpen, index) => {
94+
const accordionNumber = index + 1;
95+
return (
96+
<AccordionItem
97+
isOpen={isOpen}
98+
key={index}
99+
onOpenChange={() => dispatch(index)}
100+
>
101+
<Heading level={2}>Heading {accordionNumber}</Heading>
102+
<Content>Item {accordionNumber}</Content>
103+
</AccordionItem>
104+
);
105+
})}
106+
</Accordion>
107+
);
108+
};
109+
110+
const meta: Meta<typeof AccordionItem> = {
111+
title: 'Accordion',
112+
component: AccordionItem,
113+
argTypes: {
114+
onOpenChange: { action: 'open change' },
115+
},
116+
};
117+
118+
export default meta;
119+
120+
type Story = StoryObj<typeof Accordion>;
121+
122+
const defaultProps: AccordionItemProps = {
123+
defaultOpen: false,
124+
};
125+
126+
export const Default: Story = {
127+
render: Template,
128+
args: {
129+
...defaultProps,
130+
},
131+
};
132+
133+
export const Controlled = {
134+
render: ControlledTemplate,
135+
};
+172
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import React, { Children, useState, forwardRef, type Ref, useId } from 'react';
2+
import { Provider } from 'react-aria-components';
3+
import { cx } from 'cva';
4+
import { ChevronDown } from '@obosbbl/grunnmuren-icons-react';
5+
6+
import { useClientLayoutEffect } from '../utils/useClientLayoutEffect';
7+
import { HeadingContext, ContentContext } from '../content';
8+
9+
type AccordionProps = {
10+
children: React.ReactNode;
11+
12+
/** Additional CSS className for the element. */
13+
className?: string;
14+
15+
/** Additional style properties for the element. */
16+
style?: React.CSSProperties;
17+
};
18+
19+
type AccordionItemProps = {
20+
children?: React.ReactNode;
21+
22+
/** Additional CSS className for the element. */
23+
className?: string;
24+
25+
/** Additional style properties for the element. */
26+
style?: React.CSSProperties;
27+
28+
/** Whether the accordion is open (controlled) */
29+
isOpen?: boolean;
30+
/** Whether the accordion is open by default (uncontrolled) */
31+
defaultOpen?: boolean;
32+
/** Handler that is called when the accordion's open state changes */
33+
onOpenChange?: (isOpen: boolean) => void;
34+
};
35+
36+
function Accordion(props: AccordionProps, ref: Ref<HTMLDivElement>) {
37+
const { children, ...restProps } = props;
38+
39+
const childCount = Children.count(children);
40+
41+
return (
42+
<div {...restProps} ref={ref}>
43+
{Children.map(children, (child, index) => (
44+
<>
45+
{child}
46+
{index < childCount - 1 && (
47+
<hr className="border-gray-light" aria-hidden />
48+
)}
49+
</>
50+
))}
51+
</div>
52+
);
53+
}
54+
55+
function AccordionItem(props: AccordionItemProps, ref: Ref<HTMLDivElement>) {
56+
const {
57+
className,
58+
children,
59+
defaultOpen = false,
60+
isOpen: controlledIsOpen,
61+
onOpenChange,
62+
...restProps
63+
} = props;
64+
65+
const contentId = useId();
66+
const buttonId = useId();
67+
68+
const isControlled = controlledIsOpen != null;
69+
70+
// This component has internal state that controls whether it is open or not,
71+
// regardless if we are controlled or uncontrolled.
72+
// If we are controlled, we use a layout effect to sync the controlled state
73+
// with the internal state.
74+
//
75+
const [isOpen, setIsOpen] = useState(
76+
// If we are controlled, use that open state, otherwise use the uncontrolled
77+
isControlled ? controlledIsOpen : defaultOpen,
78+
);
79+
80+
useClientLayoutEffect(() => {
81+
if (isControlled) {
82+
setIsOpen(controlledIsOpen);
83+
}
84+
}, [controlledIsOpen, isControlled]);
85+
86+
const handleOpenChange = () => {
87+
const newOpenState = !isOpen;
88+
89+
if (!isControlled) {
90+
setIsOpen(newOpenState);
91+
}
92+
93+
// Always call the change handler, even if we're uncontrolled.
94+
// Easier to add stuff such as tracking etc.
95+
if (onOpenChange) {
96+
onOpenChange(newOpenState);
97+
}
98+
};
99+
100+
return (
101+
<div
102+
{...restProps}
103+
className={cx('group relative', className)}
104+
ref={ref}
105+
data-open={isOpen}
106+
>
107+
<Provider
108+
values={[
109+
[
110+
HeadingContext,
111+
{
112+
className: 'font-semibold leading-7',
113+
// Supply a default level here to make this typecheck ok. Will be overwritten with the consumers set heading level anyways
114+
level: 3,
115+
_innerWrapper: (children) => (
116+
<button
117+
aria-controls={contentId}
118+
aria-expanded={isOpen}
119+
// the z-index is necessary for the focus ring to be drawn above the left border of the content
120+
className="relative z-10 flex min-h-[44px] w-full items-center justify-between gap-1.5 rounded-sm py-3.5 text-left focus:outline-none focus-visible:ring focus-visible:ring-black"
121+
id={buttonId}
122+
onClick={handleOpenChange}
123+
>
124+
{children}
125+
<ChevronDown
126+
className={cx(
127+
'transition-transform duration-300 motion-reduce:transition-none',
128+
isOpen && 'rotate-180',
129+
)}
130+
/>
131+
</button>
132+
),
133+
},
134+
],
135+
[
136+
ContentContext,
137+
{
138+
className:
139+
// Uses pseudo element for vertical padding, since that doesn't affect the height when the accordion is closed
140+
'text-sm font-light leading-6 px-3.5 relative overflow-hidden border-mint border-l-[3px] before:relative before:block before:h-1.5 after:relative after:block after:h-1.5',
141+
role: 'region',
142+
// @ts-expect-error TODO: remove this expect-error when we're on React 19 https://github.com/facebook/react/issues/17157#issuecomment-2003750544
143+
inert: isOpen ? undefined : 'true',
144+
'aria-labelledby': buttonId,
145+
_outerWrapper: (children) => (
146+
<div
147+
className={cx(
148+
'grid transition-all duration-300 after:relative after:block after:h-0 after:transition-all after:duration-300 motion-reduce:transition-none',
149+
isOpen ? 'grid-rows-[1fr] after:h-3.5' : 'grid-rows-[0fr] ',
150+
)}
151+
>
152+
{children}
153+
</div>
154+
),
155+
},
156+
],
157+
]}
158+
>
159+
{children}
160+
</Provider>
161+
</div>
162+
);
163+
}
164+
165+
const _Accordion = forwardRef(Accordion);
166+
const _AccordionItem = forwardRef(AccordionItem);
167+
export {
168+
_Accordion as Accordion,
169+
_AccordionItem as AccordionItem,
170+
type AccordionProps,
171+
type AccordionItemProps,
172+
};

packages/react/src/accordion/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './Accordion';

0 commit comments

Comments
 (0)