-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add Breadcrumb component (#464)
* 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
1 parent
04ed5f1
commit 841bfa4
Showing
19 changed files
with
4,213 additions
and
11 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 /> | ||
</> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 />} | ||
</> | ||
) | ||
} |
Oops, something went wrong.