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: add Breadcrumb component #464

Merged
merged 69 commits into from
Jun 19, 2024
Merged
Show file tree
Hide file tree
Changes from 68 commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
5fdab32
feat: started configuring component
May 20, 2024
7719671
feat: added stories
May 20, 2024
1d0be1b
feat: added style for nested levels
May 21, 2024
eced28e
feat: updated types and label handling
May 21, 2024
92d34c7
feat: updated style for split button
May 22, 2024
4a6f393
feat: added dropdown
May 22, 2024
edfcdb9
feat: updated style and props
May 22, 2024
f80fd9a
feat: added ellipsis on breadcrumb label
May 23, 2024
986b4d2
feat: updated props
May 24, 2024
97fb76d
feat: added onClick inside stories and removed story
May 27, 2024
8b3d625
refactor: moved css class names
May 27, 2024
0756aa4
refactor: removed important from css
May 27, 2024
309fd2a
feat: added mocks page
May 27, 2024
7176bc1
refactor: updated dropdown padding
May 27, 2024
fd900d1
feat: added tests
May 27, 2024
53552e5
feat: added tsdocs
May 27, 2024
948cf99
feat: updated docs
May 27, 2024
4cfaf86
feat: started updating ellipsis
May 28, 2024
5e06ca7
feat: added skeleton and updated ellipsis
May 28, 2024
db04b03
feat: updated tests
May 28, 2024
bf62f71
feat: updated ellipsis
May 29, 2024
5475fd6
feat: added submenu
May 29, 2024
2cf83bc
feat: updated submenu style
May 29, 2024
43356bb
feat: removed props
May 29, 2024
84e870e
feat: updated css
May 29, 2024
d8ace14
fix: updated snapshots
May 29, 2024
fd7f2ae
refactor: small code refactor
May 30, 2024
553effc
feat: added onClick on breadcrumb menu item
May 31, 2024
56092a1
feat: added default onChange
May 31, 2024
bc70e35
feat: updated onSearch
May 31, 2024
d4218dd
feat: updated stories and props
May 31, 2024
612b5ec
Fixed keys and magic numbers
epessina May 31, 2024
ce39470
refactor: cleaned class names
Jun 3, 2024
2aa02be
refactor: updated css
Jun 3, 2024
0cbaa89
feat: updated props to handle controlled menu
Jun 4, 2024
bd74f7a
feat: updated collapse algorithm
Jun 4, 2024
44cda4c
fix: fixed snapshots
Jun 4, 2024
dc50669
Items without menu
epessina Jun 6, 2024
f06c2e1
Fixed calc
epessina Jun 6, 2024
70bc1b3
Menu button
epessina Jun 7, 2024
237be87
Item style
epessina Jun 7, 2024
ce6bf9d
Button states
epessina Jun 7, 2024
4634743
Menu empty state
epessina Jun 7, 2024
3a564e3
Dropdown toggle
epessina Jun 7, 2024
ed6d298
Menu items
epessina Jun 7, 2024
3b2b627
Dropdown on trigger
epessina Jun 7, 2024
b428b13
Search
epessina Jun 8, 2024
11e83bf
Story without menu
epessina Jun 9, 2024
604506d
Menu stories
epessina Jun 9, 2024
79c6ad9
Menu onclick
epessina Jun 10, 2024
0faf59b
Destroy dropdown on close
epessina Jun 10, 2024
6e13202
Rename files
epessina Jun 10, 2024
e33d3e9
Collapse logic
epessina Jun 11, 2024
4a080e8
Get dropdown container
epessina Jun 11, 2024
4bb0f84
Collapse logic
epessina Jun 12, 2024
e2d312e
Stories
epessina Jun 12, 2024
952245f
Tests
epessina Jun 12, 2024
dd008b2
Removed deps
epessina Jun 12, 2024
0a8f745
Fixed licence
epessina Jun 12, 2024
46b92a2
Added tests
epessina Jun 12, 2024
fb4612b
Fixed key find
epessina Jun 13, 2024
076ed18
Fixed types
epessina Jun 13, 2024
353d499
Updated snapshot
epessina Jun 13, 2024
18919fc
Coverage
epessina Jun 13, 2024
f825376
Review
epessina Jun 17, 2024
74299bb
Update vite
epessina Jun 18, 2024
a492194
Items as required
epessina Jun 18, 2024
9d891f2
Rename function
epessina Jun 18, 2024
880232e
Snapshots
epessina Jun 19, 2024
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
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
Loading