Skip to content

Commit 3abb78a

Browse files
GoldGroove06Arshpreet Singh
andauthored
Accordion controlled uncontrolled (#1045)
Co-authored-by: Arshpreet Singh <73437174+GoldGroove06@users.noreply.github.com.>
1 parent 4d24b93 commit 3abb78a

File tree

7 files changed

+112
-25
lines changed

7 files changed

+112
-25
lines changed

src/components/ui/Accordion/fragments/AccordionRoot.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import React, { useState, useRef } from 'react';
33
import { clsx } from 'clsx';
44
import { customClassSwitcher } from '~/core';
55
import { AccordionContext } from '../contexts/AccordionContext';
6-
6+
import useControllableState from '~/core/hooks/useControllableState';
77
import RovingFocusGroup from '~/core/utils/RovingFocusGroup';
88
import Primitive from '~/core/primitives/Primitive';
99

@@ -19,13 +19,27 @@ export type AccordionRootProps = {
1919
loop?: boolean;
2020
disableTabIndexing?: boolean;
2121
openMultiple?: boolean;
22+
value?: (number | string)[];
23+
defaultValue?: (number | string)[];
24+
onValueChange?: (value: (number | string)[]) => void;
2225
}
2326

24-
const AccordionRoot = ({ children, orientation = 'vertical', disableTabIndexing = true, asChild, transitionDuration = 0, transitionTimingFunction = 'linear', customRootClass, loop = true, openMultiple = false }: AccordionRootProps) => {
27+
const AccordionRoot = ({ children, orientation = 'vertical', disableTabIndexing = true, asChild, transitionDuration = 0, transitionTimingFunction = 'linear', customRootClass, loop = true, openMultiple = false, value, defaultValue = [], onValueChange }: AccordionRootProps) => {
2528
const accordionRef = useRef<HTMLDivElement | null>(null);
2629
const rootClass = customClassSwitcher(customRootClass, COMPONENT_NAME);
30+
const processedValue = value !== undefined
31+
? (openMultiple ? value : (value.length > 0 ? [value[0]] : []))
32+
: undefined;
33+
34+
const processedDefaultValue = openMultiple
35+
? defaultValue
36+
: (defaultValue.length > 0 ? [defaultValue[0]] : []);
2737

28-
const [activeItems, setActiveItems] = useState<(number | string)[]>([]);
38+
const [activeItems, setActiveItems] = useControllableState<(number | string)[]>(
39+
processedValue,
40+
processedDefaultValue,
41+
onValueChange
42+
);
2943

3044
return (
3145
<AccordionContext.Provider

src/components/ui/Accordion/stories/Accordion.stories.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Accordion from '../Accordion';
22
import SandboxEditor from '~/components/tools/SandboxEditor/SandboxEditor';
33
import type { Meta, StoryObj } from '@storybook/react';
4+
import Button from '../../Button/Button';
45
import React from 'react';
56

67
// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
@@ -96,3 +97,25 @@ export const WithAnimation: Story = {
9697
export const OpenMultiple: Story = {
9798
render: () => <AccordionExample openMultiple />
9899
};
100+
101+
export const WithDeafultValue: Story = {
102+
render: () => <AccordionExample defaultValue={[2]} />
103+
};
104+
105+
export const ControlledValue: Story = {
106+
render: () => {
107+
const [value, setValue] = React.useState<number[]>([]);
108+
const [multiple, setMultiple] = React.useState(false);
109+
110+
return (
111+
<>
112+
<Button onClick={() => setMultiple(!multiple)}>{`Toggle Open Multiple (${multiple ? 'on' : 'off'})`}</Button>
113+
<Button onClick={() => setValue([])}>Close All</Button>
114+
<Button onClick={() => setValue([1])}>Open 2</Button>
115+
<Button onClick={() => setValue([0])}>Open 0</Button>
116+
<Button onClick={() => setValue([0, 1])}>Open 0, 1</Button>
117+
<AccordionExample value={value} onValueChange={setValue} openMultiple={multiple} />
118+
</>
119+
);
120+
}
121+
};

src/components/ui/Accordion/tests/Accordion.test.tsx

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import React from 'react';
33
import * as axe from 'axe-core';
44

55
import Accordion from '../Accordion';
6+
import { AccordionRootProps } from '../fragments/AccordionRoot';
67
import { ACCESSIBILITY_TEST_TAGS } from '~/setupTests';
78

89
// Test items to use in our composable accordion
@@ -13,9 +14,9 @@ const testItems = [
1314
];
1415

1516
// Create a test accordion component using the composable pattern
16-
const TestAccordion = () => {
17+
const TestAccordion = (props: Partial<AccordionRootProps>) => {
1718
return (
18-
<Accordion.Root>
19+
<Accordion.Root {...props}>
1920
{testItems.map((item, index) => (
2021
<Accordion.Item value={index} key={index}>
2122
<Accordion.Header>
@@ -100,4 +101,53 @@ describe('Accordion Component', () => {
100101
done();
101102
});
102103
});
104+
105+
test('controlled mode responds to external value changes', () => {
106+
const TestWithControls = () => {
107+
const [value, setValue] = React.useState<(number | string)[]>([]);
108+
109+
return (
110+
<>
111+
<button onClick={() => setValue([])}>Close All</button>
112+
<button onClick={() => setValue([1])}>Open 2</button>
113+
<button onClick={() => setValue([0, 2])}>Open 1 & 3</button>
114+
<TestAccordion value={value} onValueChange={setValue} openMultiple />
115+
</>
116+
);
117+
};
118+
119+
render(<TestWithControls />);
120+
121+
// Initially all closed
122+
expect(screen.queryByText('Content 1')).not.toBeInTheDocument();
123+
expect(screen.queryByText('Content 2')).not.toBeInTheDocument();
124+
expect(screen.queryByText('Content 3')).not.toBeInTheDocument();
125+
126+
// Open item 2
127+
fireEvent.click(screen.getByText('Open 2'));
128+
expect(screen.queryByText('Content 1')).not.toBeInTheDocument();
129+
expect(screen.getByText('Content 2')).toBeInTheDocument();
130+
expect(screen.queryByText('Content 3')).not.toBeInTheDocument();
131+
132+
// Open items 1 & 3
133+
fireEvent.click(screen.getByText('Open 1 & 3'));
134+
expect(screen.getByText('Content 1')).toBeInTheDocument();
135+
expect(screen.queryByText('Content 2')).not.toBeInTheDocument();
136+
expect(screen.getByText('Content 3')).toBeInTheDocument();
137+
138+
// Close all
139+
fireEvent.click(screen.getByText('Close All'));
140+
expect(screen.queryByText('Content 1')).not.toBeInTheDocument();
141+
expect(screen.queryByText('Content 2')).not.toBeInTheDocument();
142+
expect(screen.queryByText('Content 3')).not.toBeInTheDocument();
143+
});
144+
145+
test('works with defaultValue to show initial item', () => {
146+
render(<TestAccordion defaultValue={[2]} />);
147+
148+
// Item 3 content should be visible initially
149+
expect(screen.getByText('Content 3')).toBeInTheDocument();
150+
expect(screen.queryByText('Content 1')).not.toBeInTheDocument();
151+
expect(screen.queryByText('Content 2')).not.toBeInTheDocument();
152+
});
103153
});

src/components/ui/Card/stories/Card.stories.tsx

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -53,17 +53,17 @@ export const Size = () => {
5353
<span key={index} className="inline-flex items-start space-x-2">
5454
{Sizes.map((size, index) => {
5555
return <Card key={index} size={size} variant={variant} >
56-
<div className='flex items-center space-x-4'>
57-
<Avatar
58-
src='https://i.pravatar.cc/64'
59-
alt='avatar'
56+
<div className='flex items-center space-x-4'>
57+
<Avatar
58+
src='https://i.pravatar.cc/64'
59+
alt='avatar'
6060
/>
61-
<div>
62-
<p className='font-bold text-gray-1000'>John Doe</p>
63-
<p className='text-xs text-gray-800'>
61+
<div>
62+
<p className='font-bold text-gray-1000'>John Doe</p>
63+
<p className='text-xs text-gray-800'>
6464
1 hour ago
65-
</p>
66-
</div>
65+
</p>
66+
</div>
6767
</div>
6868
</Card>;
6969
})}
@@ -85,16 +85,16 @@ export const Variant = () => {
8585
{Variants.map((variant, index) => {
8686
return <Card key={index} variant={variant} >
8787
<div className='flex items-center space-x-4'>
88-
<Avatar
89-
src='https://i.pravatar.cc/64'
90-
alt='avatar'
88+
<Avatar
89+
src='https://i.pravatar.cc/64'
90+
alt='avatar'
9191
/>
92-
<div>
93-
<p className='font-bold text-gray-1000'>John Doe</p>
94-
<p className='text-xs text-gray-800'>
92+
<div>
93+
<p className='font-bold text-gray-1000'>John Doe</p>
94+
<p className='text-xs text-gray-800'>
9595
1 hour ago
96-
</p>
97-
</div>
96+
</p>
97+
</div>
9898
</div>
9999
</Card>;
100100
})}

src/core/primitives/Avatar/fragments/AvatarPrimitiveRoot.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ const AvatarPrimitiveRoot = ({ children, className = '', customRootClass = '', a
3535
hasError,
3636
setHasError,
3737
handleLoadImage,
38-
handleErrorImage,
38+
handleErrorImage
3939
};
4040

4141
return <AvatarPrimitiveContext.Provider value={values} >

src/core/utils/RovingFocusGroup/context/RovingFocusRootContext.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,5 @@ export type RovingFocusRootContextTypes = {
1818
export const RovingFocusRootContext = createContext<RovingFocusRootContextTypes>({
1919
orientation: 'horizontal',
2020
loop: true,
21-
disableTabIndexing: false,
21+
disableTabIndexing: false
2222
});

src/core/utils/RovingFocusGroup/fragments/RovingFocusRoot.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ const RovingFocusRoot = ({
4747
const sendValues = {
4848
orientation,
4949
loop,
50-
disableTabIndexing,
50+
disableTabIndexing
5151
};
5252

5353
return <RovingFocusRootContext.Provider value={sendValues}>

0 commit comments

Comments
 (0)