Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Introduce alignment to Dropdown component #DS-1411 #1837

Merged
merged 3 commits into from
Jan 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 42 additions & 16 deletions packages/web-react/src/components/Dropdown/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,20 +52,43 @@ import { UncontrolledDropdown, DropdownTrigger, DropdownPopover } from '@lmc-eu/

### Dropdown

| Name | Type | Default | Required | Description |
| ----------------- | -------------------------------------------------- | -------------- | -------- | ---------------------------------------------- |
| `enableAutoClose` | `bool` | `true` | ✕ | Enables close on click outside of Dropdown |
| `fullWidthMode` | [`DropdownFullWidthMode`][dropdown-fullwidth-mode] | `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-start` | ✕ | Alignment of the component |
| Name | Type | Default | Required | Description |
| ----------------- | --------------------------------------------------------------------- | -------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `alignmentX` | \[ [AlignmentXExtended dictionary][dictionary-alignment] \| `object`] | `null` | ✕ | Apply vertical alignment to trigger, use object to set responsive values, e.g. `{ mobile: 'left', tablet: 'center', desktop: 'right' }` |
| `alignmentY` | \[ [AlignmentYExtended dictionary][dictionary-alignment] \| `object`] | `null` | ✕ | Apply horizontal alignment to trigger, use object to set responsive values, e.g. `{ mobile: 'top', tablet: 'center', desktop: 'bottom' }` |
crishpeen marked this conversation as resolved.
Show resolved Hide resolved
| `enableAutoClose` | `bool` | `true` | ✕ | Enables close on click outside of Dropdown |
| `fullWidthMode` | [`DropdownFullWidthMode`][dropdown-fullwidth-mode] | `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-start` | ✕ | Alignment of the component |

On top of the API options, the components accept [additional attributes][readme-additional-attributes].
If you need more control over the styling of a component, you can use [style props][readme-style-props]
and [escape hatches][readme-escape-hatches].

#### Alignment

Dropdown supports the extended [Alignment Dictionary][dictionary-alignment] for alignment on both axes. To use it, set the
specific prop to the `Dropdown` component, e.g. `<Dropdown alignmentX="right" />` or `<Dropdown alignmentY="stretch" />`. Adding
any of these props will make the element display as `flex`.

We also support responsive alignment props. To use them, set the prop as an object,
e.g. `<Dropdown alignmentX={{ mobile: 'right', tablet: 'left', desktop: 'center' }} />`.

ℹ️ This controls only the alignment inside the wrapping `Dropdown` element. And even with alignment, the popover will still be positioned
at edge of the `Dropdown` element and on the place defined by the placement attribute.

```jsx
<Dropdown alignmentX={{ mobile: 'right', tablet: 'left', desktop: 'center' }} alignmentY="center" id="#dropdown-alignment">
<DropdownTrigger elementType={Button}>Button as anchor</DropdownTrigger>
<DropdownPopover>
<!-- ... -->
</DropdownPopover>
</Dropdown>
```

### DropdownTrigger

| Name | Type | Default | Required | Description |
Expand All @@ -89,18 +112,21 @@ and [escape hatches][readme-escape-hatches].

### UncontrolledDropdown

| Name | Type | Default | Required | Description |
| ----------------- | -------------------------------------------------- | -------------- | -------- | ---------------------------------------------- |
| `enableAutoClose` | `bool` | `true` | ✕ | Enables close on click outside of Dropdown |
| `fullWidthMode` | [`DropdownFullWidthMode`][dropdown-fullwidth-mode] | `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-start` | ✕ | Alignment of the component |
| Name | Type | Default | Required | Description |
| ----------------- | --------------------------------------------------------------------- | -------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `alignmentX` | \[ [AlignmentXExtended dictionary][dictionary-alignment] \| `object`] | `null` | ✕ | Apply vertical alignment to trigger, use object to set responsive values, e.g. `{ mobile: 'left', tablet: 'center', desktop: 'right' }` |
| `alignmentY` | \[ [AlignmentYExtended dictionary][dictionary-alignment] \| `object`] | `null` | ✕ | Apply horizontal alignment to trigger, use object to set responsive values, e.g. `{ mobile: 'top', tablet: 'center', desktop: 'bottom' }` |
| `enableAutoClose` | `bool` | `true` | ✕ | Enables close on click outside of Dropdown |
| `fullWidthMode` | [`DropdownFullWidthMode`][dropdown-fullwidth-mode] | `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-start` | ✕ | Alignment of the component |

On top of the API options, the components accept [additional attributes][readme-additional-attributes].
If you need more control over the styling of a component, you can use [style props][readme-style-props]
and [escape hatches][readme-escape-hatches].

[dictionary-alignment]: https://github.com/lmc-eu/spirit-design-system/tree/main/docs/DICTIONARIES.md#alignment
[dictionary-placement]: https://github.com/lmc-eu/spirit-design-system/tree/main/docs/DICTIONARIES.md#placement
[dropdown-fullwidth-mode]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web-react/src/types/dropdown.ts#L19
[item]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web-react/src/components/Item/README.md
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import '@testing-library/jest-dom';
import { render, screen, fireEvent } from '@testing-library/react';
import React from 'react';
import { classNamePrefixProviderTest, restPropsTest, stylePropsTest } from '@local/tests';
import { DropdownAlignmentXType, DropdownAlignmentYType } from '../../../types';
import Dropdown from '../Dropdown';
import DropdownPopover from '../DropdownPopover';
import DropdownTrigger from '../DropdownTrigger';
Expand All @@ -19,14 +20,15 @@ describe('Dropdown', () => {
restPropsTest(Dropdown, '.Dropdown');

it('should render text children', () => {
const dom = render(
render(
<Dropdown id="dropdown" isOpen={false} onToggle={() => {}}>
<DropdownTrigger>Trigger</DropdownTrigger>
<DropdownPopover>Hello World</DropdownPopover>
<DropdownPopover data-testid="dropdown-popover">Hello World</DropdownPopover>
</Dropdown>,
);

const trigger = screen.getByRole('button');
const element = dom.container.querySelector('.DropdownPopover') as HTMLElement;
const element = screen.getByTestId('dropdown-popover') as HTMLElement;

expect(trigger).toHaveTextContent('Trigger');
expect(element).toHaveTextContent('Hello World');
Expand All @@ -35,13 +37,14 @@ describe('Dropdown', () => {
it('should be opened', () => {
const onToggle = jest.fn();

const dom = render(
render(
<Dropdown id="dropdown" isOpen onToggle={onToggle}>
<DropdownTrigger>trigger</DropdownTrigger>
<DropdownPopover>Hello World</DropdownPopover>
<DropdownPopover data-testid="dropdown-popover">Hello World</DropdownPopover>
</Dropdown>,
);
const element = dom.container.querySelector('.DropdownPopover') as HTMLElement;

const element = screen.getByTestId('dropdown-popover') as HTMLElement;
const trigger = screen.getByRole('button');

expect(element).toHaveClass('is-open');
Expand All @@ -51,16 +54,57 @@ describe('Dropdown', () => {
it('should call toggle function', () => {
const onToggle = jest.fn();

const dom = render(
render(
<Dropdown id="dropdown" isOpen={false} onToggle={onToggle}>
<DropdownTrigger>trigger</DropdownTrigger>
<DropdownPopover>Hello World</DropdownPopover>
</Dropdown>,
);
const trigger = dom.container.querySelector('button') as HTMLElement;

const trigger = screen.getByRole('button') as HTMLElement;
fireEvent.click(trigger);

expect(onToggle).toHaveBeenCalled();
});

describe('Alignment tests', () => {
const alignmentTests: Array<[unknown, unknown, string]> = [
['center', undefined, 'Dropdown Dropdown--alignmentXCenter'],
['center', 'center', 'Dropdown Dropdown--alignmentXCenter Dropdown--alignmentYCenter'],
[
{ tablet: 'center', desktop: 'right' },
undefined,
'Dropdown Dropdown--tablet--alignmentXCenter Dropdown--desktop--alignmentXRight',
],
[
{ mobile: 'left', tablet: 'center', desktop: 'right' },
undefined,
'Dropdown Dropdown--alignmentXLeft Dropdown--tablet--alignmentXCenter Dropdown--desktop--alignmentXRight',
],
[
{ mobile: 'left', tablet: 'center', desktop: 'right' },
{ mobile: 'top', tablet: 'center', desktop: 'bottom' },
'Dropdown Dropdown--alignmentXLeft Dropdown--tablet--alignmentXCenter Dropdown--desktop--alignmentXRight Dropdown--alignmentYTop Dropdown--tablet--alignmentYCenter Dropdown--desktop--alignmentYBottom',
],
];

it.each(alignmentTests)(
'should render alignmentX=%o and alignmentY=%o',
(alignmentX, alignmentY, expectedClass) => {
render(
<Dropdown
alignmentX={alignmentX as DropdownAlignmentXType}
alignmentY={alignmentY as DropdownAlignmentYType}
data-testid="dropdown"
id="dropdown"
isOpen={false}
onToggle={() => {}}
/>,
);

// If your component *always* applies the 'Dropdown' class, include it in the expectation:
expect(screen.getByTestId('dropdown')).toHaveClass(expectedClass);
},
);
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { renderHook } from '@testing-library/react';
import { SpiritDropdownProps } from '../../../types';
import { useDropdownStyleProps } from '../useDropdownStyleProps';

describe('useDropdownStyleProps', () => {
Expand Down Expand Up @@ -29,4 +30,31 @@ describe('useDropdownStyleProps', () => {
expect(result.current.classProps.popover).toBe('DropdownPopover');
expect(result.current.props).toEqual({ transferProp: 'test' });
});

it.each([
// alignmentX, alignmentY, expectedClasses
[undefined, undefined, 'Dropdown'],
['left', undefined, 'Dropdown Dropdown--alignmentXLeft'],
['left', 'top', 'Dropdown Dropdown--alignmentXLeft Dropdown--alignmentYTop'],
[
{ mobile: 'left', tablet: 'center', desktop: 'right' },
undefined,
'Dropdown Dropdown--alignmentXLeft Dropdown--tablet--alignmentXCenter Dropdown--desktop--alignmentXRight',
],
[
{ mobile: 'left', tablet: 'center', desktop: 'right' },
{ mobile: 'top', tablet: 'center', desktop: 'bottom' },
'Dropdown Dropdown--alignmentXLeft Dropdown--tablet--alignmentXCenter Dropdown--desktop--alignmentXRight Dropdown--alignmentYTop Dropdown--tablet--alignmentYCenter Dropdown--desktop--alignmentYBottom',
],
[
'left',
{ mobile: 'top', tablet: 'center', desktop: 'bottom' },
'Dropdown Dropdown--alignmentXLeft Dropdown--alignmentYTop Dropdown--tablet--alignmentYCenter Dropdown--desktop--alignmentYBottom',
],
])('should return alignment CSS classes', (alignmentX, alignmentY, expectedClasses) => {
const props: SpiritDropdownProps = { alignmentX, alignmentY } as SpiritDropdownProps;
const { result } = renderHook(() => useDropdownStyleProps(props));

expect(result.current.classProps.root).toBe(expectedClasses);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from 'react';
import { Box } from '../../Box';
import { Button } from '../../Button';
import { Grid } from '../../Grid';
import { Item } from '../../Item';
import Dropdown from '../Dropdown';
import DropdownPopover from '../DropdownPopover';
import DropdownTrigger from '../DropdownTrigger';

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

return (
<Grid cols={2}>
<Dropdown
alignmentX={{ mobile: 'right', tablet: 'left', desktop: 'center' }}
alignmentY="center"
id="dropdown-alignment"
isOpen={isOpen}
onToggle={onToggle}
placement="bottom"
>
<DropdownTrigger elementType={Button}>Button as anchor</DropdownTrigger>
<DropdownPopover>
<Item elementType="a" href="#" label="Action" />
<Item elementType="a" href="#" label="Another action" />
<Item elementType="a" href="#" label="Something else here" />
</DropdownPopover>
</Dropdown>
<Box paddingX="space-800" paddingY="space-1300" backgroundColor="tertiary" UNSAFE_className="text-center">
This is a big unrelated box to demonstrate the Dropdown Trigger alignment
</Box>
</Grid>
);
};

export default DropdownAlignment;
4 changes: 4 additions & 0 deletions packages/web-react/src/components/Dropdown/demo/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import DocsSection from '../../../../docs/DocsSections';
import { IconsProvider } from '../../../context';
import DropdownAlignment from './DropdownAlignment';
import DropdownDisabledAutoclose from './DropdownDisabledAutoclose';
import DropdownFullwidthAll from './DropdownFullwidthAll';
import DropdownFullwidthMobileOnly from './DropdownFullwidthMobileOnly';
Expand All @@ -20,6 +21,9 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<DocsSection title="Placements" stackAlignment="stretch">
<DropdownPlacements />
</DocsSection>
<DocsSection title="Alignment" stackAlignment="stretch">
<DropdownAlignment />
</DocsSection>
<DocsSection title="Various items">
<DropdownVariousItems />
</DocsSection>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Markdown } from '@storybook/blocks';
import type { Meta, StoryObj } from '@storybook/react';
import React, { useState } from 'react';
import { Button, Icon, Text } from '../..';
import { Placements } from '../../../constants';
import { AlignmentXExtended, AlignmentYExtended, Placements } from '../../../constants';
import { DropdownFullWidthModes, SpiritDropdownProps } from '../../../types';
import ReadMe from '../README.md';
import { Dropdown, DropdownTrigger, DropdownPopover } from '..';
Expand All @@ -17,6 +17,20 @@ const meta: Meta<typeof Dropdown> = {
layout: 'centered',
},
argTypes: {
alignmentX: {
control: 'select',
options: [undefined, ...Object.values(AlignmentXExtended)],
table: {
defaultValue: { summary: undefined },
},
},
alignmentY: {
control: 'select',
options: [undefined, ...Object.values(AlignmentYExtended)],
table: {
defaultValue: { summary: undefined },
},
},
children: {
control: 'object',
},
Expand Down Expand Up @@ -45,6 +59,8 @@ const meta: Meta<typeof Dropdown> = {
},
},
args: {
alignmentX: undefined,
alignmentY: undefined,
children: (
<>
<a href="#info" className="d-flex mb-400">
Expand Down
Loading
Loading