Skip to content

Commit

Permalink
Refactor(web-react): Refactor Dropdown to controlled component #DS-637
Browse files Browse the repository at this point in the history
- Created new component called DropdownModern with new structure
- Added deprecation messages to Dropdown and DropdownModern
- Added deprecation to `renderTrigger` in Dropdown component
- Created new component called UncontrolledDropdown
  • Loading branch information
pavelklibani committed Nov 6, 2023
1 parent 7147a6d commit 3c0f08b
Show file tree
Hide file tree
Showing 30 changed files with 911 additions and 11 deletions.
17 changes: 14 additions & 3 deletions packages/web-react/src/components/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import classNames from 'classnames';
import React, { LegacyRef, createElement, useRef } from 'react';
import { Placements } from '../../constants';
import { useStyleProps } from '../../hooks';
import { SpiritDropdownProps } from '../../types';
import DropdownWrapper from './DropdownWrapper';
import { useDeprecationMessage, useStyleProps } from '../../hooks';
import { SpiritDropdownProps } from '../../types';
import { useDropdown } from './useDropdown';
import { useDropdownAriaProps } from './useDropdownAriaProps';
import { useDropdownStyleProps } from './useDropdownStyleProps';
Expand All @@ -13,7 +13,10 @@ const defaultProps = {
placement: Placements.BOTTOM_LEFT,
};

const Dropdown = (props: SpiritDropdownProps) => {
/**
* @deprecated Dropdown component is deprecated and will be removed in the next major version. Please use "DropdownModern" component instead.
*/
export const Dropdown = (props: SpiritDropdownProps) => {
const {
id = Math.random().toString(36).slice(2, 7),
children,
Expand All @@ -36,6 +39,14 @@ const Dropdown = (props: SpiritDropdownProps) => {
UNSAFE_className: classProps.triggerClassName,
});

useDeprecationMessage({
method: 'custom',
trigger: true,
componentName: 'Dropdown',
customText:
'Dropdown component is deprecated and will be removed in the next major version. Please use "DropdownModern" component instead.',
});

const triggerRenderHandler = () => {
if (renderTrigger) {
return renderTrigger({
Expand Down
33 changes: 33 additions & 0 deletions packages/web-react/src/components/Dropdown/DropdownContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { MutableRefObject, createContext, useContext } from 'react';
import { Placements } from '../../constants';
import { ClickEvent, PlacementDictionaryType } from '../../types';
import { fullWidthModeKeys } from './useDropdownAriaProps';

type DropdownContextType = {
dropdownRef: MutableRefObject<HTMLElement | null>;
fullWidthMode?: keyof typeof fullWidthModeKeys;
id: string;
isOpen: boolean;
onToggle: (event: ClickEvent) => void;
placement?: PlacementDictionaryType;
triggerRef: MutableRefObject<HTMLElement | undefined>;
};

const defaultContext: DropdownContextType = {
dropdownRef: { current: null },
fullWidthMode: fullWidthModeKeys.off,
id: '',
isOpen: false,
onToggle: () => {},
placement: Placements.BOTTOM_LEFT,
triggerRef: { current: undefined },
};

const DropdownContext = createContext<DropdownContextType>(defaultContext);
const DropdownProvider = DropdownContext.Provider;
const DropdownConsumer = DropdownContext.Consumer;
const useDropdownContext = (): DropdownContextType => useContext(DropdownContext);

export default DropdownContext;
export { DropdownConsumer, DropdownProvider, useDropdownContext };
export type { DropdownContextType };
67 changes: 67 additions & 0 deletions packages/web-react/src/components/Dropdown/DropdownModern.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import React, { useRef } from 'react';
import classNames from 'classnames';
import { useClickOutside, useDeprecationMessage, useStyleProps } from '../../hooks';
import { ChildrenProps, SpiritDropdownModernProps } from '../../types';
import { DropdownProvider } from './DropdownContext';
import { useDropdownStyleProps } from './useDropdownStyleProps';

interface DropdownProps extends ChildrenProps, SpiritDropdownModernProps {}

export const DropdownModern = (props: DropdownProps) => {
const {
children,
enableAutoClose = true,
fullWidthMode,
id,
isOpen = false,
onAutoClose,
onToggle,
placement,
...rest
} = props;
const { classProps } = useDropdownStyleProps();
const { styleProps, props: otherProps } = useStyleProps({ ...rest });

const dropdownRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLDivElement>();

const closeHandler = (event: Event) => {
if (!enableAutoClose) {
return;
}

if (!triggerRef?.current?.contains(event?.target as Node)) {
if (onAutoClose) {
onAutoClose(event);
}

onToggle && isOpen && onToggle();
}
};

useClickOutside({ ref: dropdownRef, callback: closeHandler });

useDeprecationMessage({
method: 'component',
trigger: true,
componentName: 'DropdownModern',
componentProps: {
newName: 'Dropdown',
},
});

return (
<DropdownProvider value={{ id, isOpen, fullWidthMode, placement, onToggle, dropdownRef, triggerRef }}>
<div
ref={dropdownRef}
{...styleProps}
{...otherProps}
className={classNames(classProps.wrapperClassName, styleProps.className)}
>
{children}
</div>
</DropdownProvider>
);
};

export default DropdownModern;
29 changes: 29 additions & 0 deletions packages/web-react/src/components/Dropdown/DropdownPopover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React from 'react';
import classNames from 'classnames';
import { useStyleProps } from '../../hooks';
import { ChildrenProps, StyleProps } from '../../types';
import { useDropdownContext } from './DropdownContext';
import { useDropdownAriaProps } from './useDropdownAriaProps';
import { useDropdownStyleProps } from './useDropdownStyleProps';

interface DropdownPopoverProps extends ChildrenProps, StyleProps {}

export const DropdownPopover = (props: DropdownPopoverProps) => {
const { children, ...rest } = props;
const { id, isOpen, onToggle, fullWidthMode, placement } = useDropdownContext();
const { classProps, props: modifiedProps } = useDropdownStyleProps({ isOpen, placement, ...rest });
const { styleProps: contentStyleProps, props: contentOtherProps } = useStyleProps({ ...modifiedProps });
const { contentProps } = useDropdownAriaProps({ id, isOpen, toggleHandler: onToggle, fullWidthMode });

return (
<div
className={classNames(classProps.contentClassName, contentStyleProps.className)}
{...contentOtherProps}
{...contentProps}
>
{children}
</div>
);
};

export default DropdownPopover;
36 changes: 36 additions & 0 deletions packages/web-react/src/components/Dropdown/DropdownTrigger.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React, { ElementType } from 'react';
import { useStyleProps } from '../../hooks';
import { StyleProps } from '../../types';
import { useDropdownContext } from './DropdownContext';
import { useDropdownAriaProps } from './useDropdownAriaProps';
import { useDropdownStyleProps } from './useDropdownStyleProps';

interface DropdownTriggerProps extends StyleProps {
elementType?: ElementType | string;
children: string | React.ReactNode | ((props: { isOpen: boolean }) => React.ReactNode);
}

export const DropdownTrigger = (props: DropdownTriggerProps) => {
const { elementType = 'button', children, ...rest } = props;
const { id, isOpen, onToggle, fullWidthMode, triggerRef } = useDropdownContext();

const Component = elementType;

const { classProps } = useDropdownStyleProps({ isOpen, ...rest });
const { styleProps: triggerStyleProps } = useStyleProps({
UNSAFE_className: classProps.triggerClassName,
});
const { triggerProps } = useDropdownAriaProps({ id, isOpen, toggleHandler: onToggle, fullWidthMode });

return (
<Component id={id} {...rest} ref={triggerRef} {...triggerStyleProps} {...triggerProps}>
{typeof children === 'function' ? children({ isOpen }) : children}
</Component>
);
};

DropdownTrigger.defaultProps = {
elementType: 'button',
};

export default DropdownTrigger;
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useDropdownStyleProps } from './useDropdownStyleProps';

interface DropdownWrapperProps extends ChildrenProps {}

const DropdownWrapper = ({ children }: DropdownWrapperProps) => {
export const DropdownWrapper = ({ children }: DropdownWrapperProps) => {
const { classProps } = useDropdownStyleProps();

return <div className={classProps.wrapperClassName}>{children}</div>;
Expand Down
88 changes: 85 additions & 3 deletions packages/web-react/src/components/Dropdown/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Dropdown

⚠️ Dropdown component is [deprecated][deprecated] and will be removed in the next major version. Please use "DropdownModern" component instead.
This is the React implementation of the [Dropdown] component.

## Usage
Expand All @@ -9,8 +10,8 @@ import { Dropdown } from '@lmc-eu/spirit-web-react/components';
```

```jsx
<Dropdown id="DropdownExample" renderToggle={({ trigger }) => <button {...trigger}>...</button>}>
...
<Dropdown id="DropdownExample" renderToggle={({ trigger }) => <button {...trigger}></button>}>
</Dropdown>
```

Expand Down Expand Up @@ -39,7 +40,88 @@ import { Dropdown } from '@lmc-eu/spirit-web-react/components';
| `trigger['aria-controls']` | `string` | Trigger aria controls |
| `trigger.ref` | `LegacyRef<HTMLButtonElement & HTMLAnchorElement>` | Trigger reference |

---

# DropdownModern

⚠️ `DropdownModern` component is [deprecated] and will be renamed to `Dropdown` in the next major version.

## Usage

```jsx
import { DropdownModern, DropdownTrigger, DropdownPopover } from '@lmc-eu/spirit-web-react/components';
```

```jsx
const [isOpen, setIsOpen] = React.useState(false);
const onToggle = () => setIsOpen(!isOpen);

<DropdownModern id="DropdownExample" isOpen={isOpen} onToggle={onToggle}>
<DropdownTrigger elementType="button">Trigger button</DropdownTrigger>
<DropdownPopover></DropdownPopover>
</DropdownModern>;
```

### Uncontrolled dropdown

```jsx
import { UncontrolledDropdown, DropdownTrigger, DropdownPopover } from '@lmc-eu/spirit-web-react/components';
```

```jsx
<UncontrolledDropdown id="UncontrolledDropdownExample">
<DropdownTrigger elementType="button">Trigger button</DropdownTrigger>
<DropdownPopover></DropdownPopover>
</UncontrolledDropdown>
```

## API

### DropdownModern

| Name | Type | Default | Required | Description |
| ------------------ | ------------------------------------------------ | ------------- | -------- | ---------------------------------------------- |
| `enableAutoClose` | `bool` | `true` || Enables close on click outside of Dropdown |
| `fullWidthMode` | [`DropdownFullwidthMode`][dropdownfullwidthmode] | `off` || Full-width mode |
| `id` | `string` | - || Component id |
| `isOpen` | `bool` | `false` || Open state |
| `onAutoClose` | `(event: Event) => void` ||| Callback on close on click outside of Dropdown |
| `onToggle` | `() => void` ||| Function for toggle open state of dropdown |
| `placement` | [Placement dictionary][dictionary-placement] | `bottom-left` || Alignment of the component |
| `UNSAFE_className` | `string` ||| Wrapper custom classname |
| `UNSAFE_style` | `CSSProperties` ||| Wrapper custom style |

### DropdownTrigger

| Name | Type | Default | Required | Description |
| ------------------ | ------------------------- | -------- | -------- | -------------------------------- |
| `children` | [`string` \| `ReactNode`] ||| Content of trigger element |
| `elementType` | [`string` \| `ReactNode`] | `button` || Element type of dropdown trigger |
| `UNSAFE_className` | `string` ||| DropdownTrigger custom classname |
| `UNSAFE_style` | `CSSProperties` ||| DropdownTrigger custom style |

### DropdownPopover

| Name | Type | Default | Required | Description |
| ------------------ | ------------------------- | ------- | -------- | -------------------------------- |
| `children` | [`string` \| `ReactNode`] ||| Content of trigger element |
| `UNSAFE_className` | `string` ||| DropdownPopover custom classname |
| `UNSAFE_style` | `CSSProperties` ||| DropdownPopover custom style |

### UncontrolledDropdown

| Name | Type | Default | Required | Description |
| ------------------ | ------------------------------------------------ | ------------- | -------- | ---------------------------------------------- |
| `enableAutoClose` | `bool` | `true` || Enables close on click outside of Dropdown |
| `fullWidthMode` | [`DropdownFullwidthMode`][dropdownfullwidthmode] | `off` || Full-width mode |
| `id` | `string` | `<random>` || Component id |
| `onAutoClose` | `(event: Event) => void` ||| Callback on close on click outside of Dropdown |
| `placement` | [Placement dictionary][dictionary-placement] | `bottom-left` || Alignment of the component |
| `UNSAFE_className` | `string` ||| Wrapper custom classname |
| `UNSAFE_style` | `CSSProperties` ||| Wrapper custom style |

[deprecated]: https://github.com/lmc-eu/spirit-design-system/tree/main/packages/web-react/README.md#deprecations
[dictionary-placement]: https://github.com/lmc-eu/spirit-design-system/tree/main/docs/DICTIONARIES.md#placement
[dropdown]: https://github.com/lmc-eu/spirit-design-system/tree/main/packages/web/src/scss/components/Dropdown
[dropdownbreakpoint]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web-react/src/types/dropdown.ts#L11
[dropdownfullwidthmode]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web-react/src/types/dropdown.ts#L19
[dictionary-placement]: https://github.com/lmc-eu/spirit-design-system/tree/main/docs/DICTIONARIES.md#placement
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import React, { useRef } from 'react';
import classNames from 'classnames';
import { useStyleProps } from '../../hooks';
import { UncontrolledDropdownProps } from '../../types';
import { DropdownProvider } from './DropdownContext';
import { useDropdownStyleProps } from './useDropdownStyleProps';
import { useDropdown } from './useDropdown';

export const UncontrolledDropdown = (props: UncontrolledDropdownProps) => {
const { children, enableAutoClose = true, fullWidthMode, id, onAutoClose, placement, ...rest } = props;
const { classProps } = useDropdownStyleProps();
const { styleProps, props: otherProps } = useStyleProps({ ...rest });

const dropdownRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLDivElement>();

const { isOpen, toggleHandler: onToggle } = useDropdown({ dropdownRef, triggerRef, enableAutoClose, onAutoClose });

return (
<DropdownProvider value={{ id, isOpen, fullWidthMode, placement, onToggle, dropdownRef, triggerRef }}>
<div
ref={dropdownRef}
{...styleProps}
{...otherProps}
className={classNames(classProps.wrapperClassName, styleProps.className)}
>
{children}
</div>
</DropdownProvider>
);
};

export default UncontrolledDropdown;
Loading

0 comments on commit 3c0f08b

Please sign in to comment.