Skip to content

Commit

Permalink
feat(fuselage)!: Lighter Accordion and AccordionItem components (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
tassoevan authored Oct 23, 2024
1 parent 938c110 commit 86fe018
Show file tree
Hide file tree
Showing 14 changed files with 682 additions and 111 deletions.
13 changes: 13 additions & 0 deletions .changeset/soft-islands-decide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@rocket.chat/fuselage": minor
---

Simplifies `Accordion` and `AccordionItem`

It removes an obsolete and not accessible toggle switch in `AccordionItem` and eases the internal usage of `Box` to
improve rendering performance.

Additionally, it adds a new `StylingBox` component that can be used as a wrapper for components that accept styling
props but don't need the weight of the `Box` component prop handling internally.

Also, it adds a new `cx` and `cxx` helpers to compose class names.
6 changes: 6 additions & 0 deletions packages/fuselage/jest-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,9 @@ window.ResizeObserver = jest.fn().mockImplementation(() => ({
unobserve: jest.fn(),
disconnect: jest.fn(),
}));

let uniqueIdCounter = 0;
jest.mock('@rocket.chat/fuselage-hooks', () => ({
...jest.requireActual('@rocket.chat/fuselage-hooks'),
useUniqueId: () => `unique-id-${uniqueIdCounter++}`,
}));
26 changes: 17 additions & 9 deletions packages/fuselage/src/components/Accordion/Accordion.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,25 @@ import { axe } from 'jest-axe';
import { render } from '../../testing';
import * as stories from './Accordion.stories';

const { Default } = composeStories(stories);
const testCases = Object.values(composeStories(stories)).map((Story) => [
Story.storyName || 'Story',
Story,
]);

describe('[Accordion Component]', () => {
it('renders without crashing', () => {
render(<Default />);
});
test.each(testCases)(
`renders %s without crashing`,
async (_storyname, Story) => {
const tree = render(<Story />);
expect(tree.baseElement).toMatchSnapshot();
}
);

it('should have no a11y violations', async () => {
const { container } = render(<Default />);
test.each(testCases)(
'%s should have no a11y violations',
async (_storyname, Story) => {
const { container } = render(<Story />);

const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
}
);
54 changes: 42 additions & 12 deletions packages/fuselage/src/components/Accordion/Accordion.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,66 @@ import type { StoryFn, Meta } from '@storybook/react';
import type { ComponentType } from 'react';

import Box from '../Box';
import { Accordion } from './Accordion';
import { AccordionItem } from './AccordionItem';
import Accordion from './Accordion';
import AccordionItem from './AccordionItem';

export default {
title: 'Containers/Accordion',
component: Accordion,
subcomponents: {
'Accordion.Item': Accordion.Item as ComponentType<any>,
'AccordionItem': AccordionItem as ComponentType<any>,
AccordionItem: AccordionItem as ComponentType<any>,
},
} satisfies Meta<typeof Accordion>;

const Template: StoryFn<typeof Accordion> = () => (
export const Default: StoryFn<typeof Accordion> = () => (
<Accordion>
<Accordion.Item title='Item #1' defaultExpanded>
<AccordionItem title='Item #1'>
<Box color='default' fontScale='p2' marginBlockEnd={16}>
Content #1
</Box>
</Accordion.Item>
<Accordion.Item title='Item #2'>
</AccordionItem>
<AccordionItem title='Item #2'>
<Box color='default' fontScale='p2' marginBlockEnd={16}>
Content #2
</Box>
</Accordion.Item>
<Accordion.Item title='Item #3'>
</AccordionItem>
<AccordionItem title='Item #3'>
<Box color='default' fontScale='p2' marginBlockEnd={16}>
Content #3
</Box>
</Accordion.Item>
</AccordionItem>
</Accordion>
);

export const Default: StoryFn<typeof Accordion> = Template.bind({});
const ItemTemplate: StoryFn<typeof AccordionItem> = ({
title = 'Item #2',
...args
}) => (
<Accordion>
<AccordionItem title='Item #1'>
<Box color='default' fontScale='p2' marginBlockEnd={16}>
Content #1
</Box>
</AccordionItem>
<AccordionItem title={title} {...args}>
<Box color='default' fontScale='p2' marginBlockEnd={16}>
Content #2
</Box>
</AccordionItem>
<AccordionItem title='Item #3'>
<Box color='default' fontScale='p2' marginBlockEnd={16}>
Content #3
</Box>
</AccordionItem>
</Accordion>
);

export const ExpandedItemByDefault = ItemTemplate.bind({});
ExpandedItemByDefault.args = {
defaultExpanded: true,
};

export const DisabledItem = ItemTemplate.bind({});
DisabledItem.args = {
disabled: true,
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
display: flex;
flex-flow: column nowrap;
border-block-end-color: colors.stroke(extra-light);

border-block-end-width: lengths.border-width(default);
}

Expand Down Expand Up @@ -64,14 +63,6 @@
@include typography.use-font-scale(h4);
}

.rcx-accordion-item__toggle-switch {
display: flex;
align-items: center;
flex: 0 0 auto;

margin: lengths.margin(none) lengths.margin(24);
}

.rcx-accordion-item__panel {
visibility: hidden;

Expand Down
27 changes: 14 additions & 13 deletions packages/fuselage/src/components/Accordion/Accordion.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
import type { ComponentProps, ReactElement, ReactNode } from 'react';
import type { ReactNode } from 'react';

import Box from '../Box';
import { AccordionItem } from './AccordionItem';
import { cx, cxx } from '../../helpers/composeClassNames';
import { StylingBox } from '../Box';
import { StylingProps } from '../Box/stylingProps';

type AccordionProps = ComponentProps<typeof Box> & {
animated?: boolean;
export type AccordionProps = {
children: ReactNode;
};
} & Partial<StylingProps>;

/**
* An `Accordion` allows users to toggle the display of sections of content.
*/
export function Accordion(props: AccordionProps): ReactElement<AccordionProps> {
return <Box animated rcx-accordion {...props} />;
}
const Accordion = ({ children, ...props }: AccordionProps) => (
<StylingBox {...props}>
<div className={cx(cxx('rcx-box')('full', 'animated'), 'rcx-accordion')}>
{children}
</div>
</StylingBox>
);

/**
* @deprecated use named import instead
*/
Accordion.Item = AccordionItem;
export default Accordion;
114 changes: 49 additions & 65 deletions packages/fuselage/src/components/Accordion/AccordionItem.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { useToggle, useUniqueId } from '@rocket.chat/fuselage-hooks';
import type { FormEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react';
import type { KeyboardEvent, MouseEvent, ReactNode } from 'react';

import Box from '../Box';
import { cx, cxx } from '../../helpers/composeClassNames';
import { StylingBox } from '../Box';
import { Chevron } from '../Chevron';
import { ToggleSwitch } from '../ToggleSwitch';

type AccordionItemProps = {
export type AccordionItemProps = {
children?: ReactNode;
className?: string;
defaultExpanded?: boolean;
Expand All @@ -14,33 +14,20 @@ type AccordionItemProps = {
tabIndex?: number;
title: ReactNode;
noncollapsible?: boolean;
onToggle?: (e: MouseEvent | KeyboardEvent) => void;
onToggleEnabled?: (e: FormEvent) => void;
};

export const AccordionItem = function Item({
const AccordionItem = ({
children,
className,
defaultExpanded,
disabled,
disabled = false,
expanded: propExpanded,
tabIndex = 0,
title,
noncollapsible = !title,
onToggle,
onToggleEnabled,
...props
}: AccordionItemProps) {
}: AccordionItemProps) => {
const [stateExpanded, toggleStateExpanded] = useToggle(defaultExpanded);
const expanded = propExpanded || stateExpanded;
const toggleExpanded = (event: MouseEvent | KeyboardEvent) => {
if (onToggle) {
onToggle.call(event.currentTarget, event);
return;
}

toggleStateExpanded();
};

const panelExpanded = noncollapsible || expanded;

Expand All @@ -52,27 +39,25 @@ export const AccordionItem = function Item({
return;
}
e.currentTarget?.blur();
toggleExpanded(e);
toggleStateExpanded();
};

const handleKeyDown = (event: KeyboardEvent) => {
if (disabled || event.currentTarget !== event.target) {
return;
}

if ([13, 32].includes(event.keyCode)) {
event.preventDefault();
if (![' ', 'Enter'].includes(event.key)) {
return;
}

if (event.repeat) {
return;
}
event.preventDefault();

toggleExpanded(event);
if (event.repeat) {
return;
}
};

const handleToggleClick = (event: MouseEvent) => {
event.stopPropagation();
toggleStateExpanded();
};

const collapsibleProps = {
Expand All @@ -92,42 +77,41 @@ export const AccordionItem = function Item({
const barProps = noncollapsible ? nonCollapsibleProps : collapsibleProps;

return (
<Box is='section' rcx-accordion-item className={className} {...props}>
{title && (
<Box
role='button'
animated
rcx-accordion-item__bar
rcx-accordion-item__bar--disabled={disabled}
{...barProps}
>
<Box is='h2' rcx-accordion-item__title id={titleId}>
{title}
</Box>
{!noncollapsible && (
<>
{(disabled || onToggleEnabled) && (
<Box rcx-accordion-item__toggle-switch>
<ToggleSwitch
checked={!disabled}
onClick={handleToggleClick}
onChange={onToggleEnabled}
/>
</Box>
<StylingBox {...props}>
<section className={cx(cxx('rcx-box')('full'), 'rcx-accordion-item')}>
{title && (
<div
role='button'
className={cx(
cxx('rcx-box')('full', 'animated'),
cxx('rcx-accordion-item__bar')({ disabled })
)}
{...barProps}
>
<h2
className={cx(
cxx('rcx-box')('full'),
'rcx-accordion-item__title'
)}
<Chevron size='x24' up={expanded} />
</>
id={titleId}
>
{title}
</h2>
{!noncollapsible && <Chevron size='x24' up={expanded} />}
</div>
)}
<div
className={cx(
cxx('rcx-box')('full', 'animated'),
cxx('rcx-accordion-item__panel')({ expanded: panelExpanded })
)}
</Box>
)}
<Box
animated
rcx-accordion-item__panel
rcx-accordion-item__panel--expanded={panelExpanded}
id={panelId}
>
{children}
</Box>
</Box>
id={panelId}
>
{children}
</div>
</section>
</StylingBox>
);
};

export default AccordionItem;
Loading

0 comments on commit 86fe018

Please sign in to comment.