Skip to content

Commit

Permalink
feat(dropwdown): added Dropdown component (#570)
Browse files Browse the repository at this point in the history
* feat(dropdown): first dropdown component implementation

* refactor: changed fn name

* refactor: fixed newline

* docs: better props

* feat: danger ui

* refactor: removed comment

* refactor: extracted eventAdapter

* refactor: innernode oneliner

* rem: unused style

* docs: danger doc

* fix: danger ui

* refactor: module css new line
  • Loading branch information
fredmaggiowski authored Aug 8, 2024
1 parent 7aa0552 commit a3e3df4
Show file tree
Hide file tree
Showing 9 changed files with 593 additions and 0 deletions.
71 changes: 71 additions & 0 deletions src/components/Dropdown/Dropdown.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Copyright 2024 Mia srl
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
*/

import type { Meta, StoryObj } from '@storybook/react'
import { action } from '@storybook/addon-actions'

import { DropdownProps, DropdownTrigger } from './props'
import { Button } from '../Button'
import { Dropdown } from '.'

const defaults: Partial<DropdownProps> = {
items: [{
id: 'id1',
label: 'value 1',
}, {
id: 'id2',
label: 'value 2',
secondaryLabel: 'Some additional info 2',
}, {
id: 'id3',
label: 'I am danger!',
danger: true,
secondaryLabel: 'Some additional info 3',
}],
triggers: [DropdownTrigger.Click],
children: <Button >{'click me'}</Button>,
onClick: action('on click'),
}

const meta = {
component: Dropdown,
args: defaults,
argTypes: {
children: { control: false },
},
render: (_, { args }) => <Dropdown {...args} />,
} satisfies Meta<typeof Dropdown>

type Story = StoryObj<typeof meta>

export default meta

export const BasicExample: Story = {}

export const Disabled: Story = {
args: {
isDisabled: true,
},
}

export const ContextMenuTrigger: Story = {
args: {
children: <span>{'right-click on me'}</span>,
triggers: [DropdownTrigger.ContextMenu],
},
}
99 changes: 99 additions & 0 deletions src/components/Dropdown/Dropdown.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Copyright 2024 Mia srl
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
*/

import { DropdownItem, DropdownProps, DropdownTrigger } from './props'
import { RenderResult, render, screen, userEvent, waitFor } from '../../test-utils'
import { Button } from '../Button'
import { Dropdown } from './Dropdown'

const items: DropdownItem[] = [
{ id: '1', label: 'Label 1' },
{ id: '2', label: 'Label 2', secondaryLabel: 'Additional Info 2' },
{ id: '3', label: 'Danger Label', secondaryLabel: 'Additional Info 2', danger: true },
]
const defaultProps: DropdownProps = {
items,
children: <Button>{'test-trigger-button'}</Button>,
onClick: jest.fn(),
}

describe('Dropdown Component', () => {
beforeEach(() => jest.clearAllMocks())

describe('triggers', () => {
it('opens dropdown on click', async() => {
renderDropdown()
const button = screen.getByText('test-trigger-button')
userEvent.click(button)
await screen.findByRole('menuitem', { name: 'Label 1' })
})

it('opens dropdown on hover', async() => {
renderDropdown({ props: { ...defaultProps, triggers: [DropdownTrigger.Hover] } })
const button = screen.getByText('test-trigger-button')
userEvent.hover(button)
await screen.findByRole('menuitem', { name: 'Label 1' })
})
})

describe('label layouts', () => {
it('render labels', async() => {
renderDropdown({ props: { ...defaultProps, items } })
const button = screen.getByText('test-trigger-button')
userEvent.click(button)

await screen.findByRole('menuitem', { name: 'Label 1' })

expect(screen.getAllByRole('menuitem')).toHaveLength(3)

const [first, second, third] = screen.getAllByRole('menuitem')
expect(first).toMatchSnapshot()
expect(second).toMatchSnapshot()
expect(third).toMatchSnapshot()
})
})

describe('onClick', () => {
it('invokes onClick with correct id', async() => {
const onClick = jest.fn()
const props = {
...defaultProps,
onClick,
}
renderDropdown({ props })
const button = screen.getByText('test-trigger-button')
userEvent.click(button)

const item = await screen.findByRole('menuitem', { name: 'Label 1' })
userEvent.click(item)

await waitFor(() => expect(onClick).toHaveBeenCalled())
expect(onClick).toHaveBeenCalledTimes(1)
const [[invocation]] = onClick.mock.calls
expect(invocation.id).toEqual('1')
expect(invocation.selectedPath).toEqual(['1'])
expect(invocation.item).toEqual(items[0])
})
})
})

function renderDropdown(
{ props }: {props: DropdownProps} = { props: defaultProps }
): RenderResult {
return render(<Dropdown {...props} />)
}
105 changes: 105 additions & 0 deletions src/components/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* Copyright 2024 Mia srl
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
*/

import { Dropdown as AntdDropdown, type MenuProps as AntdMenuProps } from 'antd'
import React, { ReactElement, ReactNode, useCallback, useMemo } from 'react'

import { DropdownClickEvent, DropdownItem, DropdownProps, DropdownTrigger } from './props'
import Label from './components/Label/Label'
import styles from './dropdown.module.css'

type ArrayElement<ArrayType extends readonly unknown[] | undefined> =
ArrayType extends readonly (infer ElementType)[] ? ElementType : never;

type AntdMenuItems = AntdMenuProps['items']
type AntdMenuItem = ArrayElement<AntdMenuItems>

type AntdMenuClickEvent = {
key: string,
keyPath: string[],
domEvent: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>
}

export const defaults = {
trigger: [DropdownTrigger.Click],
}

const itemMatcher = (id: string) => (item: DropdownItem): boolean => item.id === id

export const Dropdown = ({
autoFocus,
children,
isDisabled,
items,
onClick,
triggers,
}: DropdownProps): ReactElement => {
const findItem = useCallback((id: string) => items.find(itemMatcher(id)), [items])

const antdItems = useMemo<AntdMenuItems>(() => itemsAdapter(items), [items])
const innerNode = useMemo(() => (children ? <span>{children}</span> : null), [children])

const onAntdMenuClick = useCallback(
(antdEvent: AntdMenuClickEvent) => onClick(eventAdapter(antdEvent, findItem)),
[findItem, onClick]
)

const dropdownRender = useCallback((menu: ReactNode): ReactNode => {
return React.cloneElement(menu as ReactElement)
}, [])

const menu = useMemo(() => ({
items: antdItems,
onClick: onAntdMenuClick,
}), [antdItems, onAntdMenuClick])

return (
<AntdDropdown
autoFocus={autoFocus}
disabled={isDisabled}
dropdownRender={dropdownRender}
menu={menu}
overlayClassName={styles.dropdownWrapper}
trigger={triggers}
>
{innerNode}
</AntdDropdown>
)
}

Dropdown.Trigger = DropdownTrigger

function itemsAdapter(items: DropdownItem[]): AntdMenuItems {
return items.map<AntdMenuItem>((item: DropdownItem) => ({
label: <Label {...item} />,
key: item.id,
danger: item.danger,
}))
}

function eventAdapter(
event: AntdMenuClickEvent,
itemFinder: (id: string) => DropdownItem|undefined,
): DropdownClickEvent {
return {
id: event.key,
selectedPath: event.keyPath,
domEvent: event.domEvent,
item: itemFinder(event.key),
}
}
90 changes: 90 additions & 0 deletions src/components/Dropdown/__snapshots__/Dropdown.test.tsx.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Dropdown Component label layouts render labels 1`] = `
<li
class="mia-platform-dropdown-menu-item mia-platform-dropdown-menu-item-only-child"
data-menu-id="rc-menu-uuid-test-1"
role="menuitem"
tabindex="-1"
>
<span
class="mia-platform-dropdown-menu-title-content"
>
<div
class="labelContainer"
>
<span
class="primaryLabel"
>
Label 1
</span>
</div>
</span>
</li>
`;

exports[`Dropdown Component label layouts render labels 2`] = `
<li
class="mia-platform-dropdown-menu-item mia-platform-dropdown-menu-item-only-child"
data-menu-id="rc-menu-uuid-test-2"
role="menuitem"
tabindex="-1"
>
<span
class="mia-platform-dropdown-menu-title-content"
>
<div
class="labelContainer"
>
<span
class="primaryLabel"
>
Label 2
</span>
<span
class="secondaryLabel"
>
·
</span>
<span
class="secondaryLabel"
>
Additional Info 2
</span>
</div>
</span>
</li>
`;

exports[`Dropdown Component label layouts render labels 3`] = `
<li
class="mia-platform-dropdown-menu-item mia-platform-dropdown-menu-item-danger mia-platform-dropdown-menu-item-only-child"
data-menu-id="rc-menu-uuid-test-3"
role="menuitem"
tabindex="-1"
>
<span
class="mia-platform-dropdown-menu-title-content"
>
<div
class="labelContainer"
>
<span
class="primaryLabel danger"
>
Danger Label
</span>
<span
class="secondaryLabel danger"
>
·
</span>
<span
class="secondaryLabel danger"
>
Additional Info 2
</span>
</div>
</span>
</li>
`;
Loading

0 comments on commit a3e3df4

Please sign in to comment.