Skip to content

Commit

Permalink
feat: add Breadcrumb component (#464)
Browse files Browse the repository at this point in the history
* feat: started configuring component

* feat: added stories

* feat: added style for nested levels

* feat: updated types and label handling

* feat: updated style for split button

* feat: added dropdown

* feat: updated style and props

* feat: added ellipsis on breadcrumb label

* feat: updated props

* feat: added onClick inside stories and removed story

* refactor: moved css class names

* refactor: removed important from css

* feat: added mocks page

* refactor: updated dropdown padding

* feat: added tests

* feat: added tsdocs

* feat: updated docs

* feat: started updating ellipsis

* feat: added skeleton and updated ellipsis

* feat: updated tests

* feat: updated ellipsis

* feat: added submenu

* feat: updated submenu style

* feat: removed props

* feat: updated css

* fix: updated snapshots

* refactor: small code refactor

* feat: added onClick on breadcrumb menu item

* feat: added default onChange

* feat: updated onSearch

* feat: updated stories and props

* Fixed keys and magic numbers

* refactor: cleaned class names

* refactor: updated css

* feat: updated props to handle controlled menu

* feat: updated collapse algorithm

* fix: fixed snapshots

* Items without menu

* Fixed calc

* Menu button

* Item style

* Button states

* Menu empty state

* Dropdown toggle

* Menu items

* Dropdown on trigger

* Search

* Story without menu

* Menu stories

* Menu onclick

* Destroy dropdown on close

* Rename files

* Collapse logic

* Get dropdown container

* Collapse logic

* Stories

* Tests

* Removed deps

* Fixed licence

* Added tests

* Fixed key find

* Fixed types

* Updated snapshot

* Coverage

* Review

* Update vite

* Items as required

* Rename function

* Snapshots

---------

Co-authored-by: federico.pini <[email protected]>
Co-authored-by: epessina <[email protected]>
  • Loading branch information
3 people authored Jun 19, 2024
1 parent 04ed5f1 commit 841bfa4
Show file tree
Hide file tree
Showing 19 changed files with 4,213 additions and 11 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"traverse": "^0.6.8",
"ts-jest": "^29.1.5",
"typescript": "^5.3.3",
"typescript-plugin-css-modules": "^5.1.0",
"vite": "^5.3.1",
"vite-plugin-dts": "^3.7.3",
"vite-plugin-svgr": "^3.3.0",
Expand Down
90 changes: 90 additions & 0 deletions src/components/Breadcrumb/Breadcrumb.Collapsed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* 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, Menu, Skeleton } from 'antd'
import { ReactElement, useMemo } from 'react'
import { ItemType } from 'antd/es/menu/hooks/useItems'
import classNames from 'classnames'

import { BodyS } from '../Typography/BodyX/BodyS'
import { BreadcrumbItemType } from './Breadcrumb.types'
import { BreadcrumbSeparator } from './Breadcrumb.Separator'
import { Icon } from '../Icon'
import { buildItemKey } from './Breadcrumb.utils'
import styles from './Breadcrumb.module.css'

type Props = {
isLoading?: boolean
getDropdownContainer: () => HTMLElement | undefined
items: BreadcrumbItemType[]
}

export const BREADCRUMB_COLLAPSED_WIDTH = 32

export const BreadcrumbCollapsed = ({ isLoading, getDropdownContainer, items }: Props): ReactElement => {
const menuItems = useMemo<ItemType[]>(() => {
return items.map<ItemType>((item, idx) => {
const maybeSubItem = item.menu?.items?.find(({ key }) => key && key === item.menu?.activeKey)
const labelText = maybeSubItem?.label ?? item.label
const labelIcon = maybeSubItem?.icon ?? item.icon

return {
className: !item.onClick ? styles.noInteraction : '',
key: buildItemKey(item, idx),
label: (
<BodyS ellipsis={{ rows: 1, tooltip: item?.label }}>
{labelText}
</BodyS>
),
icon: labelIcon ? <div>{labelIcon}</div> : undefined,
onClick: ({ domEvent }) => item.onClick?.(domEvent as React.MouseEvent<Element, MouseEvent>),
}
})
}, [items])

const dropdown = useMemo(() => {
return (
<div className={classNames([styles.dropdownMenuContainer, styles.dropdownCollapseMenuContainer])}>
<div className={styles.dropdownMenu}>
<Menu items={menuItems} />
</div>
</div>
)
}, [menuItems])

return isLoading
? <Skeleton.Button active />
: (
<>
<Dropdown
dropdownRender={() => dropdown}
getPopupContainer={(trigger) => getDropdownContainer() ?? trigger}
placement="bottomRight"
trigger={['click']}
>
<div
className={classNames([styles.breadcrumbItemButton, styles.breadcrumbItemButtonConnected])}
style={{ width: BREADCRUMB_COLLAPSED_WIDTH }}
>
<Icon name="PiDotsThree" size={16} />
</div>
</Dropdown>
<BreadcrumbSeparator />
</>
)
}
113 changes: 113 additions & 0 deletions src/components/Breadcrumb/Breadcrumb.Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* 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 { Input, Menu } from 'antd'
import { ReactElement, useMemo, useState } from 'react'
import { ItemType } from 'antd/es/menu/hooks/useItems'
import { debounce } from 'lodash-es'

import { BreadcrumbItemMenu, BreadcrumbItemMenuItem, BreadcrumbItemType, SearchOptions } from './Breadcrumb.types'
import { BodyS } from '../Typography/BodyX/BodyS'
import { Icon } from '../Icon'
import { buildMenuItemKey } from './Breadcrumb.utils'
import styles from './Breadcrumb.module.css'

type Props = {
item: BreadcrumbItemType
setOpen: (next: boolean) => void
}

export const getSearchOption = <K extends keyof SearchOptions, >(search: BreadcrumbItemMenu['search'], opt: K): SearchOptions[K] | undefined => {
return typeof search === 'boolean' ? undefined : search?.[opt]
}

export const BreadcrumbItemMenuDropdown = ({ item, setOpen }: Props): ReactElement => {
const [searchValue, setSearchValue] = useState('')

const filteredItems = useMemo<BreadcrumbItemMenuItem[]>(() => {
if (!item.menu?.items) { return [] }

if (searchValue === '') { return item.menu.items }

return item.menu.items.filter((subItem) => subItem.label?.toLowerCase().includes(searchValue.toLowerCase()))
}, [item, searchValue])

const menuItems = useMemo(() => filteredItems.map<ItemType>((menuItemData, currentIndex) => {
return {
key: buildMenuItemKey(menuItemData, currentIndex),
icon: menuItemData?.icon,
label: (
<BodyS ellipsis={{ rows: 1, tooltip: menuItemData?.label }}>
{menuItemData?.label}
</BodyS>
),
}
}), [filteredItems])

return (
<div className={styles.dropdownMenuContainer}>
{
Boolean(item.menu?.search) && (
<div className={styles.dropdownMenuSearch}>
<Input
allowClear={getSearchOption(item.menu?.search, 'allowClear')}
autoFocus
placeholder={getSearchOption(item.menu?.search, 'placeholder') ?? 'Search...'}
// @ts-expect-error size 12 is not accepted by Icon component but supported by underling SVG
suffix={<Icon name="PiMagnifyingGlass" size={12} />}
onChange={(event) => {
const onChange = getSearchOption(item.menu?.search, 'onChange')

if (onChange) {
onChange(event)
} else {
debounce(() => setSearchValue(event.target.value), 300)()
}
}}
/>
</div>
)
}
{
filteredItems.length > 0
? (
<div className={styles.dropdownMenu}>
<Menu
items={menuItems}
selectedKeys={item.menu?.activeKey ? [item.menu.activeKey] : []}
onClick={({ key, domEvent }) => {
item.menu?.onClick?.(key, domEvent)

if (item.menu?.isOpen === undefined) {
setOpen(false)
}
}}
/>
</div>
)
: (
<div className={styles.noItemsContainer}>
<BodyS>
{item.menu?.emptyText ?? 'No items'}
</BodyS>
</div>
)
}
</div>
)
}
166 changes: 166 additions & 0 deletions src/components/Breadcrumb/Breadcrumb.Item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/**
* 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, DropdownProps, Skeleton } from 'antd'
import { ReactElement, useCallback, useMemo, useState } from 'react'
import classNames from 'classnames'

import { BodyL } from '../Typography/BodyX/BodyL'
import { BreadcrumbItemMenuDropdown } from './Breadcrumb.Dropdown'
import { BreadcrumbItemType } from './Breadcrumb.types'
import { BreadcrumbSeparator } from './Breadcrumb.Separator'
import CaretFullDownSvg from './assets/caret-full-down.svg'
import styles from './Breadcrumb.module.css'

type Props = {
item: BreadcrumbItemType
getDropdownContainer: () => HTMLElement | undefined
isLoading?: boolean;
isHidden?: boolean;
isLastItem: boolean
}

export const BreadcrumbItem = ({
item,
getDropdownContainer,
isLoading,
isHidden,
isLastItem,
}: Props): ReactElement => {
const [dropdownOpen, setDropdownOpen] = useState(false)

const hasMenu = useMemo(() => Boolean(item.menu?.items), [item])

const label = useMemo(() => {
const maybeSubItem = item.menu?.items?.find(({ key }) => key && key === item.menu?.activeKey)
const labelText = maybeSubItem?.label ?? item.label
const labelIcon = maybeSubItem?.icon ?? item.icon

return (!labelText && !labelIcon)
? undefined
: (
<>
{labelIcon}
{
labelText && (
<div className={styles.breadcrumbItemLabelText}>
<BodyL ellipsis={{ rows: 1, tooltip: labelText }}>
{labelText}
</BodyL>
</div>
)
}
</>
)
}, [item])

const onItemClick = useCallback<Exclude<BreadcrumbItemType['onClick'], undefined>>((...args) => {
if (isHidden) { return }

return item.onClick?.(...args)
}, [isHidden, item])

const itemButton = useMemo(() => {
if (!label && !hasMenu) {
return (
<div
className={styles.breadcrumbItemButton}
onClick={onItemClick}
>
<div
className={classNames([
styles.breadcrumbItemButtonEmpty,
!item.onClick && styles.breadcrumbItemButtonNoInteraction,
])}
/>
</div>
)
}

const dropdownProps: DropdownProps = {
destroyPopupOnHide: true,
dropdownRender: () => <BreadcrumbItemMenuDropdown item={item} setOpen={setDropdownOpen} />,
getPopupContainer: (trigger) => getDropdownContainer() ?? trigger,
open: !isHidden && hasMenu && (item.menu?.isOpen !== undefined ? item.menu.isOpen : dropdownOpen),
placement: 'bottomLeft',
trigger: ['click'],
onOpenChange: (next: boolean): void => {
if (isHidden) { return }

item.menu?.onDropdownVisibleChange?.(next)

if (item.menu?.isOpen === undefined) {
setDropdownOpen(next)
}
},
}

const isButtonConnected = Boolean(!item.onClick && label && hasMenu)
if (isButtonConnected) {
return (
<Dropdown {...dropdownProps}>
<div className={classNames([isLastItem && styles.breadcrumbItemLast])}>
<div className={classNames([styles.breadcrumbItemButton, styles.breadcrumbItemButtonConnected])}>
{label}
<CaretFullDownSvg aria-label="caret-full-down" />
</div>
</div>
</Dropdown>
)
}

return (
<div className={classNames([isLastItem && styles.breadcrumbItemLast])}>
<div
className={classNames([
styles.breadcrumbItemButton,
styles.breadcrumbItemButtonSegmented,
!item.onClick && styles.breadcrumbItemButtonNoInteraction,
])}
>
{
label && (
<div
className={classNames([styles.breadcrumbItemLabel, hasMenu && styles.withMenu])}
onClick={onItemClick}
>
{label}
</div>
)
}
{
hasMenu && (
<Dropdown {...dropdownProps}>
<div className={classNames([styles.breadcrumbMenuIcon, label && styles.withLabel])}>
<CaretFullDownSvg aria-label="caret-full-down" />
</div>
</Dropdown>
)
}
</div>
</div>
)
}, [label, hasMenu, isHidden, item, dropdownOpen, isLastItem, onItemClick, getDropdownContainer])

return (
<>
{isLoading ? <Skeleton.Button active /> : itemButton}
{!isLastItem && !isHidden && <BreadcrumbSeparator />}
</>
)
}
Loading

0 comments on commit 841bfa4

Please sign in to comment.