Skip to content

Commit

Permalink
Feat(web-react): Introduce Drawer component #DS-1580
Browse files Browse the repository at this point in the history
  • Loading branch information
curdaj committed Dec 30, 2024
1 parent b53a603 commit 285cbb5
Show file tree
Hide file tree
Showing 18 changed files with 405 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/web-react/scripts/entryPoints.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const entryPoints = [
{ dirs: ['components', 'Container'] },
{ dirs: ['components', 'Dialog'] },
{ dirs: ['components', 'Divider'] },
{ dirs: ['components', 'Drawer'] },
{ dirs: ['components', 'Dropdown'] },
{ dirs: ['components', 'Field'] },
{ dirs: ['components', 'FieldGroup'] },
Expand Down
42 changes: 42 additions & 0 deletions packages/web-react/src/components/Drawer/Drawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use client';

import classNames from 'classnames';
import React from 'react';
import { AlignmentX } from '../../constants';
import { useStyleProps, useLastActiveFocus } from '../../hooks';
import { SpiritDrawerProps } from '../../types';
import Dialog from '../Dialog/Dialog';
import { DrawerProvider } from './DrawerContext';
import { useDrawerStyleProps } from './useDrawerStyleProps';

const Drawer = (props: SpiritDrawerProps) => {
const { children, alignment = AlignmentX.RIGHT, isOpen, onClose, id, ...restProps } = props;
const { classProps } = useDrawerStyleProps({ drawerAlignment: alignment });
const { styleProps, props: otherProps } = useStyleProps(restProps);

const contextValue = {
id,
isOpen,
onClose,
};

useLastActiveFocus(isOpen);

return (
<DrawerProvider value={contextValue}>
<Dialog
{...otherProps}
{...styleProps}
id={id}
isOpen={isOpen}
onClose={onClose}
className={classNames(classProps.root, styleProps.className)}
aria-labelledby={`${id}__title`}
>
{children}
</Dialog>
</DrawerProvider>
);
};

export default Drawer;
21 changes: 21 additions & 0 deletions packages/web-react/src/components/Drawer/DrawerCloseButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
'use client';

import React from 'react';
import { DrawerCloseButtonProps } from '../../types';
import { Button } from '../Button';
import { Icon } from '../Icon';
import { VisuallyHidden } from '../VisuallyHidden';
import { useDrawerContext } from './DrawerContext';

const DrawerCloseButton = ({ label = 'Close', ...restProps }) => {
const { id, isOpen, onClose } = useDrawerContext();

return (
<Button {...restProps} isSymmetrical color="tertiary" onClick={onClose} aria-expanded={isOpen} aria-controls={id}>
<Icon name="close" />
<VisuallyHidden>{label}</VisuallyHidden>
</Button>
);
};

export default DrawerCloseButton;
22 changes: 22 additions & 0 deletions packages/web-react/src/components/Drawer/DrawerContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
'use client';

import { createContext, useContext } from 'react';
import { DrawerDialogHandlingProps } from '../../types';

export type DrawerContextProps = {
id: string;
} & DrawerDialogHandlingProps;

const defaultContext: DrawerContextProps = {
id: '',
isOpen: false,
onClose: () => null,
};

const DrawerContext = createContext<DrawerContextProps>(defaultContext);
const DrawerProvider = DrawerContext.Provider;
const DrawerConsumer = DrawerContext.Consumer;
const useDrawerContext = (): DrawerContextProps => useContext(DrawerContext);

export default DrawerContext;
export { DrawerProvider, DrawerConsumer, useDrawerContext };
34 changes: 34 additions & 0 deletions packages/web-react/src/components/Drawer/DrawerDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use client';

import classNames from 'classnames';
import React, { ElementType, ForwardedRef, forwardRef, HTMLAttributes } from 'react';
import { useStyleProps } from '../../hooks';
import { DrawerDialogElementType, DrawerDialogProps } from '../../types';
import { useDrawerDialogStyleProps } from './useDrawerDialogStyleProps';
import { useDrawerStyleProps } from './useDrawerStyleProps';

const DrawerDialog = <E extends ElementType = DrawerDialogElementType>(
props: DrawerDialogProps<E>,
ref: ForwardedRef<HTMLDivElement>,
) => {
const { elementType: ElementTag = 'div', children, ...restProps } = props;

const { classProps } = useDrawerStyleProps(restProps);
const { drawerDialogStyleProps, props: otherStyleProps } = useDrawerDialogStyleProps(restProps);
const { styleProps, props: otherProps } = useStyleProps(otherStyleProps);

const combinedStyleProps = { ...styleProps.style, ...drawerDialogStyleProps };

return (
<ElementTag
ref={ref}
{...(otherProps as HTMLAttributes<HTMLElement>)}
style={{ ...(combinedStyleProps as HTMLAttributes<HTMLElement>) }}
className={classNames(classProps.dialog, styleProps.className)}
>
{children}
</ElementTag>
);
};

export default forwardRef(DrawerDialog);
1 change: 1 addition & 0 deletions packages/web-react/src/components/Drawer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Drawer
50 changes: 50 additions & 0 deletions packages/web-react/src/components/Drawer/__tests__/Modal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import '@testing-library/jest-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { classNamePrefixProviderTest } from '../../../../tests/providerTests/classNamePrefixProviderTest';
import { restPropsTest } from '../../../../tests/providerTests/restPropsTest';
import { stylePropsTest } from '../../../../tests/providerTests/stylePropsTest';
import { SpiritModalProps } from '../../../types';
import Modal from '../Modal';

describe('Modal', () => {
const ModalTest = (props: SpiritModalProps) => (
<Modal {...props} id="modal-example" isOpen={false} onClose={() => null}>
<div>Test</div>
</Modal>
);

classNamePrefixProviderTest(ModalTest, 'Modal');

stylePropsTest(ModalTest);

restPropsTest(ModalTest, 'dialog');

it('should not close modal dialog', () => {
const mockedOnClose = jest.fn();
render(
<Modal id="test" isOpen onClose={mockedOnClose} closeOnBackdropClick={false}>
<div>Test</div>
</Modal>,
);

const dialog = screen.getByRole('dialog');
fireEvent.click(dialog);

expect(mockedOnClose).not.toHaveBeenCalled();
});

it('should close modal dialog', () => {
const mockedOnClose = jest.fn();
render(
<Modal id="test" isOpen onClose={mockedOnClose} closeOnBackdropClick>
<div>Test</div>
</Modal>,
);

const dialog = screen.getByRole('dialog');
fireEvent.click(dialog);

expect(mockedOnClose).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { renderHook } from '@testing-library/react';
import { useModalStyleProps } from '../useModalStyleProps';

describe('useModalStyleProps', () => {
it('should return defaults', () => {
const { result } = renderHook(() => useModalStyleProps({}));

expect(result.current.classProps.root).toBe('Modal Modal--center');
expect(result.current.classProps.dialog).toBe('ModalDialog');
expect(result.current.classProps.title).toBe('ModalHeader__title');
expect(result.current.classProps.header).toBe('ModalHeader');
expect(result.current.classProps.body).toBe('ModalBody');
expect(result.current.classProps.footer.root).toBe('ModalFooter ModalFooter--right');
expect(result.current.classProps.footer.description).toBe('ModalFooter__description');
expect(result.current.classProps.footer.actions).toBe('ModalFooter__actions');
});
});
32 changes: 32 additions & 0 deletions packages/web-react/src/components/Drawer/demo/DrawerDefault.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React, { useState } from 'react';
import { Button } from '../..';
import { AlignmentX } from '../../../constants';
import { AlignmentXDictionaryType } from '../../../types';
import Drawer from '../Drawer';
import DrawerCloseButton from '../DrawerCloseButton';
import DrawerDialog from '../DrawerDialog';

const DrawerDefault = () => {
const [isDrawerBasicOpen, setDrawerBasicOpen] = useState(false);
const [drawerAlign, setDrawerAlign] = useState<AlignmentXDictionaryType>(AlignmentX.RIGHT);
const toggleDrawerBasic = () => setDrawerBasicOpen(!isDrawerBasicOpen);

const handleDrawerBasicClose = () => setDrawerBasicOpen(false);

return (
<>
<Button onClick={toggleDrawerBasic} data-test-id="drawer-basic">
Open Drawer
</Button>

<Drawer alignment={drawerAlign} id="example-basic" isOpen={isDrawerBasicOpen} onClose={handleDrawerBasicClose}>
<DrawerDialog>
<DrawerCloseButton label="test" />
<div>Drawer content</div>
</DrawerDialog>
</Drawer>
</>
);
};

export default DrawerDefault;
20 changes: 20 additions & 0 deletions packages/web-react/src/components/Drawer/demo/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Because there is no `dist` directory during the CI run
/* eslint-disable import/no-extraneous-dependencies, import/extensions, import/no-unresolved */
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: No declaration file -- @see https://jira.almacareer.tech/browse/DS-561
import icons from '@lmc-eu/spirit-icons/icons';
import React from 'react';
import ReactDOM from 'react-dom/client';
import DocsSection from '../../../../docs/DocsSections';
import { IconsProvider } from '../../../context';
import DrawerDefault from './DrawerDefault';

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<IconsProvider value={icons}>
<DocsSection title="Drawer">
<DrawerDefault />
</DocsSection>
</IconsProvider>
</React.StrictMode>,
);
1 change: 1 addition & 0 deletions packages/web-react/src/components/Drawer/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{{> web-react/demo title="Drawer" parentPageName="Components" }}
7 changes: 7 additions & 0 deletions packages/web-react/src/components/Drawer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
'use client';

export { default as Drawer } from './Drawer';
export { default as DrawerCloseButton } from './DrawerCloseButton';
export { default as DrawerDialog } from './DrawerDialog';
export * from './DrawerContext';
export * from './useDrawerStyleProps';
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Markdown } from '@storybook/blocks';
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import React from 'react';
import { AlignmentY } from '../../../constants';
import Drawer from '../Drawer';
import ReadMe from '../README.md';

const meta: Meta<typeof Drawer> = {
title: 'Components/Drawer',
component: Drawer,
parameters: {
docs: {
page: () => <Markdown>{ReadMe}</Markdown>,
},
},
argTypes: {},
args: {
alignmentY: AlignmentY.CENTER,
id: 'modal',
isOpen: false,
onClose: fn(),
closeOnEscapeKeyDown: true,
closeOnBackdropClick: true,
},
};

export default meta;
type Story = StoryObj<typeof Drawer>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { ElementType, CSSProperties } from 'react';
import { DrawerDialogCSSHeight, ModalDialogCSSHeightBreakpoints, DrawerDialogProps } from '../../types';

interface CustomizedHeightCSSProperties extends CSSProperties {
[key: string]: string | undefined | number;
}

const setCustomHeight = (
baseVarName: string,
propValue: DrawerDialogCSSHeight | ModalDialogCSSHeightBreakpoints | undefined,
): CustomizedHeightCSSProperties => {
if (!propValue) return {};

if (typeof propValue === 'object') {
return Object.keys(propValue).reduce((acc, key) => {
const suffix = key === 'mobile' ? '' : `-${key}`;
const propName = `--${baseVarName}${suffix}`;
acc[propName] = propValue[key as keyof ModalDialogCSSHeightBreakpoints]?.toString();

return acc;
}, {} as CustomizedHeightCSSProperties);
}
const propName = `--${baseVarName}`;

return { [propName]: propValue?.toString() } as CustomizedHeightCSSProperties;
};

export const useDrawerDialogStyleProps = <E extends ElementType>(props: DrawerDialogProps<E>) => {
const { height, maxHeight, ...otherProps } = props;

const customizedHeightStyle = {
...setCustomHeight('drawer-dialog-height', height),
...setCustomHeight('drawer-dialog-max-height', maxHeight),
};

return {
drawerDialogStyleProps: customizedHeightStyle,
props: otherProps,
};
};
35 changes: 35 additions & 0 deletions packages/web-react/src/components/Drawer/useDrawerStyleProps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import classNames from 'classnames';
import { useClassNamePrefix } from '../../hooks';
import { AlignmentXDictionaryType } from '../../types';

export interface DrawerStylesProps {
drawerAlignment: AlignmentXDictionaryType;
}

export interface DrawerStylesReturn {
/** className props */
classProps: {
root: string;
dialog: string;
};
}

export function useDrawerStyleProps(props: Partial<DrawerStylesProps>): DrawerStylesReturn {
const { drawerAlignment } = props;
const drawerClass = useClassNamePrefix('Drawer');
const drawerAlignClasses = {
left: `${drawerClass}--left`,
center: `${drawerClass}--center`,
right: `${drawerClass}--right`,
};
const drawerDialogClass = `${drawerClass}Dialog`;

const classProps = {
root: classNames(drawerClass, drawerAlignment && { [drawerAlignClasses[drawerAlignment]]: drawerAlignment }),
dialog: classNames(drawerDialogClass, {}),
};

return {
classProps,
};
}
1 change: 1 addition & 0 deletions packages/web-react/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from './Collapse';
export * from './Container';
export * from './Dialog';
export * from './Divider';
export * from './Drawer';
export * from './Dropdown';
export * from './Field';
export * from './FieldGroup';
Expand Down
Loading

0 comments on commit 285cbb5

Please sign in to comment.